From 95ef2bbd69d4b167553c69f96a2ddbda66686091 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 16:58:56 +0100 Subject: [PATCH 01/11] add extension, localFile and isDownloaded getters to File --- lib/file/data.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/file/data.dart b/lib/file/data.dart index 30a6d54f..80cd7ae0 100644 --- a/lib/file/data.dart +++ b/lib/file/data.dart @@ -1,5 +1,8 @@ +import 'dart:io' as io; + import 'package:hive/hive.dart'; import 'package:meta/meta.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/course/course.dart'; import 'package:time_machine/time_machine.dart'; @@ -68,6 +71,24 @@ class File implements Entity, Comparable { @HiveField(1) final String name; + String get extension { + final lastDot = name.lastIndexOf('.'); + return lastDot == null ? null : name.substring(lastDot + 1); + } + + Future get localFile async { + final directory = await getExternalStorageDirectory(); + final fileName = extension == null ? id.toString() : '$id.$extension'; + return io.File('${directory.path}/$fileName'); + } + + Future get isDownloaded async { + // Calling `.existsSync()` is much faster than using `exists()`. But in + // this case, latency is much more important than throughput: This code is + // executed during rendering and we don't want any jank. + // ignore: avoid_slow_async_io + return (await localFile).exists(); + } /// An [Id] for either a [User] or [Course]. @HiveField(3) From 3cfca679d992d517d6160598e7ddf7a4c8def3ef Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 16:59:19 +0100 Subject: [PATCH 02/11] use File's extension getter instead of relying on a custom implementation --- lib/file/widgets/file_thumbnail.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/file/widgets/file_thumbnail.dart b/lib/file/widgets/file_thumbnail.dart index c5b6d64c..ab2c5999 100644 --- a/lib/file/widgets/file_thumbnail.dart +++ b/lib/file/widgets/file_thumbnail.dart @@ -43,7 +43,7 @@ class FileThumbnail extends StatelessWidget { if (file.isDirectory) { return Icon(Icons.folder); } - final type = file.name.substring(file.name.lastIndexOf('.') + 1); + final type = file.extension; final assetPath = supportedThumbnails.contains(type) ? 'assets/file_thumbnails/${type}s.png' : 'assets/file_thumbnails/default.png'; From 56d92844e85781512e38f22b6efe6044004c735e Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 16:59:36 +0100 Subject: [PATCH 03/11] show offline pin if file is downloaded --- lib/file/widgets/file_tile.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/file/widgets/file_tile.dart b/lib/file/widgets/file_tile.dart index 6303363c..7831d0b0 100644 --- a/lib/file/widgets/file_tile.dart +++ b/lib/file/widgets/file_tile.dart @@ -25,6 +25,14 @@ class FileTile extends StatelessWidget { title: Text(file.name), subtitle: Text(subtitle), leading: FileThumbnail(file: file), + trailing: FutureBuilder( + future: file.isDownloaded, + builder: (context, snapshot) { + return snapshot.data == true + ? Icon(Icons.offline_pin) + : SizedBox.shrink(); + }, + ), onTap: () => onOpen(file), onLongPress: () => FileMenu.show(context, file), ); From 08342b19c0700e1c23e0d949c46a629e948957e1 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 17:00:48 +0100 Subject: [PATCH 04/11] rely on open_file package --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 0cc607df..13431b73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: logger_flutter: ^0.7.1 meta: ^1.1.8 mime: ^0.9.6+3 + open_file: ^3.0.1 package_info: ^0.4.0+3 pedantic: ^1.8.0+1 path_provider: ^1.1.0 From 9b80356a3de0c4666515e0f069b5a07b554d1ea5 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 17:01:32 +0100 Subject: [PATCH 05/11] simplify ensureStoragePermissionGranted and add openFile method --- lib/file/bloc.dart | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/file/bloc.dart b/lib/file/bloc.dart index 0ec362ea..0e276db3 100644 --- a/lib/file/bloc.dart +++ b/lib/file/bloc.dart @@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; +import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:schulcloud/app/app.dart'; @@ -31,6 +33,13 @@ class UploadProgressUpdate { class FileBloc { const FileBloc(); + Future openFile(File file) async { + if (!(await file.isDownloaded)) { + await downloadFile(file); + } + await OpenFile.open((await file.localFile).path); + } + Future downloadFile(File file) async { assert(file != null); @@ -44,25 +53,22 @@ class FileBloc { ); final signedUrl = json.decode(response.body)['url']; + print((await getExternalStorageDirectory()).path); + final localFile = await file.localFile; + await FlutterDownloader.enqueue( url: signedUrl, - savedDir: '/sdcard/Download', - fileName: file.name, + savedDir: localFile.dirName, + fileName: localFile.name, showNotification: true, - openFileFromNotification: true, + // openFileFromNotification: true, ); } Future ensureStoragePermissionGranted() async { - final permissions = await PermissionHandler() - .checkPermissionStatus(PermissionGroup.storage); - bool isGranted() => permissions.value != 0; - - if (isGranted()) { - return; - } - await PermissionHandler().requestPermissions([PermissionGroup.storage]); - if (!isGranted()) { + final permissions = + await PermissionHandler().requestPermissions([PermissionGroup.storage]); + if (permissions[PermissionGroup.storage] != PermissionStatus.granted) { throw PermissionNotGranted(); } } From f3bf1ed40999917f03f297455bc199a46a492ede Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 17:01:42 +0100 Subject: [PATCH 06/11] use openFile instead of downloadFile method --- lib/assignment/widgets/assignment_details_screen.dart | 2 +- lib/file/widgets/file_browser.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/assignment/widgets/assignment_details_screen.dart b/lib/assignment/widgets/assignment_details_screen.dart index 34839cbf..9d7244c6 100644 --- a/lib/assignment/widgets/assignment_details_screen.dart +++ b/lib/assignment/widgets/assignment_details_screen.dart @@ -392,7 +392,7 @@ List _buildFileSection( final file = update.data; return FileTile( file: file, - onOpen: (file) => services.get().downloadFile(file), + onOpen: (file) => services.get().openFile(file), ); }, ), diff --git a/lib/file/widgets/file_browser.dart b/lib/file/widgets/file_browser.dart index 7dbac6d1..2641ca3f 100644 --- a/lib/file/widgets/file_browser.dart +++ b/lib/file/widgets/file_browser.dart @@ -50,7 +50,7 @@ class FileBrowser extends StatelessWidget { assert(file.isActualFile); try { - await services.get().downloadFile(file); + await services.get().openFile(file); unawaited(services.snackBar .showMessage(context.s.file_fileBrowser_downloading(file.name))); } on PermissionNotGranted { From 0cdf1f5edb2fe93dd4e6212682844b441a2c3423 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 18:05:18 +0100 Subject: [PATCH 07/11] clean up code --- lib/file/bloc.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/file/bloc.dart b/lib/file/bloc.dart index 0e276db3..840d4684 100644 --- a/lib/file/bloc.dart +++ b/lib/file/bloc.dart @@ -9,7 +9,6 @@ import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; import 'package:open_file/open_file.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:schulcloud/app/app.dart'; @@ -53,7 +52,6 @@ class FileBloc { ); final signedUrl = json.decode(response.body)['url']; - print((await getExternalStorageDirectory()).path); final localFile = await file.localFile; await FlutterDownloader.enqueue( @@ -61,7 +59,7 @@ class FileBloc { savedDir: localFile.dirName, fileName: localFile.name, showNotification: true, - // openFileFromNotification: true, + openFileFromNotification: true, ); } From 78ddc548c85f76b6a838cedd4f6c8434deafcc5d Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 18 Mar 2020 18:14:30 +0100 Subject: [PATCH 08/11] Update pubspec.lock --- pubspec.lock | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 25544069..4a0bdb55 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -176,6 +176,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" file_picker: dependency: "direct main" description: @@ -215,7 +222,7 @@ packages: name: flutter_cached url: "https://pub.dartlang.org" source: hosted - version: "4.2.4" + version: "4.2.7" flutter_downloader: dependency: "direct main" description: @@ -360,7 +367,7 @@ packages: name: intl url: "https://pub.dartlang.org" source: hosted - version: "0.16.1" + version: "0.16.0" intl_translation: dependency: transitive description: @@ -466,6 +473,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1+2" + open_file: + dependency: "direct main" + description: + name: open_file + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" package_config: dependency: transitive description: From 02525a07fab74237a7fa8a16ff37c50649acc72b Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Tue, 24 Mar 2020 11:50:06 +0100 Subject: [PATCH 09/11] add LocalFile --- lib/app/hive.dart | 1 + lib/file/bloc.dart | 55 +++++++++++++++++++---- lib/file/data.dart | 80 +++++++++++++++++++++++++-------- lib/file/widgets/file_tile.dart | 10 +---- lib/main.dart | 2 +- 5 files changed, 112 insertions(+), 36 deletions(-) diff --git a/lib/app/hive.dart b/lib/app/hive.dart index c5303254..870db093 100644 --- a/lib/app/hive.dart +++ b/lib/app/hive.dart @@ -301,6 +301,7 @@ class TypeId { static const article = 56; static const file = 53; + static const localFile = 72; } Future initializeHive() async { diff --git a/lib/file/bloc.dart b/lib/file/bloc.dart index 840d4684..f45ba52e 100644 --- a/lib/file/bloc.dart +++ b/lib/file/bloc.dart @@ -6,11 +6,14 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:hive/hive.dart'; import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:schulcloud/app/app.dart'; +import 'package:time_machine/time_machine.dart'; import 'data.dart'; @@ -28,18 +31,28 @@ class UploadProgressUpdate { bool get isSingleFile => totalNumberOfFiles == 1; } +extension AssociatedLocalFile on File { + LocalFile get localFile => services.get().localFiles.get(id.value); + bool get isDownloaded => localFile != null; +} + @immutable class FileBloc { - const FileBloc(); + const FileBloc._(this.localFiles); + + final Box localFiles; + + static Future create() async { + final box = await Hive.openBox('localFiles'); + return FileBloc._(box); + } Future openFile(File file) async { - if (!(await file.isDownloaded)) { - await downloadFile(file); - } - await OpenFile.open((await file.localFile).path); + final localFile = file.localFile ?? await downloadFile(file); + await OpenFile.open(localFile.actualFile.path); } - Future downloadFile(File file) async { + Future downloadFile(File file) async { assert(file != null); await ensureStoragePermissionGranted(); @@ -52,15 +65,27 @@ class FileBloc { ); final signedUrl = json.decode(response.body)['url']; - final localFile = await file.localFile; + final directory = await getApplicationDocumentsDirectory(); + final extension = file.extension; + final fileName = '${file.id}${extension == null ? '' : '.$extension'}'; + final actualFile = io.File('${directory.path}/$fileName'); await FlutterDownloader.enqueue( url: signedUrl, - savedDir: localFile.dirName, - fileName: localFile.name, + savedDir: actualFile.dirName, + fileName: actualFile.name, showNotification: true, openFileFromNotification: true, ); + + // TODO(marcelgarus): Do this when the file downloaded successfully: + final localFile = LocalFile( + fileId: file.id, + downloadedAt: Instant.now(), + actualFile: actualFile, + ); + await localFiles.put(file.id.value, localFile); + return localFile; } Future ensureStoragePermissionGranted() async { @@ -71,6 +96,18 @@ class FileBloc { } } + Future deleteLocalFile(File file) async { + await file.localFile?.actualFile?.delete(); + await localFiles.delete(file.id.value); + } + + Future deleteAllLocalFiles() async { + await Future.wait([ + for (final file in localFiles.values) file.actualFile.delete(), + ]); + await localFiles.clear(); + } + Stream uploadFile({ @required Id owner, Id parent, diff --git a/lib/file/data.dart b/lib/file/data.dart index 80cd7ae0..8017673e 100644 --- a/lib/file/data.dart +++ b/lib/file/data.dart @@ -9,6 +9,11 @@ import 'package:time_machine/time_machine.dart'; part 'data.g.dart'; +String _extension(String fileName) { + final lastDot = fileName.lastIndexOf('.'); + return lastDot == null ? null : fileName.substring(lastDot + 1); +} + @HiveType(typeId: TypeId.file) class File implements Entity, Comparable { File({ @@ -71,24 +76,7 @@ class File implements Entity, Comparable { @HiveField(1) final String name; - String get extension { - final lastDot = name.lastIndexOf('.'); - return lastDot == null ? null : name.substring(lastDot + 1); - } - - Future get localFile async { - final directory = await getExternalStorageDirectory(); - final fileName = extension == null ? id.toString() : '$id.$extension'; - return io.File('${directory.path}/$fileName'); - } - - Future get isDownloaded async { - // Calling `.existsSync()` is much faster than using `exists()`. But in - // this case, latency is much more important than throughput: This code is - // executed during rendering and we don't want any jank. - // ignore: avoid_slow_async_io - return (await localFile).exists(); - } + String get extension => _extension(name); /// An [Id] for either a [User] or [Course]. @HiveField(3) @@ -164,3 +152,59 @@ class File implements Entity, Comparable { Future delete() => services.api.delete('fileStorage/$id'); } + +class LocalFile { + LocalFile({ + @required this.fileId, + @required this.downloadedAt, + @required this.actualFile, + }) : assert(fileId != null), + assert(downloadedAt != null), + assert(actualFile != null), + assert(actualFile.existsSync()); + + final Id fileId; + final io.File actualFile; + + final Instant downloadedAt; + + Future copyWith({ + Instant downloadedAt = Instant.unixEpoch, + }) async { + return LocalFile( + fileId: fileId, + downloadedAt: downloadedAt, + actualFile: actualFile, + ); + } +} + +class LocalFileAdapter extends TypeAdapter { + @override + int get typeId => TypeId.localFile; + + @override + void write(BinaryWriter writer, LocalFile file) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(file.fileId) + ..writeByte(1) + ..write(file.downloadedAt) + ..writeByte(2) + ..write(file.actualFile.path); + } + + @override + LocalFile read(BinaryReader reader) { + var numOfFields = reader.readByte(); + var fields = { + for (var i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return LocalFile( + fileId: fields[0] as Id, + downloadedAt: fields[1] as Instant, + actualFile: io.File(fields[2] as String), + ); + } +} diff --git a/lib/file/widgets/file_tile.dart b/lib/file/widgets/file_tile.dart index 7831d0b0..45ef86ac 100644 --- a/lib/file/widgets/file_tile.dart +++ b/lib/file/widgets/file_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:schulcloud/app/app.dart'; +import '../bloc.dart'; import '../data.dart'; import 'file_menu.dart'; import 'file_thumbnail.dart'; @@ -25,14 +26,7 @@ class FileTile extends StatelessWidget { title: Text(file.name), subtitle: Text(subtitle), leading: FileThumbnail(file: file), - trailing: FutureBuilder( - future: file.isDownloaded, - builder: (context, snapshot) { - return snapshot.data == true - ? Icon(Icons.offline_pin) - : SizedBox.shrink(); - }, - ), + trailing: file.isDownloaded ? Icon(Icons.offline_pin) : null, onTap: () => onOpen(file), onLongPress: () => FileMenu.show(context, file), ); diff --git a/lib/main.dart b/lib/main.dart index 7482c76b..e2953da8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,7 +79,7 @@ Future main({AppConfig appConfig = schulCloudAppConfig}) async { ..registerSingleton(NetworkService()) ..registerSingleton(ApiNetworkService()) ..registerSingleton(CalendarBloc()) - ..registerSingleton(FileBloc()) + ..registerSingletonAsync((_) => FileBloc.create()) ..registerSingleton(SignInBloc()); LicenseRegistry.addLicense(() async* { From 2f9adadd241ae83eb2159b820f407ac8b88b5876 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 25 Mar 2020 16:58:01 +0100 Subject: [PATCH 10/11] Merge branch 'master' into offline-files --- .github/ISSUE_TEMPLATE/---bug-report.md | 38 -- .github/ISSUE_TEMPLATE/---feature-request.md | 20 - .github/ISSUE_TEMPLATE/1-bug-report.md | 28 ++ .github/ISSUE_TEMPLATE/2-feature-request.md | 10 + .github/PULL_REQUEST_TEMPLATE.md | 14 + .github/workflows/build.yml | 147 +++---- .github/workflows/unicorn.yml | 19 + .unicorn/.unicorn.yml | 86 ++++ .unicorn/templates/module/.template.kts | 14 + .unicorn/templates/module/data.dart.ftl | 22 ++ .unicorn/templates/module/module.dart | 2 + .unicorn/templates/module/routes.dart.ftl | 19 + README.md | 10 +- android/app/build.gradle | 8 +- android/app/src/debug/AndroidManifest.xml | 5 +- android/app/src/main/AndroidManifest.xml | 33 +- .../schulcloud/android}/MainActivity.java | 5 +- android/app/src/profile/AndroidManifest.xml | 5 +- assets/sloth_error.svg | 1 + lib/app/app.dart | 1 - lib/app/app_config.dart | 13 +- lib/app/data.dart | 9 +- lib/app/hive.dart | 25 +- lib/app/routing.dart | 52 +++ lib/app/services/api_network.dart | 2 +- lib/app/services/deep_linking.dart | 37 ++ lib/app/services/network.dart | 2 +- lib/app/services/storage.dart | 23 +- lib/app/sort_filter/filtering.dart | 79 +++- lib/app/sort_filter/sort_filter.dart | 69 +++- lib/app/sort_filter/sorting.dart | 25 +- lib/app/utils.dart | 13 +- lib/app/widgets/account_dialog.dart | 7 +- lib/app/widgets/buttons.dart | 41 +- lib/app/widgets/navigation_bar.dart | 85 ---- lib/app/widgets/navigation_item.dart | 47 --- lib/app/widgets/not_found_screen.dart | 44 +++ lib/app/widgets/page_route.dart | 18 +- lib/app/widgets/scaffold.dart | 7 + lib/app/widgets/schulcloud_app.dart | 301 ++++++++++---- lib/app/widgets/text.dart | 59 ++- lib/app/widgets/top_level_screen_wrapper.dart | 80 ++++ lib/assignment/assignment.dart | 1 + lib/assignment/data.dart | 6 +- lib/assignment/routes.dart | 43 ++ ...een.dart => assignment_detail_screen.dart} | 121 +++--- .../widgets/assignments_screen.dart | 120 +++--- lib/assignment/widgets/dashboard_card.dart | 3 +- ...creen.dart => edit_submission_screen.dart} | 50 ++- lib/calendar/widgets/dashboard_card.dart | 63 +-- lib/course/course.dart | 1 + lib/course/data.dart | 187 ++++++--- lib/course/routes.dart | 28 ++ lib/course/widgets/content_view.dart | 163 ++++++++ lib/course/widgets/course_card.dart | 9 +- lib/course/widgets/course_chip.dart | 5 +- lib/course/widgets/course_detail_screen.dart | 131 ++++--- lib/course/widgets/lesson_screen.dart | 185 +++------ lib/dashboard/dashboard.dart | 1 + lib/dashboard/routes.dart | 8 + lib/dashboard/widgets/dashboard_card.dart | 5 +- lib/file/data.dart | 88 +++-- lib/file/file.dart | 4 +- lib/file/routes.dart | 55 +++ lib/file/{bloc.dart => service.dart} | 81 +++- lib/file/widgets/choose_destination.dart | 38 ++ lib/file/widgets/file_browser.dart | 120 ++++-- lib/file/widgets/file_tile.dart | 2 +- lib/file/widgets/files_screen.dart | 23 +- lib/file/widgets/upload_fab.dart | 22 +- lib/l10n/intl_de.arb | 10 +- lib/l10n/intl_en.arb | 12 +- lib/main.dart | 16 +- lib/main_brb.dart | 2 +- lib/main_n21.dart | 2 +- lib/main_open.dart | 2 +- lib/main_thr.dart | 2 +- lib/news/news.dart | 1 + lib/news/routes.dart | 18 + lib/news/widgets/article_preview.dart | 11 +- lib/news/widgets/article_screen.dart | 60 +-- lib/news/widgets/dashboard_card.dart | 8 +- lib/settings/routes.dart | 8 + lib/settings/settings.dart | 1 + lib/sign_in/bloc.dart | 6 +- lib/sign_in/data.dart | 18 + lib/sign_in/routes.dart | 24 ++ lib/sign_in/sign_in.dart | 1 + lib/sign_in/utils.dart | 25 +- lib/sign_in/widgets/form.dart | 193 ++++----- lib/sign_in/widgets/input.dart | 32 -- .../widgets/morphing_loading_button.dart | 68 ---- lib/sign_in/widgets/sign_in_browser.dart | 34 ++ lib/sign_in/widgets/sign_in_screen.dart | 22 +- lib/sign_in/widgets/sign_out_screen.dart | 56 +++ pubspec.lock | 367 ++++++++++++++++-- pubspec.yaml | 40 +- screenshots.yaml | 16 + test_driver/screenshots.dart | 9 + test_driver/screenshots_test.dart | 69 ++++ 100 files changed, 2873 insertions(+), 1348 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/---bug-report.md delete mode 100644 .github/ISSUE_TEMPLATE/---feature-request.md create mode 100644 .github/ISSUE_TEMPLATE/1-bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/2-feature-request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/unicorn.yml create mode 100644 .unicorn/.unicorn.yml create mode 100644 .unicorn/templates/module/.template.kts create mode 100644 .unicorn/templates/module/data.dart.ftl create mode 100644 .unicorn/templates/module/module.dart create mode 100644 .unicorn/templates/module/routes.dart.ftl rename android/app/src/main/java/{com/example/schulcloud => org/schulcloud/android}/MainActivity.java (64%) create mode 100644 assets/sloth_error.svg create mode 100644 lib/app/routing.dart create mode 100644 lib/app/services/deep_linking.dart delete mode 100644 lib/app/widgets/navigation_bar.dart delete mode 100644 lib/app/widgets/navigation_item.dart create mode 100644 lib/app/widgets/not_found_screen.dart create mode 100644 lib/app/widgets/top_level_screen_wrapper.dart create mode 100644 lib/assignment/routes.dart rename lib/assignment/widgets/{assignment_details_screen.dart => assignment_detail_screen.dart} (76%) rename lib/assignment/widgets/{edit_submittion_screen.dart => edit_submission_screen.dart} (80%) create mode 100644 lib/course/routes.dart create mode 100644 lib/course/widgets/content_view.dart create mode 100644 lib/dashboard/routes.dart create mode 100644 lib/file/routes.dart rename lib/file/{bloc.dart => service.dart} (66%) create mode 100644 lib/file/widgets/choose_destination.dart create mode 100644 lib/news/routes.dart create mode 100644 lib/settings/routes.dart create mode 100644 lib/sign_in/routes.dart delete mode 100644 lib/sign_in/widgets/input.dart delete mode 100644 lib/sign_in/widgets/morphing_loading_button.dart create mode 100644 lib/sign_in/widgets/sign_in_browser.dart create mode 100644 lib/sign_in/widgets/sign_out_screen.dart create mode 100644 screenshots.yaml create mode 100644 test_driver/screenshots.dart create mode 100644 test_driver/screenshots_test.dart diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md deleted file mode 100644 index 255aa018..00000000 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index 34f39d2f..00000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md new file mode 100644 index 00000000..91d45dfc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.md @@ -0,0 +1,28 @@ +--- +name: "\U0001F41E Bug report" +about: Create a report to help us improve +title: '' +labels: 'T: fix' +assignees: '' + +--- + + + +**Describe the bug** + + + + + +**Environment:** + +- Device: +- OS version: +- App version: diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.md b/.github/ISSUE_TEMPLATE/2-feature-request.md new file mode 100644 index 00000000..d1647331 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.md @@ -0,0 +1,10 @@ +--- +name: "\U0001F680 Feature request" +about: Suggest an idea for this project +title: '' +labels: 'T: feat' +assignees: '' + +--- + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..57ea1647 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ + +**Closes: #Issue** + + + + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8bf934a..e5cf5242 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ on: jobs: install: - name: Install Flutter and dependencies + name: Install Flutter & dependencies runs-on: ubuntu-latest steps: - name: Checkout repository @@ -83,10 +83,14 @@ jobs: env: DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build-sc: - name: Build sc (Schul-Cloud) + build: + name: Build needs: install runs-on: ubuntu-latest + strategy: + matrix: + flavor: ["sc", "brb", "n21", "open", "thr"] + fail-fast: false steps: - name: Install Java uses: actions/setup-java@v1 @@ -103,20 +107,29 @@ jobs: name: source path: . - - run: flutter build apk --release --target=lib/main.dart --flavor=sc + # The default (sc) flavor doesn't have a special main.dart-suffix + - if: matrix.flavor == 'sc' + run: flutter build apk --flavor=sc + - if: matrix.flavor != 'sc' + run: flutter build apk --target=lib/main_${{ matrix.flavor }}.dart --flavor=${{ matrix.flavor }} - name: Upload APK as artifact uses: actions/upload-artifact@v1 with: - name: apk-sc - path: build/app/outputs/apk/sc/release + name: apk-${{ matrix.flavor }} + path: build/app/outputs/apk/${{ matrix.flavor }}/release - build-brb: - name: Build brb (Schul-Cloud Brandenburg) + deploy-internal: + name: Deploy internal needs: install - runs-on: ubuntu-latest + # We have to use macOS for hardware acceleration on Android emulators + runs-on: macos-10.15 + strategy: + matrix: + flavor: ["sc"] + fail-fast: false steps: - - name: Install Java + - name: Install Java 12 uses: actions/setup-java@v1 with: java-version: "12.x" @@ -124,101 +137,35 @@ jobs: uses: subosito/flutter-action@v1 with: channel: "stable" - - name: Checkout source uses: actions/download-artifact@v1 with: name: source path: . - - run: flutter build apk --release --target=lib/main_brb.dart --flavor=brb - - - name: Upload APK as artifact - uses: actions/upload-artifact@v1 - with: - name: apk-brb - path: build/app/outputs/apk/brb/release - - build-n21: - name: Build n21 (Niedersächsische Bildungscloud) - needs: install - runs-on: ubuntu-latest - steps: - - name: Install Java + - name: Install Java 10 uses: actions/setup-java@v1 with: - java-version: "12.x" - - name: Install Flutter (stable) - uses: subosito/flutter-action@v1 - with: - channel: "stable" - - - name: Checkout source - uses: actions/download-artifact@v1 - with: - name: source - path: . - - - run: flutter build apk --release --target=lib/main_n21.dart --flavor=n21 - - - name: Upload APK as artifact - uses: actions/upload-artifact@v1 - with: - name: apk-n21 - path: build/app/outputs/apk/n21/release - - build-open: - name: Build open (Open Schul-Cloud) - needs: install - runs-on: ubuntu-latest - steps: - - name: Install Java - uses: actions/setup-java@v1 - with: - java-version: "12.x" - - name: Install Flutter (stable) - uses: subosito/flutter-action@v1 - with: - channel: "stable" - - - name: Checkout source - uses: actions/download-artifact@v1 - with: - name: source - path: . - - - run: flutter build apk --release --target=lib/main_open.dart --flavor=open - - - name: Upload APK as artifact - uses: actions/upload-artifact@v1 - with: - name: apk-open - path: build/app/outputs/apk/open/release - - build-thr: - name: Build thr (Thüringer Schulcloud) - needs: install - runs-on: ubuntu-latest - steps: - - name: Install Java - uses: actions/setup-java@v1 - with: - java-version: "12.x" - - name: Install Flutter (stable) - uses: subosito/flutter-action@v1 - with: - channel: "stable" - - - name: Checkout source - uses: actions/download-artifact@v1 - with: - name: source - path: . - - - run: flutter build apk --release --target=lib/main_thr.dart --flavor=thr - - - name: Upload APK as artifact - uses: actions/upload-artifact@v1 - with: - name: apk-thr - path: build/app/outputs/apk/thr/release + java-version: 10 + - name: Create virtual device + run: | + ~/Library/Android/sdk/tools/bin/sdkmanager "system-images;android-28;default;x86" + echo no | ~/Library/Android/sdk/tools/bin/avdmanager --verbose create avd --force --name "Pixel_XL" --package "system-images;android-28;default;x86" --device "pixel_xl" + env: + JDK_JAVA_OPTIONS: "--add-modules java.xml.bind" + - name: Install screenshots package + run: | + brew update && brew install imagemagick + flutter pub global activate screenshots + # TODO(JonasWanke): enabled when this issue is fixed: https://github.com/flutter/flutter/issues/36244 + # - name: Take screenshots + # run: flutter pub global run screenshots:main -v -f sc + # - name: Upload screenshots as artifact + # uses: actions/upload-artifact@v1 + # with: + # name: screenshots-${{ matrix.flavor }} + # path: android/fastlane/metadata/android + # - uses: actions/upload-artifact@v1 + # with: + # name: tmp-screenshots + # path: /tmp/screenshots diff --git a/.github/workflows/unicorn.yml b/.github/workflows/unicorn.yml new file mode 100644 index 00000000..50384804 --- /dev/null +++ b/.github/workflows/unicorn.yml @@ -0,0 +1,19 @@ +name: Unicorn + +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + +jobs: + unicorn: + name: 🦄 Unicorn + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: JonasWanke/unicorn@9b4b462 + with: + repo-token: "${{ secrets.UNICORN_TOKEN }}" diff --git a/.unicorn/.unicorn.yml b/.unicorn/.unicorn.yml new file mode 100644 index 00000000..75ad2079 --- /dev/null +++ b/.unicorn/.unicorn.yml @@ -0,0 +1,86 @@ +unicornVersion: "0.1.0" +name: "schulcloud" +description: "A Flutter–based mobile app for the HPI Schul–Cloud." +license: null +version: "0.3.5" +categorization: + component: + values: + - name: "app" + description: "app module" + paths: + - "lib/app/**" + - name: "assignment" + description: "assignment module" + paths: + - "lib/assignment/**" + - name: "calendar" + description: "calendar module" + paths: + - "lib/calendar/**" + - name: "course" + description: "course module" + paths: + - "lib/course/**" + - name: "dashboard" + description: "dashboard module" + paths: + - "lib/dashboard/**" + - name: "file" + description: "file module" + paths: + - "lib/file/**" + - name: "l10n" + description: "localization module" + paths: + - "lib/l10n/**" + - name: "signIn" + description: "signIn module" + paths: + - "lib/login/**" + - name: "news" + description: "news module" + paths: + - "lib/news/**" + - name: "settings" + description: "settings module" + paths: + - "lib/settings/**" + labels: + color: "c2e0c6" + prefix: "C: " + descriptionPrefix: "Component: " + priority: + values: + - name: "1" + description: "1 (Lowest)" + - name: "2" + description: "2 (Low)" + - name: "3" + description: "3 (Medium)" + - name: "4" + description: "4 (High)" + - name: "5" + description: "5 (Highest)" + labels: + color: "e5b5ff" + prefix: "P: " + descriptionPrefix: "Priority: " + type: + values: + - name: "feat" + description: ":tada: New Features" + - name: "change" + description: "⚡ Changes" + - name: "fix" + description: ":bug: Bug Fixes" + - name: "docs" + description: ":scroll: Documentation updates" + - name: "refactor" + description: ":building_construction: Refactoring" + - name: "build" + description: ":package: Build, CI & other meta changes" + labels: + color: "c5def5" + prefix: "T: " + descriptionPrefix: "Type: " diff --git a/.unicorn/templates/module/.template.kts b/.unicorn/templates/module/.template.kts new file mode 100644 index 00000000..b781ec68 --- /dev/null +++ b/.unicorn/templates/module/.template.kts @@ -0,0 +1,14 @@ +val module = prompt("Module name (lowercase; used as the folder name)") +val modulePath = "lib/$module" +if (baseDir.resolve(modulePath).exists()) { + exit("directory $modulePath already exists") +} +variables["module"] = module + +variables["entity"] = prompt("Main entity class") + +copy("module.dart", "$modulePath/$module.dart") +copy("data.dart.ftl", "$modulePath/data.dart") + +copy("routes.dart.ftl", "$modulePath/routes.dart") +log.i("Please remember to register the new routes in lib/app/routing.dart") diff --git a/.unicorn/templates/module/data.dart.ftl b/.unicorn/templates/module/data.dart.ftl new file mode 100644 index 00000000..2238b805 --- /dev/null +++ b/.unicorn/templates/module/data.dart.ftl @@ -0,0 +1,22 @@ +import 'package:hive/hive.dart'; +import 'package:meta/meta.dart'; +import 'package:schulcloud/app/app.dart'; + +part 'data.g.dart'; + +@immutable +@HiveType(typeId: type${entity}) +class ${entity} implements Entity { + const ${entity}({ + @required this.id, + }) : assert(id != null); + + ${entity}.fromJson(Map data) + : this( + id: Id<${entity}>(data['_id']), + ); + + @override + @HiveField(0) + final Id<${entity}> id; +} diff --git a/.unicorn/templates/module/module.dart b/.unicorn/templates/module/module.dart new file mode 100644 index 00000000..b35e5d33 --- /dev/null +++ b/.unicorn/templates/module/module.dart @@ -0,0 +1,2 @@ +export 'data.dart'; +export 'routes.dart'; diff --git a/.unicorn/templates/module/routes.dart.ftl b/.unicorn/templates/module/routes.dart.ftl new file mode 100644 index 00000000..17a5f57c --- /dev/null +++ b/.unicorn/templates/module/routes.dart.ftl @@ -0,0 +1,19 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; + +import 'data.dart'; +import 'widgets/${module}_detail_screen.dart'; +import 'widgets/${module}s_screen.dart'; + +final ${module}Routes = Route( + matcher: Matcher.path('${module}s'), + materialBuilder: (_, __) => ${entity}sScreen(), + routes: [ + Route( + matcher: Matcher.path('{${module}Id}'), + materialBuilder: (_, result) => ${entity}DetailsScreen( + Id<${entity}>(result['${module}Id']), + ), + ), + ], +); diff --git a/README.md b/README.md index bad725e9..22525fed 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # A Flutter based mobile App for the HPI Schul-Cloud +![Build & Lint](https://github.com/schul-cloud/schulcloud-flutter/workflows/Build%20&%20Lint/badge.svg) + Check the wiki for more info. - [Wiki](https://github.com/schul-cloud/schulcloud-flutter/wiki) @@ -14,7 +16,8 @@ For help getting started with Flutter, view the [online documentation](https://flutter.io/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. -## How to run the app + +## How to: Run the app - Run `flutter packages get` - Run `flutter packages pub run build_runner build` @@ -27,4 +30,9 @@ samples, guidance on mobile development, and a full API reference. We recommend using [L42n](https://github.com/JonasWanke/l42n) for editing localization files, located in `lib/l10n`. Note that you still need to re-generate the dart files after editing these files using [Flutter Intl][flutter-intl]. +## How to: Create a new module + +Run `unicorn template apply module` (using [🦄 Unicorn](https://github.com/JonasWanke/Unicorn)) and enter the required information. + + [flutter-intl]: https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl diff --git a/android/app/build.gradle b/android/app/build.gradle index f60b4f6e..07f323d3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,15 +32,17 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.schulcloud" - minSdkVersion 16 + applicationId "org.schulcloud.android" + minSdkVersion 19 targetSdkVersion 28 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } buildTypes { + debug { + applicationIdSuffix ".debug" + } release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 0710452d..8af334ac 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,6 @@ + package="org.schulcloud.android"> + to allow setting breakpoints, to provide hot reload, etc. --> diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 030c3ec2..4bba6351 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,11 @@ + package="org.schulcloud.android"> - + + + to allow setting breakpoints, to provide hot reload, etc. --> diff --git a/assets/sloth_error.svg b/assets/sloth_error.svg new file mode 100644 index 00000000..658e12b9 --- /dev/null +++ b/assets/sloth_error.svg @@ -0,0 +1 @@ + diff --git a/lib/app/app.dart b/lib/app/app.dart index 5f179992..5a57e597 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -18,7 +18,6 @@ export 'widgets/empty_state.dart'; export 'widgets/error_widgets.dart'; export 'widgets/fade_in.dart'; export 'widgets/form.dart'; -export 'widgets/navigation_item.dart'; export 'widgets/page_route.dart' show TopLevelPageRoute; export 'widgets/scaffold.dart'; export 'widgets/schulcloud_app.dart' show SchulCloudApp, SignedInScreen; diff --git a/lib/app/app_config.dart b/lib/app/app_config.dart index 1193ebf3..0f18c8bc 100644 --- a/lib/app/app_config.dart +++ b/lib/app/app_config.dart @@ -8,13 +8,13 @@ import 'utils.dart'; class AppConfig { const AppConfig({ @required this.name, - @required this.domain, + @required this.host, @required this.title, @required this.primaryColor, @required this.secondaryColor, @required this.accentColor, }) : assert(name != null), - assert(domain != null), + assert(host != null), assert(title != null), assert(primaryColor != null), assert(secondaryColor != null), @@ -28,10 +28,10 @@ class AppConfig { static const errorColor = Color(0xFFDC2831); final String name; - final String domain; - String get baseWebUrl => 'https://$domain'; + final String host; + String get baseWebUrl => 'https://$host'; String webUrl(String path) => '$baseWebUrl/$path'; - String get baseApiUrl => 'https://api.$domain'; + String get baseApiUrl => 'https://api.$host'; final String title; final MaterialColor primaryColor; @@ -53,6 +53,9 @@ class AppConfig { brightness == Brightness.light ? Colors.white : null, fontFamily: 'PT Sans', textTheme: _createTextTheme(brightness), + buttonTheme: ButtonThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), dialogTheme: DialogTheme( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), diff --git a/lib/app/data.dart b/lib/app/data.dart index a21dd3b2..8b2431f5 100644 --- a/lib/app/data.dart +++ b/lib/app/data.dart @@ -6,7 +6,6 @@ import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/assignment/assignment.dart'; import 'package:schulcloud/calendar/calendar.dart'; import 'package:schulcloud/course/course.dart'; -import 'package:schulcloud/file/file.dart'; import 'package:schulcloud/news/news.dart'; part 'data.g.dart'; @@ -33,11 +32,7 @@ class User implements Entity { assert(avatarInitials != null), assert(avatarBackgroundColor != null), assert(permissions != null), - assert(roleIds != null), - files = LazyIds( - collectionId: 'files of $id', - fetcher: () => File.fetchList(id), - ); + assert(roleIds != null); User.fromJson(Map data) : this( @@ -98,8 +93,6 @@ class User implements Entity { }[name]; return id != null && roleIds.contains(Id(id)); } - - final LazyIds files; } class Root implements Entity { diff --git a/lib/app/hive.dart b/lib/app/hive.dart index 870db093..2f83b112 100644 --- a/lib/app/hive.dart +++ b/lib/app/hive.dart @@ -33,9 +33,7 @@ extension SaveableEntity> on Entity { /// different types. @immutable class Id> { - Id(this.value) - : assert(value != null), - assert(value.isNotEmpty); + const Id(this.value) : assert(value != null); factory Id.orNull(String value) => value == null ? null : Id(value); @@ -275,6 +273,7 @@ class RecurrenceRuleAdapter extends TypeAdapter { } // Type ids. +// Used before: 46 class TypeId { static const entity = 71; static const id = 40; @@ -293,15 +292,19 @@ class TypeId { static const event = 64; - static const contentType = 46; - static const content = 57; static const course = 58; static const lesson = 59; + static const content = 57; + static const unsupportedComponent = 73; + static const textComponent = 72; + static const etherpadComponent = 74; + static const nexboardComponent = 75; static const article = 56; static const file = 53; - static const localFile = 72; + static const filePath = 78; + static const localFile = 79; } Future initializeHive() async { @@ -323,14 +326,18 @@ Future initializeHive() async { // Calendar module: ..registerAdapter(EventAdapter()) // Courses module: - ..registerAdapter(ContentTypeAdapter()) - ..registerAdapter(ContentAdapter()) ..registerAdapter(CourseAdapter()) ..registerAdapter(LessonAdapter()) + ..registerAdapter(ContentAdapter()) + ..registerAdapter(UnsupportedComponentAdapter()) + ..registerAdapter(TextComponentAdapter()) + ..registerAdapter(EtherpadComponentAdapter()) + ..registerAdapter(NexboardComponentAdapter()) // News module: ..registerAdapter(ArticleAdapter()) // Files module: - ..registerAdapter(FileAdapter()); + ..registerAdapter(FileAdapter()) + ..registerAdapter(FilePathAdapter()); HiveCache ..registerEntityType(TypeId.user, User.fetch) diff --git a/lib/app/routing.dart b/lib/app/routing.dart new file mode 100644 index 00000000..f9b2cd3e --- /dev/null +++ b/lib/app/routing.dart @@ -0,0 +1,52 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/assignment/assignment.dart'; +import 'package:schulcloud/course/course.dart'; +import 'package:schulcloud/dashboard/dashboard.dart'; +import 'package:schulcloud/file/file.dart'; +import 'package:schulcloud/news/news.dart'; +import 'package:schulcloud/settings/settings.dart'; +import 'package:schulcloud/sign_in/sign_in.dart'; + +import 'app_config.dart'; +import 'utils.dart'; +import 'widgets/not_found_screen.dart'; +import 'widgets/page_route.dart'; +import 'widgets/schulcloud_app.dart'; + +final hostRegExp = RegExp('(?:www\.)?${RegExp.escape(services.config.host)}'); + +String appSchemeLink(String path) => 'app://org.schulcloud.android/$path'; + +final router = Router( + routes: [ + Route( + matcher: Matcher.scheme('app') & Matcher.host('org.schulcloud.android'), + routes: [ + Route( + matcher: Matcher.path('signedInScreen'), + builder: (result) => TopLevelPageRoute( + builder: (_) => SignedInScreen(), + settings: result.settings, + ), + ), + ], + ), + Route( + matcher: Matcher.webHost(hostRegExp, isOptional: true), + routes: [ + Route( + routes: [ + assignmentRoutes, + courseRoutes, + dashboardRoutes, + fileRoutes, + signInRoutes, + newsRoutes, + settingsRoutes, + ], + ), + ], + ), + Route(materialBuilder: (_, result) => NotFoundScreen(result.uri)), + ], +); diff --git a/lib/app/services/api_network.dart b/lib/app/services/api_network.dart index 2d7cd9c1..b40cf882 100644 --- a/lib/app/services/api_network.dart +++ b/lib/app/services/api_network.dart @@ -15,7 +15,7 @@ class ApiNetworkService { String _url(String path) { assert(path != null); - return '${services.get().baseApiUrl}/$path'; + return '${services.config.baseApiUrl}/$path'; } NetworkService get _network => services.network; diff --git a/lib/app/services/deep_linking.dart b/lib/app/services/deep_linking.dart new file mode 100644 index 00000000..9dd9ae6a --- /dev/null +++ b/lib/app/services/deep_linking.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:get_it/get_it.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:uni_links/uni_links.dart'; + +import '../logger.dart'; + +/// A service that handles incoming deep links. +class DeepLinkingService { + DeepLinkingService._(Uri initial) + : _subject = BehaviorSubject.seeded(initial) { + Observable(getUriLinksStream()) + .doOnData((uri) => logger.i('Received deep link: $uri')) + // We can't use pipe as we're also adding items manually + .listen(_subject.add); + } + + static Future create() async { + logger.d('Retrieving initial deep link…'); + final initialUri = await getInitialUri(); + logger.i('Initial deep link: $initialUri'); + + return DeepLinkingService._(initialUri); + } + + final BehaviorSubject _subject; + Stream get stream => _subject.stream; + + void onUriHandled() { + _subject.add(null); + } +} + +extension DeepLinkingServiceGetIt on GetIt { + DeepLinkingService get deepLinking => get(); +} diff --git a/lib/app/services/network.dart b/lib/app/services/network.dart index 2efd5561..526de642 100644 --- a/lib/app/services/network.dart +++ b/lib/app/services/network.dart @@ -217,7 +217,7 @@ class NetworkService { Map _getHeaders(Map headers) { return { 'Content-Type': 'application/json', - ...headers, + if (headers != null) ...headers, }; } } diff --git a/lib/app/services/storage.dart b/lib/app/services/storage.dart index 651a0dfc..606d0585 100644 --- a/lib/app/services/storage.dart +++ b/lib/app/services/storage.dart @@ -1,3 +1,4 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:get_it/get_it.dart'; import 'package:meta/meta.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; @@ -10,7 +11,6 @@ class StorageService { StorageService._({ StreamingSharedPreferences prefs, @required this.userIdString, - @required this.email, @required this.token, @required this.root, }) : _prefs = prefs; @@ -18,14 +18,12 @@ class StorageService { static Future create() async { StreamingSharedPreferences prefs; Preference userIdString; - Preference email; Preference token; await Future.wait([ () async { prefs = await StreamingSharedPreferences.instance; userIdString = prefs.getString('userId', defaultValue: ''); - email = prefs.getString('email', defaultValue: ''); token = prefs.getString('token', defaultValue: ''); }(), ]); @@ -35,7 +33,6 @@ class StorageService { return StorageService._( prefs: prefs, userIdString: userIdString, - email: email, token: token, root: root, ); @@ -46,24 +43,28 @@ class StorageService { final Preference userIdString; Id get userId => Id(userIdString.getValue()); - final Preference email; - bool get hasEmail => email.getValue().isNotEmpty; - final Preference token; bool get hasToken => token.getValue().isNotEmpty; + bool get isSignedIn => hasToken; + bool get isSignedOut => !isSignedIn; final Root root; Future setUserInfo({ - @required String email, @required String userId, @required String token, - }) { - return Future.wait([ - this.email.setValue(email), + }) async { + await Future.wait([ userIdString.setValue(userId), this.token.setValue(token), ]); + + // Required by [LessonScreen] + await CookieManager().setCookie( + url: services.config.baseWebUrl, + name: 'jwt', + value: token, + ); } // TODO(marcelgarus): clear the HiveCache diff --git a/lib/app/sort_filter/filtering.dart b/lib/app/sort_filter/filtering.dart index 40835d2e..7cff4bbf 100644 --- a/lib/app/sort_filter/filtering.dart +++ b/lib/app/sort_filter/filtering.dart @@ -3,6 +3,7 @@ import 'package:datetime_picker_formfield/datetime_picker_formfield.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:time_machine/time_machine.dart'; +import 'package:time_machine/time_machine_text_patterns.dart'; import '../datetime_utils.dart'; import '../utils.dart'; @@ -12,11 +13,13 @@ typedef Test = bool Function(T item, D data); typedef Selector = R Function(T item); abstract class Filter { - const Filter(this.title) : assert(title != null); + const Filter(this.titleGetter) : assert(titleGetter != null); S get defaultSelection; - final String title; + final L10nStringGetter titleGetter; + + S tryParseWebQuerySorter(Map query, String key); bool filter(T item, S selection); Widget buildWidget( @@ -35,14 +38,37 @@ abstract class Filter { @immutable class DateRangeFilter extends Filter { - const DateRangeFilter(String title, {@required this.selector}) - : assert(selector != null), - super(title); + const DateRangeFilter( + L10nStringGetter titleGetter, { + this.defaultSelection = const DateRangeFilterSelection(), + this.webQueryKey, + @required this.selector, + }) : assert(selector != null), + assert(defaultSelection != null), + super(titleGetter); @override - DateRangeFilterSelection get defaultSelection => DateRangeFilterSelection(); + final DateRangeFilterSelection defaultSelection; final Selector selector; + final String webQueryKey; + + @override + DateRangeFilterSelection tryParseWebQuerySorter( + Map query, String key) { + LocalDate tryParse(String value) { + if (value == null) { + return null; + } + return LocalDatePattern.iso.parse(value).TryGetValue(null); + } + + final prefix = webQueryKey ?? key; + return DateRangeFilterSelection( + start: tryParse(query['${prefix}From']), + end: tryParse(query['${prefix}To']), + ); + } @override bool filter(T item, DateRangeFilterSelection selection) { @@ -125,15 +151,29 @@ class DateRangeFilterSelection { @immutable class FlagsFilter extends Filter> { - const FlagsFilter(String title, {@required this.filters}) + const FlagsFilter(L10nStringGetter titleGetter, {@required this.filters}) : assert(filters != null), - super(title); + super(titleGetter); @override - Map get defaultSelection => {}; + Map get defaultSelection { + return { + for (final entry in filters.entries) + entry.key: entry.value.defaultSelection, + }; + } final Map> filters; + @override + Map tryParseWebQuerySorter( + Map query, String key) { + return { + for (final entry in filters.entries) + entry.key: entry.value.tryParseWebQuerySorter(query, entry.key), + }; + } + @override Widget buildWidget( BuildContext context, @@ -155,7 +195,7 @@ class FlagsFilter extends Filter> { return FilterChip( avatar: avatar, - label: Text(filter.title), + label: Text(filter.titleGetter(context.s)), onSelected: (value) { final newValue = { null: true, @@ -179,13 +219,26 @@ typedef SetFlagFilterCallback = void Function(String key, bool value); @immutable class FlagFilter { - const FlagFilter(this.title, {@required this.selector}) - : assert(title != null), + const FlagFilter( + this.titleGetter, { + this.defaultSelection, + this.webQueryKey, + @required this.selector, + }) : assert(titleGetter != null), assert(selector != null); - final String title; + final L10nStringGetter titleGetter; + final bool defaultSelection; + final String webQueryKey; final Selector selector; + bool tryParseWebQuerySorter(Map query, String key) { + return { + 'true': true, + 'false': false, + }[query[webQueryKey ?? key]]; + } + // ignore: avoid_positional_boolean_parameters bool apply(T item, bool selection) { if (selection == null) { diff --git a/lib/app/sort_filter/sort_filter.dart b/lib/app/sort_filter/sort_filter.dart index dbaa22ea..0b0f416c 100644 --- a/lib/app/sort_filter/sort_filter.dart +++ b/lib/app/sort_filter/sort_filter.dart @@ -1,7 +1,9 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../utils.dart'; import 'filtering.dart'; import 'sorting.dart'; @@ -11,14 +13,59 @@ typedef SortFilterChangeCallback = void Function( @immutable class SortFilter { - const SortFilter({ - this.sortOptions = const {}, + SortFilter({ + this.sorters = const {}, + @required this.defaultSorter, + this.defaultSortOrder = SortOrder.ascending, this.filters = const {}, - }) : assert(sortOptions != null), + }) : assert(sorters != null), + assert(sorters.isNotEmpty), + assert(defaultSorter != null), + assert(defaultSortOrder != null), assert(filters != null); - final Map> sortOptions; + final Map> sorters; + final String defaultSorter; + final SortOrder defaultSortOrder; final Map filters; + + SortFilterSelection get defaultSelection { + return SortFilterSelection( + config: this, + sortSelectionKey: defaultSorter, + sortOrder: defaultSortOrder, + filterSelections: { + for (final entry in filters.entries) + entry.key: entry.value.defaultSelection, + }, + ); + } + + /// Parses a query string generated by the web client, e.g. + /// https://schul-cloud.org/homework/#?dueDateFrom=2020-03-09&dueDateTo=2020-03-27&private=true&publicSubmissions=false&sort=updatedAt&sortorder=1&teamSubmissions=true + SortFilterSelection tryParseWebQuery(Map query) { + if (query.isEmpty) { + return defaultSelection; + } + + return SortFilterSelection( + config: this, + sortSelectionKey: _tryParseWebQuerySorter(query) ?? defaultSorter, + sortOrder: SortOrderUtils.tryParseWebQuery(query) ?? defaultSortOrder, + filterSelections: { + for (final entry in filters.entries) + entry.key: entry.value.tryParseWebQuerySorter(query, entry.key), + }, + ); + } + + String _tryParseWebQuerySorter(Map query) { + final key = query['sort']; + return sorters.entries + .firstOrNullWhere( + (option) => key == (option.value.webQueryKey ?? option.key)) + ?.key; + } } @immutable @@ -40,7 +87,7 @@ class SortFilterSelection { final SortFilter config; final String sortSelectionKey; - Sorter get sortSelection => config.sortOptions[sortSelectionKey]; + Sorter get sortSelection => config.sorters[sortSelectionKey]; final SortOrder sortOrder; final Map filterSelections; @@ -146,24 +193,26 @@ class SortFilterWidget extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - _buildSortSection(), + _buildSortSection(context), for (final filterKey in config.filters.keys) _buildFilterSection(context, filterKey), ], ); } - Widget _buildSortSection() { + Widget _buildSortSection(BuildContext context) { + final s = context.s; + return _Section( title: 'Order by', child: ChipGroup( children: [ - for (final sortOption in config.sortOptions.entries) + for (final sortOption in config.sorters.entries) ActionChip( avatar: sortOption.key != selection.sortSelectionKey ? null : Icon(selection.sortOrder.icon), - label: Text(sortOption.value.title), + label: Text(sortOption.value.title(s)), onPressed: () => onSelectionChange( selection.withSortSelection(sortOption.key)), ), @@ -176,7 +225,7 @@ class SortFilterWidget extends StatelessWidget { final filter = config.filters[filterKey]; return _Section( - title: filter.title, + title: filter.titleGetter(context.s), child: filter.buildWidget( context, selection.filterSelections[filterKey], diff --git a/lib/app/sort_filter/sorting.dart b/lib/app/sort_filter/sorting.dart index c4169474..e1fa0efa 100644 --- a/lib/app/sort_filter/sorting.dart +++ b/lib/app/sort_filter/sorting.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:schulcloud/app/app.dart'; import 'filtering.dart'; @@ -22,18 +23,31 @@ extension SortOrderUtils on SortOrder { SortOrder.descending: Icons.arrow_downward, }[this]; } + + static SortOrder tryParseWebQuery(Map query) { + return { + '1': SortOrder.ascending, + '-1': SortOrder.descending, + }[query['sortorder']]; + } } @immutable class Sorter { - const Sorter(this.title, {@required this.comparator}) - : assert(title != null), + const Sorter( + this.title, { + this.webQueryKey, + @required this.comparator, + }) : assert(title != null), assert(comparator != null); + Sorter.simple( - String title, { + L10nStringGetter titleGetter, { + String webQueryKey, @required Selector selector, }) : this( - title, + titleGetter, + webQueryKey: webQueryKey, comparator: (a, b) { final selectorA = selector(a); if (selectorA == null) { @@ -47,6 +61,7 @@ class Sorter { }, ); - final String title; + final L10nStringGetter title; + final String webQueryKey; final Comparator comparator; } diff --git a/lib/app/utils.dart b/lib/app/utils.dart index 0cea2b7c..a61d58ed 100644 --- a/lib/app/utils.dart +++ b/lib/app/utils.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:ui'; +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:html/parser.dart'; @@ -9,6 +10,7 @@ import 'package:http/http.dart'; import 'package:schulcloud/generated/l10n.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'app_config.dart'; import 'services/network.dart'; final services = GetIt.instance; @@ -54,7 +56,12 @@ String formatFileSize(int bytes) { return '${(bytes / power).toStringAsFixed(index == 0 ? 0 : 1)} ${units[index]}'; } +typedef L10nStringGetter = String Function(S); + extension LegenWaitForItDaryString on String { + // ignore: unnecessary_this + String get blankToNull => this?.isBlank != false ? null : this; + String get withoutLinebreaks => replaceAll(RegExp('[\r\n]'), ''); /// Removes html tags from a string. @@ -91,8 +98,10 @@ extension LegenWaitForItDaryString on String { /// Tries launching a url. Future tryLaunchingUrl(String url) async { - if (await canLaunch(url)) { - await launch(url); + final resolved = + Uri.parse(services.config.baseWebUrl).resolve(url).toString(); + if (await canLaunch(resolved)) { + await launch(resolved); return true; } return false; diff --git a/lib/app/widgets/account_dialog.dart b/lib/app/widgets/account_dialog.dart index b1eb54a6..35ecd4d7 100644 --- a/lib/app/widgets/account_dialog.dart +++ b/lib/app/widgets/account_dialog.dart @@ -2,7 +2,6 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cached/flutter_cached.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:schulcloud/settings/settings.dart'; import 'package:schulcloud/sign_in/sign_in.dart'; import '../app_config.dart'; @@ -72,11 +71,7 @@ class AccountDialog extends StatelessWidget { ListTile( leading: Icon(Icons.settings), title: Text(s.settings), - onTap: () { - context.navigator.pushReplacement(MaterialPageRoute( - builder: (_) => SettingsScreen(), - )); - }, + onTap: () => context.navigator.pushNamed('/settings'), ), ListTile( leading: SvgPicture.asset( diff --git a/lib/app/widgets/buttons.dart b/lib/app/widgets/buttons.dart index 11df79e7..6672d20e 100644 --- a/lib/app/widgets/buttons.dart +++ b/lib/app/widgets/buttons.dart @@ -2,29 +2,30 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; class PrimaryButton extends StatelessWidget { - const PrimaryButton({Key key, this.onPressed, this.child}) : super(key: key); + const PrimaryButton({ + Key key, + this.isEnabled, + @required this.onPressed, + @required this.child, + this.isLoading = false, + }) : assert(child != null), + assert(isLoading != null), + super(key: key); + final bool isEnabled; final VoidCallback onPressed; final Widget child; + final bool isLoading; @override Widget build(BuildContext context) { - return RaisedButton( + return FancyRaisedButton( + isEnabled: isEnabled, onPressed: onPressed, - elevation: 0, - disabledElevation: 0, - focusElevation: 4, - hoverElevation: 2, - highlightElevation: 4, + isLoading: isLoading, color: context.theme.primaryColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - child: DefaultTextStyle( - style: TextStyle( - color: Colors.white, - fontFamily: 'PT Sans Narrow', - fontWeight: FontWeight.w700, - height: 1.25, - ), + child: DefaultTextStyle.merge( + style: TextStyle(color: context.theme.primaryColor.contrastColor), child: child, ), ); @@ -34,19 +35,25 @@ class PrimaryButton extends StatelessWidget { class SecondaryButton extends StatelessWidget { const SecondaryButton({ Key key, + this.isEnabled, @required this.onPressed, @required this.child, + this.isLoading = false, }) : assert(child != null), + assert(isLoading != null), super(key: key); + final bool isEnabled; final VoidCallback onPressed; final Widget child; + final bool isLoading; @override Widget build(BuildContext context) { - return OutlineButton( + return FancyOutlineButton( + isEnabled: isEnabled, onPressed: onPressed, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + isLoading: isLoading, child: child, ); } diff --git a/lib/app/widgets/navigation_bar.dart b/lib/app/widgets/navigation_bar.dart deleted file mode 100644 index d17c95b4..00000000 --- a/lib/app/widgets/navigation_bar.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:schulcloud/app/app.dart'; - -import 'schulcloud_app.dart'; - -/// A custom version of a navigation bar intended to be displayed at the bottom -/// of the screen. -class MyNavigationBar extends StatefulWidget { - const MyNavigationBar({ - @required this.onNavigate, - @required this.activeScreenStream, - }) : assert(onNavigate != null); - - final void Function(Screen route) onNavigate; - final Stream activeScreenStream; - - @override - _MyNavigationBarState createState() => _MyNavigationBarState(); -} - -class _MyNavigationBarState extends State { - Screen _activeScreen = Screen.dashboard; - - static const screens = [ - Screen.dashboard, - Screen.courses, - Screen.assignments, - Screen.files, - Screen.news, - ]; - - @override - void initState() { - super.initState(); - widget.activeScreenStream - .listen((screen) => setState(() => _activeScreen = screen)); - } - - void _onNavigate(int index) { - _activeScreen = screens[index]; - widget.onNavigate(_activeScreen); - } - - @override - Widget build(BuildContext context) { - final s = context.s; - final theme = context.theme; - - return BottomNavigationBar( - selectedItemColor: theme.accentColor, - unselectedItemColor: theme.mediumEmphasisOnBackground, - currentIndex: screens.indexOf(_activeScreen), - onTap: _onNavigate, - items: [ - BottomNavigationBarItem( - icon: Icon(Icons.dashboard), - title: Text(s.dashboard), - backgroundColor: theme.bottomAppBarColor, - ), - BottomNavigationBarItem( - icon: Icon(Icons.school), - title: Text(s.course), - backgroundColor: theme.bottomAppBarColor, - ), - BottomNavigationBarItem( - icon: Icon(Icons.playlist_add_check), - title: Text(s.assignment), - backgroundColor: theme.bottomAppBarColor, - ), - BottomNavigationBarItem( - icon: Icon(Icons.folder), - title: Text(s.file), - backgroundColor: theme.bottomAppBarColor, - ), - BottomNavigationBarItem( - icon: Icon(Icons.new_releases), - title: Text(s.news), - backgroundColor: theme.bottomAppBarColor, - ), - ], - ); - } -} diff --git a/lib/app/widgets/navigation_item.dart b/lib/app/widgets/navigation_item.dart deleted file mode 100644 index 190836bd..00000000 --- a/lib/app/widgets/navigation_item.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/material.dart'; - -import 'text.dart'; - -class NavigationItem extends StatelessWidget { - const NavigationItem({ - @required this.icon, - @required this.text, - @required this.onPressed, - }) : assert(icon != null), - assert(text != null), - assert(onPressed != null); - - final IconData icon; - final String text; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = context.theme; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: onPressed, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Icon(icon, color: theme.mediumEmphasisOnBackground), - SizedBox(width: 16), - Expanded( - child: FancyText( - text, - style: context.textTheme.subhead, - emphasis: TextEmphasis.medium, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/app/widgets/not_found_screen.dart b/lib/app/widgets/not_found_screen.dart new file mode 100644 index 00000000..43c29372 --- /dev/null +++ b/lib/app/widgets/not_found_screen.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../utils.dart'; +import 'app_bar.dart'; +import 'scaffold.dart'; + +class NotFoundScreen extends StatelessWidget { + const NotFoundScreen(this.uri) : assert(uri != null); + + final Uri uri; + + @override + Widget build(BuildContext context) { + final s = context.s; + + return FancyScaffold( + appBar: FancyAppBar( + title: Text(s.app_notFound), + ), + sliver: SliverFillRemaining( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: BoxConstraints.expand(height: 384), + child: SvgPicture.asset( + 'assets/sloth_error.svg', + ), + ), + SizedBox(height: 8), + Text( + s.app_notFound_message(uri), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/widgets/page_route.dart b/lib/app/widgets/page_route.dart index cdc0e200..8261035a 100644 --- a/lib/app/widgets/page_route.dart +++ b/lib/app/widgets/page_route.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'top_level_screen_wrapper.dart'; + class TopLevelPageRoute extends PageRoute { - TopLevelPageRoute({@required this.builder}) : assert(builder != null); + TopLevelPageRoute({ + @required this.builder, + @required RouteSettings settings, + }) : assert(builder != null), + super(settings: settings); final WidgetBuilder builder; @@ -12,9 +18,13 @@ class TopLevelPageRoute extends PageRoute { String get barrierLabel => null; @override - Widget buildPage(BuildContext context, Animation animation, - Animation secondaryAnimation) => - builder(context); + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return TopLevelScreenWrapper(child: builder(context)); + } @override Widget buildTransitions(BuildContext context, Animation animation, diff --git a/lib/app/widgets/scaffold.dart b/lib/app/widgets/scaffold.dart index 9c21c3ff..2b62c157 100644 --- a/lib/app/widgets/scaffold.dart +++ b/lib/app/widgets/scaffold.dart @@ -53,14 +53,20 @@ class FancyTabbedScaffold extends StatelessWidget { Key key, @required this.appBarBuilder, this.controller, + this.initialTabIndex, @required this.tabs, this.omitHorizontalPadding = false, }) : assert(appBarBuilder != null), + assert( + !(controller != null && initialTabIndex != null), + '[initialTabIndex] may only be set when a [DefaultTabController] ' + 'is implicitly generated'), assert(tabs != null), super(key: key); final AppBarBuilder appBarBuilder; final TabController controller; + final int initialTabIndex; final List tabs; final bool omitHorizontalPadding; @@ -97,6 +103,7 @@ class FancyTabbedScaffold extends StatelessWidget { // [TabController] and hence lengths don't match key: ValueKey(tabs.length), length: tabs.length, + initialIndex: initialTabIndex ?? 0, child: child, ); } diff --git a/lib/app/widgets/schulcloud_app.dart b/lib/app/widgets/schulcloud_app.dart index c516456e..6979c443 100644 --- a/lib/app/widgets/schulcloud_app.dart +++ b/lib/app/widgets/schulcloud_app.dart @@ -1,26 +1,27 @@ import 'dart:async'; +import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_absolute_path/flutter_absolute_path.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:logger_flutter/logger_flutter.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:schulcloud/assignment/assignment.dart'; -import 'package:schulcloud/course/course.dart'; -import 'package:schulcloud/dashboard/dashboard.dart'; -import 'package:schulcloud/file/file.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:schulcloud/generated/l10n.dart'; -import 'package:schulcloud/sign_in/sign_in.dart'; -import 'package:schulcloud/news/news.dart'; +import 'package:share/receive_share_state.dart'; +import 'package:share/share.dart'; +import 'package:schulcloud/file/file.dart'; import '../app_config.dart'; +import '../logger.dart'; +import '../routing.dart'; import '../services/navigator_observer.dart'; import '../services/snack_bar.dart'; import '../services/storage.dart'; import '../utils.dart'; -import 'navigation_bar.dart'; -import 'page_route.dart'; class SchulCloudApp extends StatelessWidget { + static final navigatorKey = GlobalKey(); + static NavigatorState get navigator => navigatorKey.currentState; + @override Widget build(BuildContext context) { final appConfig = services.config; @@ -29,7 +30,15 @@ class SchulCloudApp extends StatelessWidget { title: appConfig.title, theme: appConfig.createThemeData(Brightness.light), darkTheme: appConfig.createThemeData(Brightness.dark), - home: services.storage.hasToken ? SignedInScreen() : SignInScreen(), + navigatorKey: navigatorKey, + initialRoute: services.storage.isSignedIn + ? appSchemeLink('signedInScreen') + : services.config.webUrl('login'), + onGenerateRoute: router.onGenerateRoute, + navigatorObservers: [ + LoggingNavigatorObserver(), + HeroController(), + ], localizationsDelegates: [ S.delegate, GlobalMaterialLocalizations.delegate, @@ -40,84 +49,108 @@ class SchulCloudApp extends StatelessWidget { } } -/// The screens that can be navigated to. -enum Screen { - dashboard, - news, - courses, - files, - assignments, -} - class SignedInScreen extends StatefulWidget { @override - _SignedInScreenState createState() => _SignedInScreenState(); + SignedInScreenState createState() => SignedInScreenState(); } -class _SignedInScreenState extends State { +class SignedInScreenState extends ReceiveShareState + with TickerProviderStateMixin { final _scaffoldKey = GlobalKey(); ScaffoldState get scaffold => _scaffoldKey.currentState; - final _navigatorKey = GlobalKey(); - NavigatorState get navigator => _navigatorKey.currentState; + static final _navigatorKeys = + List.generate(_BottomTab.count, (_) => GlobalKey()); + List _faders; + + static var _selectedTabIndex = 0; + static NavigatorState get currentNavigator => + _navigatorKeys[_selectedTabIndex].currentState; - /// When the user navigates (via the menu or pressing the back button), we - /// add the new screen to the stream. The menu listens to the stream to - /// highlight the appropriate item. - BehaviorSubject _controller; - Stream _screenStream; + void selectTab(int index, {bool popIfAlreadySelected = false}) { + assert(0 <= index && index < _BottomTab.count); + + final pop = popIfAlreadySelected && _selectedTabIndex == index; + setState(() { + _selectedTabIndex = index; + if (pop) { + currentNavigator.popUntil((route) => route.isFirst); + } + }); + } @override void initState() { super.initState(); - _controller = BehaviorSubject(); - _screenStream = _controller.stream; + enableShareReceiving(); + scheduleMicrotask(_showSnackBars); + + _faders = List.generate( + _BottomTab.count, + (_) => + AnimationController(vsync: this, duration: kThemeAnimationDuration), + ); + _faders[_selectedTabIndex].value = 1; } @override void dispose() { - _controller?.close(); + for (final fader in _faders) { + fader.dispose(); + } super.dispose(); } - void _navigateTo(Screen screen) { - // If we are at the root of a screen and try to change to the same screen, - // we just stay here. - if (!navigator.canPop() && screen == _controller.value) { - return; - } - - _controller.add(screen); - - final targetScreenBuilder = { - Screen.dashboard: (_) => DashboardScreen(), - Screen.news: (_) => NewsScreen(), - Screen.files: (_) => FilesScreen(), - Screen.courses: (_) => CoursesScreen(), - Screen.assignments: (_) => AssignmentsScreen(), - }[screen]; - - navigator - ..popUntil((route) => route.isFirst) - ..pushReplacement(TopLevelPageRoute( - builder: targetScreenBuilder, - )); + @override + void receiveShare(Share shared) { + logger.i('The user shared $shared into the app.'); + Future.delayed(Duration(seconds: 1), () async { + await services.files.uploadFileFromLocalPath( + context: context, + localPath: await FlutterAbsolutePath.getAbsolutePath(shared.path), + ); + }); } - /// When the user tries to pop, we first try to pop with the inner navigator. - /// If that's not possible (we are at a top-level location), we go to the - /// dashboard. Only if we were already there, we pop (aka close the app). - Future _onWillPop() async { - if (navigator.canPop()) { - navigator.pop(); - return false; - } else if (_controller.value != Screen.dashboard) { - _navigateTo(Screen.dashboard); - return false; - } else { - return true; - } + @override + Widget build(BuildContext context) { + final s = context.s; + final theme = context.theme; + final barColor = theme.bottomAppBarColor; + + return WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( + key: _scaffoldKey, + body: Stack( + fit: StackFit.expand, + children: [ + for (var i = 0; i < _BottomTab.count; i++) + _TabContent( + navigatorKey: _navigatorKeys[i], + initialRoute: _BottomTab.values[i].initialRoute, + fader: _faders[i], + isActive: i == _selectedTabIndex, + ), + ], + ), + bottomNavigationBar: BottomNavigationBar( + selectedItemColor: theme.accentColor, + unselectedItemColor: theme.mediumEmphasisOnBackground, + currentIndex: _selectedTabIndex, + onTap: (index) => selectTab(index, popIfAlreadySelected: true), + items: [ + for (final tab in _BottomTab.values) + BottomNavigationBarItem( + icon: Icon(tab.icon, key: tab.key), + title: Text(tab.title(s)), + backgroundColor: barColor, + ), + ], + ), + ), + ); } Future _showSnackBars() async { @@ -134,28 +167,124 @@ class _SignedInScreenState extends State { }); } + /// When the user tries to pop, we first try to pop with the inner navigator. + /// If that's not possible (we are at a top-level location), we go to the + /// dashboard. Only if we were already there, we pop (aka close the app). + Future _onWillPop() async { + if (currentNavigator.canPop()) { + currentNavigator.pop(); + return false; + } else if (_selectedTabIndex != 0) { + selectTab(0); + return false; + } else { + return true; + } + } +} + +class _TabContent extends StatefulWidget { + const _TabContent({ + Key key, + @required this.navigatorKey, + @required this.initialRoute, + @required this.fader, + @required this.isActive, + }) : assert(navigatorKey != null), + assert(initialRoute != null), + assert(fader != null), + assert(isActive != null), + super(key: key); + + final GlobalKey navigatorKey; + final String initialRoute; + final AnimationController fader; + final bool isActive; + + @override + _TabContentState createState() => _TabContentState(); +} + +class _TabContentState extends State<_TabContent> { + Widget _child; + @override Widget build(BuildContext context) { - return LogConsoleOnShake( - child: Scaffold( - key: _scaffoldKey, - body: WillPopScope( - onWillPop: _onWillPop, - child: Navigator( - key: _navigatorKey, - onGenerateRoute: (_) => - MaterialPageRoute(builder: (_) => DashboardScreen()), - observers: [ - LoggingNavigatorObserver(), - HeroController(), - ], - ), - ), - bottomNavigationBar: MyNavigationBar( - onNavigate: _navigateTo, - activeScreenStream: _screenStream, - ), + if (!widget.isActive) { + widget.fader.reverse(); + final child = _child ?? SizedBox(); + if (widget.fader.isAnimating) { + return IgnorePointer(child: child); + } + return Offstage(child: child); + } + + _child ??= FadeTransition( + opacity: widget.fader.drive(CurveTween(curve: Curves.fastOutSlowIn)), + child: Navigator( + key: widget.navigatorKey, + initialRoute: widget.initialRoute, + onGenerateRoute: router.onGenerateRoute, + observers: [ + LoggingNavigatorObserver(), + HeroController(), + ], ), ); + + widget.fader.forward(); + return _child; } } + +@immutable +class _BottomTab { + const _BottomTab({ + this.key, + @required this.icon, + @required this.title, + @required this.initialRoute, + }) : assert(icon != null), + assert(title != null), + assert(initialRoute != null); + + final ValueKey key; + final IconData icon; + final L10nStringGetter title; + final String initialRoute; + + static final values = [dashboard, course, assignment, file, news]; + static int get count => values.length; + + // We don't use relative URLs as they would start with a '/' and hence the + // navigator automatically populates our initial back stack with '/'. + static final dashboard = _BottomTab( + icon: FontAwesomeIcons.thLarge, + title: (s) => s.dashboard, + initialRoute: services.config.webUrl('dashboard'), + ); + static final course = _BottomTab( + key: ValueKey('navigation-course'), + icon: FontAwesomeIcons.graduationCap, + title: (s) => s.course, + initialRoute: services.config.webUrl('courses'), + ); + static final assignment = _BottomTab( + key: ValueKey('navigation-assignment'), + icon: FontAwesomeIcons.tasks, + title: (s) => s.assignment, + initialRoute: services.config.webUrl('homework'), + ); + static final file = _BottomTab( + key: ValueKey('navigation-file'), + icon: FontAwesomeIcons.solidFolderOpen, + title: (s) => s.file, + initialRoute: services.config.webUrl('files'), + ); + static final news = _BottomTab( + key: ValueKey('navigation-news'), + icon: FontAwesomeIcons.solidNewspaper, + title: (s) => s.news, + initialRoute: services.config.webUrl('news'), + ); +} diff --git a/lib/app/widgets/text.dart b/lib/app/widgets/text.dart index 1218a192..a8efa50d 100644 --- a/lib/app/widgets/text.dart +++ b/lib/app/widgets/text.dart @@ -2,8 +2,10 @@ import 'dart:math'; import 'dart:ui'; import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html/style.dart'; import 'package:schulcloud/app/app.dart'; import '../utils.dart'; @@ -112,8 +114,9 @@ class _FancyTextState extends State { if (widget.data == null) { child = _buildLoading(context, style); } else { - child = - widget.showRichText ? _buildRichText(style) : _buildPlainText(style); + child = widget.showRichText + ? _buildRichText(context, style) + : _buildPlainText(style); } return AnimatedSwitcher( @@ -185,7 +188,7 @@ class _FancyTextState extends State { ); } - Widget _buildRichText(TextStyle style) { + Widget _buildRichText(BuildContext context, TextStyle style) { if (widget.textType == TextType.plain) { return Text( widget.data, @@ -195,10 +198,58 @@ class _FancyTextState extends State { assert(widget.textType == TextType.html, 'Unknown TextType: ${widget.textType}.'); + final theme = context.theme; return Html( data: widget.data, - defaultTextStyle: style, onLinkTap: tryLaunchingUrl, + style: { + 'a': Style( + color: theme.primaryColor, + textDecoration: TextDecoration.none, + ), + 'code': Style( + backgroundColor: theme.contrastColor.withOpacity(0.05), + color: theme.primaryColor, + ), + // Reset style so we can render our custom hr. + 'hr': Style( + // TODO(JonasWanke): Check rendering when margin is merged into existing styles. + margin: EdgeInsets.all(0), + border: Border.fromBorderSide(BorderSide.none), + ), + 's': Style(textDecoration: TextDecoration.lineThrough), + }, + customRender: { + 'hr': (_, __, ___, ____) => Divider(), + // If the src-attribute point to an internal asset (/files/file...) we + // have to add our token. + 'img': (context, _, attributes, __) { + final src = attributes['src']; + if (src == null || src.isBlank) { + return null; + } + + final parsed = Uri.tryParse(src); + if (parsed == null || + (parsed.isAbsolute && parsed.host != services.config.host)) { + return null; + } + + final resolved = + Uri.parse(services.config.baseWebUrl).resolveUri(parsed); + return Image.network( + resolved.toString(), + headers: {'Cookie': 'jwt=${services.storage.token.getValue()}'}, + frameBuilder: (_, child, frame, __) { + if (frame == null) { + return Text(attributes['alt'] ?? '', + style: context.style.generateTextStyle()); + } + return child; + }, + ); + }, + }, ); } } diff --git a/lib/app/widgets/top_level_screen_wrapper.dart b/lib/app/widgets/top_level_screen_wrapper.dart new file mode 100644 index 00000000..9eb7f5c4 --- /dev/null +++ b/lib/app/widgets/top_level_screen_wrapper.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:logger_flutter/logger_flutter.dart'; +import 'package:schulcloud/sign_in/sign_in.dart'; + +import '../logger.dart'; +import '../services/deep_linking.dart'; +import '../services/storage.dart'; +import '../utils.dart'; +import 'schulcloud_app.dart'; + +class TopLevelScreenWrapper extends StatefulWidget { + const TopLevelScreenWrapper({@required this.child}) : assert(child != null); + + final Widget child; + + @override + _TopLevelScreenWrapperState createState() => _TopLevelScreenWrapperState(); +} + +class _TopLevelScreenWrapperState extends State { + StreamSubscription _deepLinksSubscription; + final _scaffoldKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _deepLinksSubscription = services.deepLinking.stream.listen(_handleUri); + } + + @override + void dispose() { + _deepLinksSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LogConsoleOnShake( + child: Scaffold( + key: _scaffoldKey, + body: widget.child, + ), + ); + } + + void _handleUri(Uri uri) { + if (uri == null) { + return; + } + logger.d('Handling URI $uri'); + services.deepLinking.onUriHandled(); + assert(SchulCloudApp.navigator != null); + + final isSignedOut = services.storage.isSignedOut; + final firstSegment = uri.pathSegments[0]; + + if (isSignedOut && (firstSegment == 'login' || firstSegment == 'logout')) { + // We're already signed out and should see the login screen + return; + } + + if (firstSegment == 'login' || firstSegment == 'logout') { + signOut(context); + return; + } + + if (isSignedOut) { + // We're still at the sign in screen. Wait for the user to sign in and + // then continue to our destination. + _scaffoldKey.currentState.showSnackBar(SnackBar( + content: Text(context.s.app_topLevelScreenWrapper_signInFirst), + )); + return; + } + + SignedInScreenState.currentNavigator.pushNamed(uri.toString()); + } +} diff --git a/lib/assignment/assignment.dart b/lib/assignment/assignment.dart index 863e757e..a7fccc0e 100644 --- a/lib/assignment/assignment.dart +++ b/lib/assignment/assignment.dart @@ -1,3 +1,4 @@ export 'data.dart'; +export 'routes.dart'; export 'widgets/assignments_screen.dart'; export 'widgets/dashboard_card.dart'; diff --git a/lib/assignment/data.dart b/lib/assignment/data.dart index d012b4a2..fbe6e565 100644 --- a/lib/assignment/data.dart +++ b/lib/assignment/data.dart @@ -15,6 +15,7 @@ class Assignment implements Entity { @required this.name, @required this.schoolId, @required this.createdAt, + @required this.updatedAt, @required this.availableAt, this.dueAt, @required this.teacherId, @@ -30,6 +31,7 @@ class Assignment implements Entity { assert(name != null), assert(schoolId != null), assert(createdAt != null), + assert(updatedAt != null), assert(availableAt != null), assert(teacherId != null), assert(isPrivate != null), @@ -46,6 +48,7 @@ class Assignment implements Entity { name: data['name'], description: data['description'], createdAt: (data['createdAt'] as String).parseInstant(), + updatedAt: (data['updatedAt'] as String).parseInstant(), availableAt: (data['availableDate'] as String).parseInstant(), dueAt: (data['dueDate'] as String)?.parseInstant(), courseId: data['courseId'] != null @@ -80,10 +83,11 @@ class Assignment implements Entity { @HiveField(14) final Instant createdAt; + @HiveField(19) + final Instant updatedAt; @HiveField(13) final Instant availableAt; - @HiveField(12) final Instant dueAt; bool get isOverdue => dueAt != null && dueAt < Instant.now(); diff --git a/lib/assignment/routes.dart b/lib/assignment/routes.dart new file mode 100644 index 00000000..48288144 --- /dev/null +++ b/lib/assignment/routes.dart @@ -0,0 +1,43 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; + +import 'data.dart'; +import 'widgets/assignment_detail_screen.dart'; +import 'widgets/assignments_screen.dart'; +import 'widgets/edit_submission_screen.dart'; + +const _activeTabPrefix = 'activetabid='; +final assignmentRoutes = Route( + matcher: Matcher.path('homework'), + materialBuilder: (_, result) { + // Query string is stored inside the fragment, e.g.: + // https://schul-cloud.org/homework/#?dueDateFrom=2020-03-09&dueDateTo=2020-03-27&private=true&publicSubmissions=false&sort=updatedAt&sortorder=1&teamSubmissions=true + final query = Uri.parse(result.uri.fragment).queryParameters; + final selection = + AssignmentsScreen.sortFilterConfig.tryParseWebQuery(query); + return AssignmentsScreen(sortFilterSelection: selection); + }, + routes: [ + Route( + matcher: Matcher.path('{assignmentId}'), + materialBuilder: (_, result) { + var tab = result.uri.fragment; + tab = tab.isNotEmpty && tab.startsWith(_activeTabPrefix) + ? tab.substring(_activeTabPrefix.length) + : null; + + return AssignmentDetailScreen( + Id(result['assignmentId']), + initialTab: tab, + ); + }, + routes: [ + Route( + matcher: Matcher.path('submission'), + materialBuilder: (_, result) => + EditSubmissionScreen(Id(result['assignmentId'])), + ), + ], + ), + ], +); diff --git a/lib/assignment/widgets/assignment_details_screen.dart b/lib/assignment/widgets/assignment_detail_screen.dart similarity index 76% rename from lib/assignment/widgets/assignment_details_screen.dart rename to lib/assignment/widgets/assignment_detail_screen.dart index 9d7244c6..c35da443 100644 --- a/lib/assignment/widgets/assignment_details_screen.dart +++ b/lib/assignment/widgets/assignment_detail_screen.dart @@ -6,67 +6,82 @@ import 'package:schulcloud/course/course.dart'; import 'package:schulcloud/file/file.dart'; import '../data.dart'; -import 'edit_submittion_screen.dart'; import 'grade_indicator.dart'; -class AssignmentDetailsScreen extends StatefulWidget { - const AssignmentDetailsScreen({Key key, @required this.assignment}) - : assert(assignment != null), - super(key: key); +class AssignmentDetailScreen extends StatefulWidget { + const AssignmentDetailScreen(this.assignmentId, {this.initialTab}) + : assert(assignmentId != null); - final Assignment assignment; + final Id assignmentId; + final String initialTab; @override - _AssignmentDetailsScreenState createState() => - _AssignmentDetailsScreenState(); + _AssignmentDetailScreenState createState() => _AssignmentDetailScreenState(); } -class _AssignmentDetailsScreenState extends State +class _AssignmentDetailScreenState extends State with TickerProviderStateMixin { - Assignment get assignment => widget.assignment; - @override Widget build(BuildContext context) { final s = context.s; - return CachedRawBuilder( - controller: services.storage.userId.controller, + return CachedRawBuilder( + controller: widget.assignmentId.controller, builder: (context, update) { - final user = update.data; - final showSubmissionTab = - assignment.isPrivate || user?.isTeacher == false; - final showFeedbackTab = assignment.isPublic && user?.isTeacher == false; - final showSubmissionsTab = assignment.isPublic && - (user?.isTeacher == true || assignment.hasPublicSubmissions); - - return FancyTabbedScaffold( - appBarBuilder: (_) => FancyAppBar( - title: Text(assignment.name), - subtitle: _buildSubtitle(context, assignment.courseId), - actions: [ - if (user?.hasPermission(Permission.assignmentEdit) == true) - _buildArchiveAction(context), - ], - bottom: TabBar( + final assignment = update.data; + return CachedRawBuilder( + controller: services.storage.userId.controller, + builder: (context, update) { + final user = update.data; + final showSubmissionTab = + assignment.isPrivate || user?.isTeacher == false; + final showFeedbackTab = + assignment.isPublic && user?.isTeacher == false; + final showSubmissionsTab = assignment.isPublic && + (user?.isTeacher == true || assignment.hasPublicSubmissions); + + final tabs = [ + 'extended', + if (showSubmissionTab) 'submission', + if (showFeedbackTab) 'feedback', + if (showSubmissionsTab) 'submissions', + ]; + var initialTabIndex = tabs.indexOf(widget.initialTab); + if (initialTabIndex < 0) { + initialTabIndex = null; + } + + return FancyTabbedScaffold( + initialTabIndex: initialTabIndex, + appBarBuilder: (_) => FancyAppBar( + title: Text(assignment.name), + subtitle: _buildSubtitle(context, assignment.courseId), + actions: [ + if (user?.hasPermission(Permission.assignmentEdit) == true) + _buildArchiveAction(context, assignment), + ], + bottom: TabBar( + tabs: [ + Tab(text: s.assignment_assignmentDetails_details), + if (showSubmissionTab) + Tab(text: s.assignment_assignmentDetails_submission), + if (showFeedbackTab) + Tab(text: s.assignment_assignmentDetails_feedback), + if (showSubmissionsTab) + Tab(text: s.assignment_assignmentDetails_submissions), + ], + ), + // We want a permanent elevation so tabs are more noticeable. + forceElevated: true, + ), tabs: [ - Tab(text: s.assignment_assignmentDetails_details), - if (showSubmissionTab) - Tab(text: s.assignment_assignmentDetails_submission), - if (showFeedbackTab) - Tab(text: s.assignment_assignmentDetails_feedback), - if (showSubmissionsTab) - Tab(text: s.assignment_assignmentDetails_submissions), + _DetailsTab(assignment: assignment), + if (showSubmissionTab) _SubmissionTab(assignment: assignment), + if (showFeedbackTab) _FeedbackTab(assignment: assignment), + if (showSubmissionsTab) _SubmissionsTab(), ], - ), - // We want a permanent elevation so tabs are more noticeable. - forceElevated: true, - ), - tabs: [ - _DetailsTab(assignment: assignment), - if (showSubmissionTab) _SubmissionTab(assignment: assignment), - if (showFeedbackTab) _FeedbackTab(assignment: assignment), - if (showSubmissionsTab) _SubmissionsTab(), - ], + ); + }, ); }, ); @@ -78,7 +93,7 @@ class _AssignmentDetailsScreenState extends State } return CachedRawBuilder( - controller: assignment.courseId.controller, + controller: courseId.controller, builder: (context, update) { return Row(children: [ CourseColorDot(update.data), @@ -93,7 +108,7 @@ class _AssignmentDetailsScreenState extends State ); } - Widget _buildArchiveAction(BuildContext context) { + Widget _buildArchiveAction(BuildContext context, Assignment assignment) { final s = context.s; return IconButton( @@ -281,12 +296,8 @@ class _SubmissionTab extends StatelessWidget { backgroundColor: Colors.transparent, floatingActionButton: Builder( builder: (context) => FloatingActionButton.extended( - onPressed: () => context.navigator.push(MaterialPageRoute( - builder: (_) => EditSubmissionScreen( - assignment: assignment, - submission: submission, - ), - )), + onPressed: () => context.navigator + .pushNamed('/homework/${assignment.id}/submission'), label: Text(labelText), icon: Icon(Icons.edit), ), @@ -392,7 +403,7 @@ List _buildFileSection( final file = update.data; return FileTile( file: file, - onOpen: (file) => services.get().openFile(file), + onOpen: (file) => services.files.openFile(file), ); }, ), diff --git a/lib/assignment/widgets/assignments_screen.dart b/lib/assignment/widgets/assignments_screen.dart index b27ca1c7..b37f6e95 100644 --- a/lib/assignment/widgets/assignments_screen.dart +++ b/lib/assignment/widgets/assignments_screen.dart @@ -2,76 +2,82 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; +import 'package:schulcloud/assignment/assignment.dart'; import 'package:schulcloud/course/course.dart'; import 'package:time_machine/time_machine.dart'; import '../data.dart'; -import 'assignment_details_screen.dart'; class AssignmentsScreen extends StatefulWidget { + AssignmentsScreen({ + SortFilterSelection sortFilterSelection, + }) : initialSortFilterSelection = + sortFilterSelection ?? sortFilterConfig.defaultSelection; + + static final sortFilterConfig = SortFilter( + sorters: { + 'createdAt': Sorter.simple( + (s) => s.assignment_assignment_property_createdAt, + selector: (assignment) => assignment.createdAt, + ), + 'updatedAt': Sorter.simple( + (s) => s.assignment_assignment_property_updatedAt, + selector: (assignment) => assignment.updatedAt, + ), + 'availableAt': Sorter.simple( + (s) => s.assignment_assignment_property_availableAt, + webQueryKey: 'availableDate', + selector: (assignment) => assignment.availableAt, + ), + 'dueAt': Sorter.simple( + (s) => s.assignment_assignment_property_dueAt, + selector: (assignment) => assignment.dueAt, + ), + }, + defaultSorter: 'dueAt', + filters: { + 'dueAt': DateRangeFilter( + (s) => s.assignment_assignment_property_dueAt, + webQueryKey: 'dueDate', + selector: (assignment) => assignment.dueAt?.inLocalZone()?.calendarDate, + defaultSelection: DateRangeFilterSelection(start: LocalDate.today()), + ), + 'more': FlagsFilter( + (s) => s.assignment_assignment_property_more, + filters: { + 'isArchived': FlagFilter( + (s) => s.assignment_assignment_property_isArchived, + selector: (assignment) => assignment.isArchived, + defaultSelection: false, + ), + 'isPrivate': FlagFilter( + (s) => s.assignment_assignment_property_isPrivate, + webQueryKey: 'private', + selector: (assignment) => assignment.isPrivate, + ), + 'hasPublicSubmissions': FlagFilter( + (s) => s.assignment_assignment_property_hasPublicSubmissions, + webQueryKey: 'publicSubmissions', + selector: (assignment) => assignment.hasPublicSubmissions, + ), + }, + ), + }, + ); + final SortFilterSelection initialSortFilterSelection; + @override _AssignmentsScreenState createState() => _AssignmentsScreenState(); } class _AssignmentsScreenState extends State { - SortFilter _sortFilterConfig; SortFilterSelection _sortFilter; @override - void didChangeDependencies() { - super.didChangeDependencies(); + void initState() { + super.initState(); - final s = context.s; - _sortFilterConfig = SortFilter( - sortOptions: { - 'createdAt': Sorter.simple( - s.assignment_assignment_property_createdAt, - selector: (assignment) => assignment.createdAt, - ), - 'availableAt': Sorter.simple( - s.assignment_assignment_property_availableAt, - selector: (assignment) => assignment.availableAt, - ), - 'dueAt': Sorter.simple( - s.assignment_assignment_property_dueAt, - selector: (assignment) => assignment.dueAt, - ), - }, - filters: { - 'dueAt': DateRangeFilter( - s.assignment_assignment_property_dueAt, - selector: (assignment) => - assignment.dueAt?.inLocalZone()?.calendarDate, - ), - 'more': FlagsFilter( - s.assignment_assignment_property_more, - filters: { - 'isArchived': FlagFilter( - s.assignment_assignment_property_isArchived, - selector: (assignment) => assignment.isArchived, - ), - 'isPrivate': FlagFilter( - s.assignment_assignment_property_isPrivate, - selector: (assignment) => assignment.isPrivate, - ), - 'hasPublicSubmissions': FlagFilter( - s.assignment_assignment_property_hasPublicSubmissions, - selector: (assignment) => assignment.hasPublicSubmissions, - ), - }, - ), - }, - ); - _sortFilter ??= SortFilterSelection( - config: _sortFilterConfig, - sortSelectionKey: 'dueAt', - filterSelections: { - 'dueAt': DateRangeFilterSelection(start: LocalDate.today()), - 'more': { - 'isArchived': false, - }, - }, - ); + _sortFilter ??= widget.initialSortFilterSelection; } @override @@ -156,9 +162,7 @@ class AssignmentCard extends StatelessWidget { final SetFlagFilterCallback setFlagFilterCallback; void _showAssignmentDetailsScreen(BuildContext context) { - context.navigator.push(MaterialPageRoute( - builder: (context) => AssignmentDetailsScreen(assignment: assignment), - )); + context.navigator.pushNamed('/homework/${assignment.id}'); } @override diff --git a/lib/assignment/widgets/dashboard_card.dart b/lib/assignment/widgets/dashboard_card.dart index 2d614729..a1fc7d79 100644 --- a/lib/assignment/widgets/dashboard_card.dart +++ b/lib/assignment/widgets/dashboard_card.dart @@ -17,8 +17,7 @@ class AssignmentDashboardCard extends StatelessWidget { return DashboardCard( title: s.assignment_dashboardCard, footerButtonText: s.assignment_dashboardCard_all, - onFooterButtonPressed: () => context.navigator - .push(MaterialPageRoute(builder: (context) => AssignmentsScreen())), + onFooterButtonPressed: () => context.navigator.pushNamed('/homework'), child: CachedRawBuilder>( controller: services.storage.root.assignments.controller, builder: (context, update) { diff --git a/lib/assignment/widgets/edit_submittion_screen.dart b/lib/assignment/widgets/edit_submission_screen.dart similarity index 80% rename from lib/assignment/widgets/edit_submittion_screen.dart rename to lib/assignment/widgets/edit_submission_screen.dart index 456f4857..9c8e5e65 100644 --- a/lib/assignment/widgets/edit_submittion_screen.dart +++ b/lib/assignment/widgets/edit_submission_screen.dart @@ -1,12 +1,51 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cached/flutter_cached.dart'; import 'package:pedantic/pedantic.dart'; import 'package:schulcloud/app/app.dart'; import '../data.dart'; -class EditSubmissionScreen extends StatefulWidget { - const EditSubmissionScreen({ +class EditSubmissionScreen extends StatelessWidget { + const EditSubmissionScreen(this.assignmentId) : assert(assignmentId != null); + + final Id assignmentId; + + @override + Widget build(BuildContext context) { + return CachedRawBuilder( + controller: assignmentId.controller, + builder: (_, assignmentUpdate) { + final assignment = assignmentUpdate.data; + return CachedRawBuilder( + controller: assignment.mySubmission, + builder: (_, update) { + final submission = update.data; + + if (assignmentUpdate.hasError || update.hasError) { + return Center( + child: + Text((assignmentUpdate.error ?? update.error).toString()), + ); + } + + if (assignmentUpdate.data == null) { + return Center(child: CircularProgressIndicator()); + } + + return EditSubmissionForm( + assignment: assignment, + submission: submission, + ); + }, + ); + }, + ); + } +} + +class EditSubmissionForm extends StatefulWidget { + const EditSubmissionForm({ Key key, @required this.assignment, this.submission, @@ -17,10 +56,10 @@ class EditSubmissionScreen extends StatefulWidget { final Submission submission; @override - _EditSubmissionScreenState createState() => _EditSubmissionScreenState(); + _EditSubmissionFormState createState() => _EditSubmissionFormState(); } -class _EditSubmissionScreenState extends State { +class _EditSubmissionFormState extends State { final _formKey = GlobalKey(); bool _isValid; bool _ignoreFormattingOverwrite = false; @@ -64,9 +103,6 @@ class _EditSubmissionScreenState extends State { if (result) { await submission.delete(); - // Intentionally using a context outside our scaffold. The - // current scaffold only exists inside the route and is being - // removed by Navigator.pop(). await services.snackBar .showMessage(s.assignment_editSubmission_delete_success); context.navigator.pop(); diff --git a/lib/calendar/widgets/dashboard_card.dart b/lib/calendar/widgets/dashboard_card.dart index 1c9d3f8f..442f34ad 100644 --- a/lib/calendar/widgets/dashboard_card.dart +++ b/lib/calendar/widgets/dashboard_card.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; @@ -8,7 +11,20 @@ import 'package:time_machine/time_machine.dart'; import '../bloc.dart'; import '../data.dart'; -class CalendarDashboardCard extends StatelessWidget { +class CalendarDashboardCard extends StatefulWidget { + @override + _CalendarDashboardCardState createState() => _CalendarDashboardCardState(); +} + +class _CalendarDashboardCardState extends State { + StreamSubscription _subscription; + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final s = context.s; @@ -31,15 +47,28 @@ class CalendarDashboardCard extends StatelessWidget { final now = Instant.now(); final events = update.data.where((e) => e.end > now); + _subscription?.cancel(); if (events.isEmpty) { - return Text( - s.calendar_dashboardCard_empty, - textAlign: TextAlign.center, + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: FancyText( + s.calendar_dashboardCard_empty, + emphasis: TextEmphasis.medium, + textAlign: TextAlign.center, + ), + ), ); } + // Update this widget when the current event is over. + final nextEnd = events.map((e) => e.end).min(); + _subscription = + Future.delayed(Instant.now().timeUntil(nextEnd).toDuration) + .asStream() + .listen((_) => setState(() {})); return Column( - children: events.map((e) => _EventPreview(event: e)).toList(), + children: events.map((e) => _EventPreview(e)).toList(), ); }, ), @@ -48,9 +77,7 @@ class CalendarDashboardCard extends StatelessWidget { } class _EventPreview extends StatelessWidget { - const _EventPreview({Key key, @required this.event}) - : assert(event != null), - super(key: key); + const _EventPreview(this.event) : assert(event != null); final Event event; @@ -60,7 +87,7 @@ class _EventPreview extends StatelessWidget { final textTheme = context.textTheme; final hasStarted = event.start <= now; - Widget widget = ListTile( + return ListTile( title: Text( event.title, style: hasStarted ? textTheme.headline : textTheme.subhead, @@ -77,23 +104,5 @@ class _EventPreview extends StatelessWidget { ), ), ); - - final durationMicros = event.duration.inMicroseconds; - if (hasStarted && durationMicros != 0) { - widget = Column( - children: [ - widget, - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: LinearProgressIndicator( - value: now.timeSince(event.start).inMicroseconds / durationMicros, - valueColor: AlwaysStoppedAnimation(context.theme.primaryColor), - backgroundColor: context.theme.primaryColor.withOpacity(0.12), - ), - ) - ], - ); - } - return widget; } } diff --git a/lib/course/course.dart b/lib/course/course.dart index 0ccda398..df838926 100644 --- a/lib/course/course.dart +++ b/lib/course/course.dart @@ -1,4 +1,5 @@ export 'data.dart'; +export 'routes.dart'; export 'widgets/course_chip.dart'; export 'widgets/course_color_dot.dart'; export 'widgets/course_detail_screen.dart'; diff --git a/lib/course/data.dart b/lib/course/data.dart index 347d947c..ae3e62df 100644 --- a/lib/course/data.dart +++ b/lib/course/data.dart @@ -1,9 +1,9 @@ import 'dart:ui'; import 'package:hive/hive.dart'; +import 'package:dartx/dartx.dart'; import 'package:meta/meta.dart'; import 'package:schulcloud/app/app.dart'; -import 'package:schulcloud/file/file.dart'; part 'data.g.dart'; @@ -12,28 +12,28 @@ class Course implements Entity { Course({ @required this.id, @required this.name, - @required this.description, + this.description, @required this.teacherIds, @required this.color, }) : assert(id != null), assert(name != null), - assert(description != null), + assert(description?.isBlank != true), assert(teacherIds != null), assert(color != null), lessons = LazyIds( collectionId: 'lessons of course $id', fetcher: () async => Lesson.fetchList(courseId: id), ), - files = LazyIds( - collectionId: 'files of $id', - fetcher: () => File.fetchList(id), + visibleLessons = LazyIds( + collectionId: 'visible lessons of course $id', + fetcher: () async => Lesson.fetchList(courseId: id, hidden: false), ); Course.fromJson(Map data) : this( id: Id(data['_id']), name: data['name'], - description: data['description'], + description: (data['description'] as String).blankToNull, teacherIds: (data['teacherIds'] as List).castIds(), color: (data['color'] as String).hexToColor, ); @@ -58,36 +58,54 @@ class Course implements Entity { final Color color; final LazyIds lessons; + final LazyIds visibleLessons; +} - final LazyIds files; +extension CourseId on Id { + String get webUrl => scWebUrl('courses/$this'); } @HiveType(typeId: TypeId.lesson) -class Lesson implements Entity { +class Lesson implements Entity, Comparable { const Lesson({ @required this.id, + @required this.courseId, @required this.name, @required this.contents, + @required this.isHidden, + @required this.position, }) : assert(id != null), + assert(courseId != null), assert(name != null), - assert(contents != null); + assert(contents != null), + assert(isHidden != null), + assert(position != null); Lesson.fromJson(Map data) : this( id: Id(data['_id']), + courseId: Id(data['courseId']), name: data['name'], contents: (data['contents'] as List) .map((content) => Content.fromJson(content)) - .where((c) => c != null) .toList(), + isHidden: data['hidden'] ?? false, + position: data['position'], ); static Future fetch(Id id) async => Lesson.fromJson(await services.api.get('lessons/$id').json); - static Future> fetchList({Id courseId}) async { + static Future> fetchList({ + Id courseId, + bool hidden, + }) async { final jsonList = await services.api.get('lessons', parameters: { if (courseId != null) 'courseId': courseId.value, + if (hidden == true) + 'hidden': 'true' + else if (hidden == false) + 'hidden[\$ne]': 'true', }).parseJsonList(); return jsonList.map((data) => Lesson.fromJson(data)).toList(); } @@ -96,61 +114,51 @@ class Lesson implements Entity { @HiveField(0) final Id id; + @HiveField(3) + final Id courseId; + @HiveField(1) final String name; @HiveField(2) final List contents; -} + Iterable get visibleContents => contents.where((c) => c.isVisible); -@HiveType(typeId: TypeId.contentType) -enum ContentType { - @HiveField(0) - text, + @HiveField(5) + final bool isHidden; + bool get isVisible => !isHidden; - @HiveField(1) - etherpad, + @HiveField(4) + final int position; + @override + int compareTo(Lesson other) => position.compareTo(other.position); - @HiveField(2) - nexboad, + String get webUrl => '${courseId.webUrl}/topics/$id'; } @HiveType(typeId: TypeId.content) class Content implements Entity { - const Content({ + Content({ @required this.id, @required this.title, - @required this.type, - this.text, - this.url, + @required this.isHidden, + @required this.component, }) : assert(id != null), - assert(title != null), - assert(type != null); + assert(title?.isBlank != true), + assert(isHidden != null), + assert(component != null); factory Content.fromJson(Map data) { - ContentType type; - switch (data['component']) { - case 'text': - type = ContentType.text; - break; - case 'Etherpad': - type = ContentType.etherpad; - break; - case 'neXboard': - type = ContentType.nexboad; - break; - default: - return null; - } return Content( id: Id(data['_id']), - title: data['title'] != '' ? data['title'] : 'Ohne Titel', - type: type, - text: type == ContentType.text ? data['content']['text'] : null, - url: type != ContentType.text ? data['content']['url'] : null, + title: (data['title'] as String).blankToNull, + isHidden: data['hidden'] ?? false, + component: Component.fromJson(data), ); } + // Used before: 2 – 4 + @override @HiveField(0) final Id id; @@ -158,14 +166,93 @@ class Content implements Entity { @HiveField(1) final String title; - @HiveField(2) - final ContentType type; + @HiveField(5) + final bool isHidden; + bool get isVisible => !isHidden; - @HiveField(3) + @HiveField(6) + final Component component; +} + +abstract class Component { + const Component(); + + factory Component.fromJson(Map data) { + final componentFactory = _componentFactories[data['component']]; + if (componentFactory == null) { + return UnsupportedComponent(); + } + + return componentFactory(data['content'] ?? {}); + } + static final _componentFactories = { + 'text': (content) => TextComponent.fromJson(content), + 'Etherpad': (content) => EtherpadComponent.fromJson(content), + 'neXboard': (content) => NexboardComponent.fromJson(content), + }; +} + +@HiveType(typeId: TypeId.unsupportedComponent) +class UnsupportedComponent extends Component { + const UnsupportedComponent(); +} + +@HiveType(typeId: TypeId.textComponent) +class TextComponent extends Component { + TextComponent({ + @required this.text, + }) : assert(text?.isBlank != true); + + factory TextComponent.fromJson(Map data) { + return TextComponent( + text: (data['text'] as String).blankToNull, + ); + } + + @HiveField(0) final String text; +} - @HiveField(4) +@HiveType(typeId: TypeId.etherpadComponent) +class EtherpadComponent extends Component { + EtherpadComponent({ + @required this.url, + this.description, + }) : assert(url != null), + assert(description?.isBlank != true); + + factory EtherpadComponent.fromJson(Map data) { + return EtherpadComponent( + url: data['url'], + description: (data['description'] as String).blankToNull, + ); + } + + @HiveField(0) + final String url; + + @HiveField(1) + final String description; +} + +@HiveType(typeId: TypeId.nexboardComponent) +class NexboardComponent extends Component { + NexboardComponent({ + @required this.url, + this.description, + }) : assert(url != null), + assert(description?.isBlank != true); + + factory NexboardComponent.fromJson(Map data) { + return NexboardComponent( + url: data['url'], + description: (data['description'] as String).blankToNull, + ); + } + + @HiveField(0) final String url; - bool get isText => text != null; + @HiveField(1) + final String description; } diff --git a/lib/course/routes.dart b/lib/course/routes.dart new file mode 100644 index 00000000..a818c927 --- /dev/null +++ b/lib/course/routes.dart @@ -0,0 +1,28 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; + +import 'data.dart'; +import 'widgets/course_detail_screen.dart'; +import 'widgets/courses_screen.dart'; +import 'widgets/lesson_screen.dart'; + +final courseRoutes = Route( + matcher: Matcher.path('courses'), + materialBuilder: (_, __) => CoursesScreen(), + routes: [ + Route( + matcher: Matcher.path('{courseId}'), + materialBuilder: (_, result) => + CourseDetailsScreen(Id(result['courseId'])), + routes: [ + Route( + matcher: Matcher.path('topics/{topicId}'), + materialBuilder: (_, result) => LessonScreen( + courseId: Id(result['courseId']), + lessonId: Id(result['topicId']), + ), + ), + ], + ), + ], +); diff --git a/lib/course/widgets/content_view.dart b/lib/course/widgets/content_view.dart new file mode 100644 index 00000000..56e83764 --- /dev/null +++ b/lib/course/widgets/content_view.dart @@ -0,0 +1,163 @@ +import 'dart:math'; + +import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cached/flutter_cached.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:schulcloud/app/app.dart'; + +import '../data.dart'; + +class ContentView extends StatelessWidget { + const ContentView(this.content, {Key key}) + : assert(content != null), + super(key: key); + + final Content content; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (content.title != null) + Text(content.title, style: context.textTheme.headline), + _ComponentView(content.component), + ], + ); + } +} + +class _ComponentView extends StatelessWidget { + const _ComponentView(this.component, {Key key}) + : assert(component != null), + super(key: key); + + final Component component; + + @override + Widget build(BuildContext context) { + // Required so Dart automatically casts component in if-branches. + final component = this.component; + if (component is TextComponent) { + if (component.text == null) { + return SizedBox(); + } + return FancyText.rich(component.text); + } + if (component is EtherpadComponent) { + return _ComponentWrapper( + description: component.description, + url: component.url, + child: _ExternalContentWebView(component.url), + ); + } + if (component is NexboardComponent) { + return CachedRawBuilder( + controller: services.storage.userId.controller, + builder: (context, update) { + if (update.hasError) { + return ErrorBanner(update.error, update.stackTrace); + } else if (update.hasNoData) { + return Center(child: CircularProgressIndicator()); + } + + final user = update.data; + // https://github.com/schul-cloud/schulcloud-client/blob/90e7d1f70be4b0e8224f9e18525a7ef1c7ff297a/views/topic/components/content-neXboard.hbs#L3-L4 + final url = + '${component.url}?disableConference=true&username=${user.avatarInitials}'; + return _ComponentWrapper( + description: component.description, + url: url, + child: _ExternalContentWebView(url), + ); + }, + ); + } + + assert(component is UnsupportedComponent); + return EmptyStateScreen(text: context.s.course_contentView_unsupported); + } +} + +class _ComponentWrapper extends StatelessWidget { + const _ComponentWrapper({ + Key key, + this.description, + @required this.child, + this.url, + }) : assert(child != null), + super(key: key); + + final String description; + final Widget child; + final String url; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (description != null) ...[ + FancyText(description, emphasis: TextEmphasis.medium), + SizedBox(height: 8), + ], + child, + if (url != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: FlatButton.icon( + textColor: context.theme.mediumEmphasisOnBackground, + onPressed: () => tryLaunchingUrl(url), + icon: Icon(Icons.open_in_new), + label: Text(context.s.general_viewInBrowser), + ), + ), + ], + ); + } +} + +class _ExternalContentWebView extends StatefulWidget { + const _ExternalContentWebView(this.url, {Key key}) + : assert(url != null), + super(key: key); + + final String url; + + @override + _ExternalContentWebViewState createState() => _ExternalContentWebViewState(); +} + +class _ExternalContentWebViewState extends State<_ExternalContentWebView> + with AutomaticKeepAliveClientMixin { + // We want WebViews to keep their state after scrolling, so e.g. an Etherpad + // doesn't have to reload. + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + // About 75 % of the device height minus AppBar and BottomNavigationBar. + final webViewHeight = (context.mediaQuery.size.height - 2 * 64) * 0.75; + return LimitedBox( + maxHeight: max(384, webViewHeight), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: context.theme.primaryColor), + ), + // To make the border visible. + padding: EdgeInsets.all(1), + child: InAppWebView( + initialUrl: widget.url, + initialOptions: InAppWebViewWidgetOptions( + inAppWebViewOptions: + InAppWebViewOptions(transparentBackground: true), + ), + ), + ), + ); + } +} diff --git a/lib/course/widgets/course_card.dart b/lib/course/widgets/course_card.dart index 4dfb1146..51a6938a 100644 --- a/lib/course/widgets/course_card.dart +++ b/lib/course/widgets/course_card.dart @@ -4,23 +4,16 @@ import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; import '../data.dart'; -import 'course_detail_screen.dart'; class CourseCard extends StatelessWidget { const CourseCard(this.course) : assert(course != null); final Course course; - void _openDetailsScreen(BuildContext context) { - context.navigator.push(MaterialPageRoute( - builder: (context) => CourseDetailsScreen(course: course), - )); - } - @override Widget build(BuildContext context) { return FancyCard( - onTap: () => _openDetailsScreen(context), + onTap: () => context.navigator.pushNamed('/courses/${course.id}'), color: course.color.withOpacity(0.12), child: Row( children: [ diff --git a/lib/course/widgets/course_chip.dart b/lib/course/widgets/course_chip.dart index 505c2b58..db8d9593 100644 --- a/lib/course/widgets/course_chip.dart +++ b/lib/course/widgets/course_chip.dart @@ -4,7 +4,6 @@ import 'package:schulcloud/app/app.dart'; import '../data.dart'; import 'course_color_dot.dart'; -import 'course_detail_screen.dart'; class CourseChip extends StatelessWidget { const CourseChip(this.course, {Key key, this.onPressed}) : super(key: key); @@ -25,9 +24,7 @@ class CourseChip extends StatelessWidget { avatar: CourseColorDot(course), label: FancyText(course?.name), onPressed: onPressed ?? - () => context.navigator.push(MaterialPageRoute( - builder: (_) => CourseDetailsScreen(course: course), - )), + () => context.navigator.pushNamed('/courses/${course.id}'), ); } } diff --git a/lib/course/widgets/course_detail_screen.dart b/lib/course/widgets/course_detail_screen.dart index 4b79eaaa..4e5ec755 100644 --- a/lib/course/widgets/course_detail_screen.dart +++ b/lib/course/widgets/course_detail_screen.dart @@ -1,77 +1,90 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; -import 'package:schulcloud/file/file.dart'; import '../data.dart'; -import 'lesson_screen.dart'; class CourseDetailsScreen extends StatelessWidget { - const CourseDetailsScreen({@required this.course}) : assert(course != null); + const CourseDetailsScreen(this.courseId) : assert(courseId != null); - final Course course; - - void _showCourseFiles(BuildContext context, Course course) { - context.navigator.push(FileBrowserPageRoute( - builder: (context) => FileBrowser(owner: course), - )); - } - - void _showLessonScreen({BuildContext context, Lesson lesson, Course course}) { - context.navigator.push(MaterialPageRoute( - builder: (context) => LessonScreen(course: course, lesson: lesson), - )); - } + final Id courseId; @override Widget build(BuildContext context) { - return Scaffold( - body: CachedBuilder>( - controller: course.lessons.controller, - errorBannerBuilder: (_, error, st) => ErrorBanner(error, st), - errorScreenBuilder: (_, error, st) => ErrorScreen(error, st), - builder: (context, lessons) { - if (lessons.isEmpty) { - return EmptyStateScreen( - text: context.s.course_detailsScreen_empty, - ); - } - return CustomScrollView( - slivers: [ - FancyAppBar( - title: Text(course.name), - actions: [ - IconButton( - icon: Icon(Icons.folder), - onPressed: () => _showCourseFiles(context, course), - ), - ], - ), - SliverList( - delegate: SliverChildListDelegate([ - Padding( - padding: EdgeInsets.symmetric( - vertical: 18, - horizontal: 12, - ), - child: Text(course.description), - ), - for (final lesson in lessons) - ListTile( - title: Text(lesson.name), - onTap: () => _showLessonScreen( - context: context, - lesson: lesson, - course: course, - ), - ), - ]), + return CachedRawBuilder( + controller: courseId.controller, + builder: (_, update) { + final course = update.data; + if (update.hasError) { + return ErrorScreen(update.error, update.stackTrace); + } else if (!update.hasData) { + return Center(child: CircularProgressIndicator()); + } + + return FancyScaffold( + appBar: FancyAppBar( + title: Text(course.name), + actions: [ + IconButton( + icon: Icon(Icons.folder), + onPressed: () => + context.navigator.pushNamed('/files/courses/${course.id}'), ), ], + ), + omitHorizontalPadding: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (course.description != null) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: FancyText( + course.description, + showRichText: true, + emphasis: TextEmphasis.medium, + ), + ), + SizedBox(height: 16), + ], + // TODO(marcelgarus): use proper slivers when flutter_cached supports them + _buildLessonsSliver(context, course), + ]), + ), + ); + }, + ); + } + + Widget _buildLessonsSliver(BuildContext context, Course course) { + return CachedRawBuilder>( + controller: course.visibleLessons.controller, + builder: (context, update) { + if (update.hasError) { + return ErrorBanner(update.error, update.stackTrace); + } else if (!update.hasData) { + return Center(child: CircularProgressIndicator()); + } + + final lessons = update.data.sorted(); + if (lessons.isEmpty) { + return EmptyStateScreen( + text: context.s.course_detailsScreen_empty, ); - }, - ), + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final lesson in lessons) + ListTile( + title: Text(lesson.name), + onTap: () => context.navigator + .pushNamed('/courses/$courseId/topics/${lesson.id}'), + ), + ], + ); + }, ); } } diff --git a/lib/course/widgets/lesson_screen.dart b/lib/course/widgets/lesson_screen.dart index e41243b1..751a637d 100644 --- a/lib/course/widgets/lesson_screen.dart +++ b/lib/course/widgets/lesson_screen.dart @@ -1,155 +1,72 @@ -import 'dart:convert'; - -import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; -import 'package:sprintf/sprintf.dart'; -import 'package:webview_flutter/webview_flutter.dart'; import '../data.dart'; +import 'content_view.dart'; class LessonScreen extends StatefulWidget { - const LessonScreen({@required this.course, @required this.lesson}) - : assert(course != null), - assert(lesson != null); + const LessonScreen({@required this.courseId, @required this.lessonId}) + : assert(courseId != null), + assert(lessonId != null); - final Course course; - final Lesson lesson; + final Id courseId; + final Id lessonId; @override _LessonScreenState createState() => _LessonScreenState(); } class _LessonScreenState extends State { - static const contentTextFormat = ''' - - - - - - - - - - %0\$s - - -'''; - - WebViewController _controller; - - void _showLessonContentMenu() { - showModalBottomSheet( - context: context, - builder: (_) => BottomSheet( - builder: (_) => _buildBottomSheetContent(), - onClosing: () {}, - ), - ); - } - @override Widget build(BuildContext context) { - return Scaffold( - body: CustomScrollView( - slivers: [ - FancyAppBar( - title: Text(widget.lesson.name), - subtitle: Text(widget.course.name), - actions: [ - IconButton( - icon: Icon(Icons.web), - onPressed: _showLessonContentMenu, - ), - ], - ), - SliverFillRemaining( - child: WebView( - initialUrl: _textOrUrl(widget.lesson.contents[0]), - onWebViewCreated: (controller) => _controller = controller, - javascriptMode: JavascriptMode.unrestricted, - ), - ), - ], - ), - ); - } - - String _createTextSource(String html) { - final theme = context.theme; - - String cssColor(Color color) { - return 'rgba(${color.red}, ${color.green}, ${color.blue}, ${color.opacity})'; - } + final s = context.s; - final fullHtml = sprintf( - contentTextFormat, - [ - html, - services.config.baseWebUrl, - cssColor(theme.contrastColor), - cssColor(theme.accentColor), - ], - ); - return Uri.dataFromString( - fullHtml, - mimeType: 'text/html', - encoding: Encoding.getByName('utf-8'), - ).toString(); - } - - String _textOrUrl(Content content) => - content.isText ? _createTextSource(content.text) : content.url; + return CachedRawBuilder( + controller: widget.lessonId.controller, + builder: (context, update) { + if (update.hasError) { + return ErrorScreen(update.error, update.stackTrace); + } else if (update.hasNoData) { + return Center(child: CircularProgressIndicator()); + } - Widget _buildBottomSheetContent() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var content in widget.lesson.contents) - NavigationItem( - icon: Icons.textsms, - text: content.title, - onPressed: () { - if (_controller == null) { - return; - } - _controller.loadUrl(_textOrUrl(content)); - Navigator.pop(context); - }, + final lesson = update.data; + final contents = lesson.visibleContents.toList(); + return FancyScaffold( + appBar: FancyAppBar( + title: Text(lesson.name), + subtitle: CachedRawBuilder( + controller: widget.courseId.controller, + builder: (_, update) => FancyText(update.data?.name), + ), ), - ], + sliver: contents.isEmpty + ? SliverFillRemaining( + child: EmptyStateScreen( + text: s.course_lessonScreen_empty, + actions: [ + PrimaryButton( + onPressed: () => tryLaunchingUrl(lesson.webUrl), + child: Text(s.general_viewInBrowser), + ), + ], + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + if (i.isOdd) { + return Divider(); + } + + return ContentView(contents[i ~/ 2]); + }, + childCount: contents.length * 2 - 1, + ), + ), + ); + }, ); } } diff --git a/lib/dashboard/dashboard.dart b/lib/dashboard/dashboard.dart index 9d13e699..453c7d3d 100644 --- a/lib/dashboard/dashboard.dart +++ b/lib/dashboard/dashboard.dart @@ -1,2 +1,3 @@ +export 'routes.dart'; export 'widgets/dashboard_card.dart'; export 'widgets/dashboard_screen.dart'; diff --git a/lib/dashboard/routes.dart b/lib/dashboard/routes.dart new file mode 100644 index 00000000..d22aa03b --- /dev/null +++ b/lib/dashboard/routes.dart @@ -0,0 +1,8 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; + +import 'widgets/dashboard_screen.dart'; + +final dashboardRoutes = Route( + matcher: Matcher.path('dashboard'), + materialBuilder: (_, __) => DashboardScreen(), +); diff --git a/lib/dashboard/widgets/dashboard_card.dart b/lib/dashboard/widgets/dashboard_card.dart index 509b79fb..9e50356d 100644 --- a/lib/dashboard/widgets/dashboard_card.dart +++ b/lib/dashboard/widgets/dashboard_card.dart @@ -28,6 +28,7 @@ class DashboardCard extends StatelessWidget { title: title, color: color, omitHorizontalPadding: true, + omitBottomPadding: footerButtonText != null, child: Column( children: [ Padding( @@ -38,13 +39,13 @@ class DashboardCard extends StatelessWidget { ), if (footerButtonText != null) Container( - padding: EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), alignment: Alignment.bottomRight, child: OutlineButton( onPressed: onFooterButtonPressed, child: Text(footerButtonText), ), - ) + ), ], ), ); diff --git a/lib/file/data.dart b/lib/file/data.dart index 8017673e..525a7662 100644 --- a/lib/file/data.dart +++ b/lib/file/data.dart @@ -2,7 +2,6 @@ import 'dart:io' as io; import 'package:hive/hive.dart'; import 'package:meta/meta.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/course/course.dart'; import 'package:time_machine/time_machine.dart'; @@ -14,28 +13,56 @@ String _extension(String fileName) { return lastDot == null ? null : fileName.substring(lastDot + 1); } +const _defaultFile = Id('invalid'); + +@HiveType(typeId: TypeId.filePath) +@immutable +class FilePath { + const FilePath(this.ownerId, [this.parentId]) + : assert(ownerId != null), + assert(ownerId is Id || ownerId is Id); + + @HiveField(0) + final Id ownerId; + + @HiveField(1) + final Id parentId; + + LazyIds get files => LazyIds( + collectionId: 'files of $ownerId in directory $parentId', + fetcher: () => File.fetchList(this), + ); + + FilePath copyWith({Id ownerId, Id parentId = _defaultFile}) { + return FilePath( + ownerId ?? this.ownerId, + parentId == _defaultFile ? this.parentId : parentId, + ); + } + + @override + String toString() => '$ownerId/${parentId ?? 'root'}'; +} + @HiveType(typeId: TypeId.file) class File implements Entity, Comparable { File({ @required this.id, @required this.name, - @required this.ownerId, + @required this.path, @required this.createdAt, @required this.updatedAt, - @required this.parentId, @required this.isDirectory, @required this.mimeType, @required this.size, }) : assert(id != null), assert(name != null), - assert(ownerId != null), - assert(ownerId is Id || ownerId is Id), assert(createdAt != null), assert(updatedAt != null), assert(isDirectory != null), files = LazyIds( collectionId: 'files in directory $id', - fetcher: () => File.fetchList(ownerId, parentId: id), + fetcher: () => File.fetchList(path.copyWith(parentId: id)), ); File.fromJson(Map data) @@ -43,32 +70,31 @@ class File implements Entity, Comparable { id: Id(data['_id']), name: data['name'], mimeType: data['type'], - ownerId: { - 'user': Id(data['owner']), - 'course': Id(data['owner']), - }[data['refOwnerModel']], + path: FilePath( + { + 'user': Id(data['owner']), + 'course': Id(data['owner']), + }[data['refOwnerModel']], + Id.orNull(data['parent']), + ), createdAt: (data['createdAt'] as String).parseInstant(), updatedAt: (data['updatedAt'] as String).parseInstant(), isDirectory: data['isDirectory'], - parentId: data['parent'] == null ? null : Id(data['parent']), size: data['size'], ); - static Future> fetchList( - Id ownerId, { - Id parentId, - }) async { + static Future> fetchList(FilePath path) async { final files = await services.api.get( 'fileStorage', parameters: { - 'owner': ownerId.value, - if (parentId != null) 'parent': parentId.value, + 'owner': path.ownerId.value, + if (path.parentId != null) 'parent': path.parentId.value, }, ).parseJsonList(isServicePaginated: false); return files.map((data) => File.fromJson(data)).toList(); } - // used before: 7, 8 + // used before: 3, 5, 7, 8 @override @HiveField(0) @@ -78,9 +104,10 @@ class File implements Entity, Comparable { final String name; String get extension => _extension(name); - /// An [Id] for either a [User] or [Course]. - @HiveField(3) - final Id ownerId; + @HiveField(12) + final FilePath path; + Id get ownerId => path.ownerId; + Id get parentId => path.parentId; @HiveField(10) final Instant createdAt; @@ -88,10 +115,6 @@ class File implements Entity, Comparable { @HiveField(9) final Instant updatedAt; - /// The parent directory. - @HiveField(5) - final Id parentId; - @HiveField(11) final bool isDirectory; bool get isActualFile => !isDirectory; @@ -117,18 +140,13 @@ class File implements Entity, Comparable { return name.compareTo(other.name); } - File copyWith({ - String name, - Id ownerId, - Id parentId, - }) { + File copyWith({String name, FilePath path}) { return File( id: id, name: name ?? this.name, - ownerId: ownerId ?? this.ownerId, + path: path ?? this.path, createdAt: createdAt, updatedAt: updatedAt, - parentId: parentId ?? this.parentId, isDirectory: isDirectory, mimeType: mimeType, size: size, @@ -147,12 +165,16 @@ class File implements Entity, Comparable { await services.api.patch('fileStorage/$id', body: { 'parent': parentDirectory, }); - copyWith(parentId: parentDirectory).saveToCache(); + copyWith(path: path.copyWith(parentId: parentDirectory)).saveToCache(); } Future delete() => services.api.delete('fileStorage/$id'); } +extension FileLoading on Id { + LazyIds files([Id parentId]) => FilePath(this, parentId).files; +} + class LocalFile { LocalFile({ @required this.fileId, diff --git a/lib/file/file.dart b/lib/file/file.dart index 9191892a..c87e48f4 100644 --- a/lib/file/file.dart +++ b/lib/file/file.dart @@ -1,5 +1,7 @@ -export 'bloc.dart'; export 'data.dart'; +export 'routes.dart'; +export 'service.dart'; +export 'widgets/choose_destination.dart'; export 'widgets/file_browser.dart'; export 'widgets/file_tile.dart'; export 'widgets/files_screen.dart'; diff --git a/lib/file/routes.dart b/lib/file/routes.dart new file mode 100644 index 00000000..4e32652b --- /dev/null +++ b/lib/file/routes.dart @@ -0,0 +1,55 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; +import 'package:schulcloud/course/course.dart'; + +import 'data.dart'; +import 'widgets/file_browser.dart'; +import 'widgets/files_screen.dart'; +import 'widgets/page_route.dart'; + +Route _buildSubdirRoute(Id Function(RouteResult result) ownerGetter) { + return Route( + matcher: Matcher.path('{parentId}'), + builder: (result) => FileBrowserPageRoute( + builder: (_) => FileBrowser(FilePath( + ownerGetter(result), + Id(result['parentId']), + )), + settings: result.settings, + ), + ); +} + +final fileRoutes = Route( + matcher: Matcher.path('files'), + materialBuilder: (_, __) => FilesScreen(), + routes: [ + Route( + matcher: Matcher.path('my'), + builder: (result) => FileBrowserPageRoute( + builder: (_) => FileBrowser(FilePath(services.storage.userId)), + settings: result.settings, + ), + routes: [ + _buildSubdirRoute((_) => services.storage.userId), + ], + ), + Route( + matcher: Matcher.path('courses'), + materialBuilder: (_, __) => FilesScreen(), + routes: [ + Route( + matcher: Matcher.path('{courseId}'), + builder: (result) => FileBrowserPageRoute( + builder: (_) => + FileBrowser(FilePath(Id(result['courseId']))), + settings: result.settings, + ), + routes: [ + _buildSubdirRoute((result) => Id(result['courseId'])), + ], + ), + ], + ), + ], +); diff --git a/lib/file/bloc.dart b/lib/file/service.dart similarity index 66% rename from lib/file/bloc.dart rename to lib/file/service.dart index f45ba52e..c84b2e94 100644 --- a/lib/file/bloc.dart +++ b/lib/file/service.dart @@ -1,12 +1,13 @@ import 'dart:convert'; import 'dart:io' as io; +import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:dartx/dartx_io.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:hive/hive.dart'; +import 'package:get_it/get_it.dart'; import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; import 'package:open_file/open_file.dart'; @@ -16,6 +17,7 @@ import 'package:schulcloud/app/app.dart'; import 'package:time_machine/time_machine.dart'; import 'data.dart'; +import 'widgets/choose_destination.dart'; @immutable class UploadProgressUpdate { @@ -32,19 +34,19 @@ class UploadProgressUpdate { } extension AssociatedLocalFile on File { - LocalFile get localFile => services.get().localFiles.get(id.value); + LocalFile get localFile => services.files.localFiles.get(id.value); bool get isDownloaded => localFile != null; } @immutable -class FileBloc { - const FileBloc._(this.localFiles); +class FileService { + const FileService._(this.localFiles); final Box localFiles; - static Future create() async { + static Future create() async { final box = await Hive.openBox('localFiles'); - return FileBloc._(box); + return FileService._(box); } Future openFile(File file) async { @@ -108,14 +110,12 @@ class FileBloc { await localFiles.clear(); } - Stream uploadFile({ - @required Id owner, - Id parent, + Stream uploadFiles({ + @required List files, + @required FilePath path, }) async* { - assert(owner != null); - - // Let the user pick files. - final files = await FilePicker.getMultiFile(); + assert(files != null); + assert(path != null); for (var i = 0; i < files.length; i++) { final file = files[i]; @@ -126,18 +126,19 @@ class FileBloc { totalNumberOfFiles: files.length, ); - await _uploadSingleFile(file: file, owner: owner, parent: parent); + await _uploadSingleFile(file: file, path: path); } } Future _uploadSingleFile({ @required io.File file, - @required Id owner, - Id parent, + @required FilePath path, }) async { assert(file != null); + logger.i('Uploading file $file'); if (!file.existsSync()) { + logger.e("File $file doesn't exist."); return; } @@ -146,15 +147,19 @@ class FileBloc { final mimeType = lookupMimeType(fileName, headerBytes: fileBuffer); // Request a signed url. + logger.d('Requesting a signed url.'); final signedUrlResponse = await services.api.post('fileStorage/signedUrl', body: { 'filename': fileName, 'fileType': mimeType, - if (parent != null) 'parent': parent, + if (path.parentId != null) 'parent': path.parentId, }); final signedInfo = json.decode(signedUrlResponse.body); // Upload the file to the storage server. + logger + ..d('Received signed info $signedInfo.') + ..d('Now uploading the actual file to ${signedInfo['url']}.'); await services.network.put( signedInfo['url'], headers: (signedInfo['header'] as Map).cast(), @@ -162,10 +167,11 @@ class FileBloc { ); // Notify the api backend. + logger.d('Notifying the file backend.'); await services.api.post('fileStorage', body: { 'name': fileName, - if (owner is! Id) ...{ - 'owner': owner.value, + if (path.ownerId is! Id) ...{ + 'owner': path.ownerId.value, // TODO(marcelgarus): For now, we only support user and course owner, but there's also team. 'refOwnerModel': 'course', }, @@ -174,5 +180,42 @@ class FileBloc { 'storageFileName': signedInfo['header']['x-amz-meta-flat-name'], 'thumbnail': signedInfo['header']['x-amz-meta-thumbnail'], }); + logger.i('Done uploading the file.'); + } + + Future uploadFileFromLocalPath({ + @required BuildContext context, + @required String localPath, + }) async { + logger.i('Letting the user choose a destination where to upload ' + '$localPath.'); + final file = io.File(localPath); + + final destination = await context.rootNavigator.push(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ChooseDestinationScreen( + title: Text(context.s.file_chooseDestination_upload), + fabIcon: Icon(Icons.file_upload), + fabLabel: Text(context.s.file_chooseDestination_upload_button), + ), + )); + + if (destination != null) { + logger.i('Uploading to $destination.'); + await services.snackBar.performAction( + action: () => uploadFiles( + files: [file], + path: destination, + ).forEach((_) {}), + loadingMessage: + context.s.file_uploadProgressSnackBarContent(1, file.name, 0), + successMessage: context.s.file_uploadCompletedSnackBar, + failureMessage: context.s.file_uploadFailedSnackBar, + ); + } } } + +extension FileServiceGetIt on GetIt { + FileService get files => get(); +} diff --git a/lib/file/widgets/choose_destination.dart b/lib/file/widgets/choose_destination.dart new file mode 100644 index 00000000..1a20020d --- /dev/null +++ b/lib/file/widgets/choose_destination.dart @@ -0,0 +1,38 @@ +import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:schulcloud/app/app.dart'; + +import '../data.dart'; + +class ChooseDestinationScreen extends StatelessWidget { + const ChooseDestinationScreen({ + @required this.title, + @required this.fabIcon, + @required this.fabLabel, + }) : assert(title != null), + assert(fabIcon != null), + assert(fabLabel != null); + + final Widget title; + final Widget fabIcon; + final Widget fabLabel; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: title), + body: Center( + child: Text( + context.s.file_chooseDestination_content, + textAlign: TextAlign.center, + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => + context.navigator.pop(FilePath(services.storage.userId)), + icon: fabIcon, + label: fabLabel, + ), + ); + } +} diff --git a/lib/file/widgets/file_browser.dart b/lib/file/widgets/file_browser.dart index 2641ca3f..6c38d3df 100644 --- a/lib/file/widgets/file_browser.dart +++ b/lib/file/widgets/file_browser.dart @@ -7,50 +7,53 @@ import 'package:pedantic/pedantic.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/course/course.dart'; -import '../bloc.dart'; import '../data.dart'; +import '../service.dart'; import 'app_bar.dart'; import 'file_tile.dart'; -import 'page_route.dart'; import 'upload_fab.dart'; class FileBrowser extends StatelessWidget { - FileBrowser({ - @required this.owner, - this.parent, + const FileBrowser( + this.path, { this.isEmbedded = false, - }) : assert(owner != null), - assert(parent == null || parent.isDirectory), + }) : assert(path != null), assert(isEmbedded != null); - final Entity owner; - Course get ownerAsCourse => owner is Course ? owner : null; + FileBrowser.myFiles( + Id parentId, { + bool isEmbedded = false, + }) : this( + FilePath(services.storage.userId, parentId), + isEmbedded: isEmbedded, + ); - final File parent; + final FilePath path; + bool get isOwnerCourse => path.ownerId is Id; + bool get isOwnerMe => path.ownerId == services.storage.userId; /// Whether this widget is embedded into another screen. If true, doesn't /// show an app bar. final bool isEmbedded; - CacheController> get _filesController { - // The owner is either a [User] or a [Course]. Either way, the [owner] is - // guaranteed to have a [files] field. - return parent?.files?.controller ?? (owner as dynamic).files.controller; - } - void _openDirectory(BuildContext context, File file) { assert(file.isDirectory); - context.navigator.push(FileBrowserPageRoute( - builder: (context) => FileBrowser(owner: owner, parent: file), - )); + if (isOwnerCourse) { + context.navigator.pushNamed('/files/courses/${path.ownerId}/${file.id}'); + } else if (isOwnerMe) { + context.navigator.pushNamed('/files/my/${file.id}'); + } else { + logger.e( + 'Unknown owner: ${path.ownerId} (type: ${path.ownerId.runtimeType}) while trying to open directory ${file.id}'); + } } Future _downloadFile(BuildContext context, File file) async { assert(file.isActualFile); try { - await services.get().openFile(file); + await services.files.openFile(file); unawaited(services.snackBar .showMessage(context.s.file_fileBrowser_downloading(file.name))); } on PermissionNotGranted { @@ -58,7 +61,7 @@ class FileBrowser extends StatelessWidget { content: Text(context.s.file_fileBrowser_download_storageAccess), action: SnackBarAction( label: context.s.file_fileBrowser_download_storageAccess_allow, - onPressed: services.get().ensureStoragePermissionGranted, + onPressed: services.files.ensureStoragePermissionGranted, ), )); } @@ -71,7 +74,9 @@ class FileBrowser extends StatelessWidget { Widget _buildEmbedded(BuildContext context) { return CachedRawBuilder>( - controller: _filesController, + // The owner is either a [User] or a [Course]. Either way, the [owner] is + // guaranteed to have a [files] field. + controller: path.files.controller, builder: (context, update) { if (update.hasError) { return ErrorScreen(update.error, update.stackTrace); @@ -91,20 +96,73 @@ class FileBrowser extends StatelessWidget { } Widget _buildStandalone(BuildContext context) { + FileBrowserAppBar buildLoadingErrorAppBar( + dynamic error, [ + Color backgroundColor, + ]) { + return FileBrowserAppBar( + title: error?.toString() ?? context.s.general_loading, + backgroundColor: backgroundColor, + ); + } + + Widget appBar; + if (isOwnerCourse) { + appBar = CachedRawBuilder( + controller: path.ownerId.controller, + builder: (context, update) { + if (!update.hasData) { + return buildLoadingErrorAppBar(update.error); + } + + final course = update.data; + if (path.parentId == null) { + return FileBrowserAppBar( + title: course.name, + backgroundColor: course.color, + ); + } + + return CachedRawBuilder( + controller: path.parentId.controller, + builder: (context, update) { + if (!update.hasData) { + return buildLoadingErrorAppBar(update.error, course.color); + } + + final parent = update.data; + return FileBrowserAppBar( + title: parent.name, + backgroundColor: course.color, + ); + }, + ); + }, + ); + } else if (path.parentId != null) { + appBar = CachedRawBuilder( + controller: path.parentId.controller, + builder: (context, update) { + if (!update.hasData) { + return buildLoadingErrorAppBar(update.error); + } + + final parent = update.data; + return FileBrowserAppBar(title: parent.name); + }, + ); + } else if (isOwnerMe) { + appBar = FileBrowserAppBar(title: context.s.file_files_my); + } + return Scaffold( appBar: PreferredSize( preferredSize: AppBar().preferredSize, - child: FileBrowserAppBar( - backgroundColor: ownerAsCourse?.color, - title: parent?.name ?? ownerAsCourse?.name ?? context.s.file_files_my, - ), - ), - floatingActionButton: UploadFab( - ownerId: owner.id, - parentId: parent?.id, + child: appBar, ), + floatingActionButton: UploadFab(path: path), body: CachedBuilder>( - controller: _filesController, + controller: path.files.controller, errorBannerBuilder: (_, error, st) => ErrorBanner(error, st), errorScreenBuilder: (_, error, st) => ErrorScreen(error, st), builder: (context, files) { diff --git a/lib/file/widgets/file_tile.dart b/lib/file/widgets/file_tile.dart index 45ef86ac..b4ee5bfb 100644 --- a/lib/file/widgets/file_tile.dart +++ b/lib/file/widgets/file_tile.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:schulcloud/app/app.dart'; -import '../bloc.dart'; import '../data.dart'; +import '../service.dart'; import 'file_menu.dart'; import 'file_thumbnail.dart'; diff --git a/lib/file/widgets/files_screen.dart b/lib/file/widgets/files_screen.dart index feff4320..43b4d600 100644 --- a/lib/file/widgets/files_screen.dart +++ b/lib/file/widgets/files_screen.dart @@ -5,8 +5,8 @@ import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/course/course.dart'; +import '../data.dart'; import 'file_browser.dart'; -import 'page_route.dart'; import 'upload_fab.dart'; class FilesScreen extends StatelessWidget { @@ -14,7 +14,9 @@ class FilesScreen extends StatelessWidget { Widget build(BuildContext context) { return FancyScaffold( appBar: FancyAppBar(title: Text(context.s.file)), - floatingActionButton: UploadFab(ownerId: services.storage.userId), + floatingActionButton: UploadFab( + path: FilePath(services.storage.userId), + ), sliver: SliverList( delegate: SliverChildListDelegate([ _CoursesList(), @@ -56,16 +58,10 @@ class _CourseCard extends StatelessWidget { final Course course; - void _showCourseFiles(BuildContext context) { - context.navigator.push(FileBrowserPageRoute( - builder: (context) => FileBrowser(owner: course), - )); - } - @override Widget build(BuildContext context) { return FlatMaterial( - onTap: () => _showCourseFiles(context), + onTap: () => context.navigator.pushNamed('/files/courses/${course.id}'), child: SizedBox( height: 48, child: Row( @@ -86,14 +82,7 @@ class _UserFiles extends StatelessWidget { return FancyCard( title: context.s.file_files_my, omitHorizontalPadding: true, - child: CachedRawBuilder( - controller: services.storage.userId.controller, - builder: (context, update) { - return update.hasData - ? FileBrowser(owner: update.data, isEmbedded: true) - : Container(); - }, - ), + child: FileBrowser.myFiles(null, isEmbedded: true), ); } } diff --git a/lib/file/widgets/upload_fab.dart b/lib/file/widgets/upload_fab.dart index ac7ff18f..68d42de7 100644 --- a/lib/file/widgets/upload_fab.dart +++ b/lib/file/widgets/upload_fab.dart @@ -1,22 +1,18 @@ import 'dart:async'; +import 'package:file_picker/file_picker.dart'; import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cached/flutter_cached.dart'; import 'package:schulcloud/app/app.dart'; -import '../bloc.dart'; import '../data.dart'; +import '../service.dart'; class UploadFab extends StatefulWidget { - const UploadFab({@required this.ownerId, this.parentId}) - : assert(ownerId != null); + const UploadFab({@required this.path}) : assert(path != null); - /// The owner of uploaded files. - final Id ownerId; - - /// The parent folder of uploaded files. - final Id parentId; + final FilePath path; @override _UploadFabState createState() => _UploadFabState(); @@ -26,11 +22,11 @@ class _UploadFabState extends State { /// Controller for the [SnackBar]. ScaffoldFeatureController snackBar; - void _startUpload(BuildContext context) { - final updates = services.get().uploadFile( - owner: widget.ownerId, - parent: widget.parentId, - ); + void _startUpload(BuildContext context) async { + final updates = services.files.uploadFiles( + files: await FilePicker.getMultiFile(), + path: widget.path, + ); snackBar = context.scaffold.showSnackBar(SnackBar( duration: Duration(days: 1), diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 536a6df0..f9d0a4ad 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -18,6 +18,7 @@ "app_notFound_message": "404 – Wir konnten die von dir aufgerufene Seite nicht finden:\n{uri}", "app_signOut_content": "Du musst dich danach erneut anmelden, um die App zu benutzen.", "app_signOut_title": "Abmelden?", + "app_topLevelScreenWrapper_signInFirst": "Bitte logge dich erst ein und öffne danach den Link.", "assignment": "Hausaufgaben", "assignment_assignment_isArchived": "Archiviert", "assignment_assignment_isPrivate": "Privat", @@ -65,8 +66,13 @@ "course": "Kurse", "course_coursesScreen_empty": "Es wurden keine Kurse gefunden.", "course_detailsScreen_empty": "Dieser Kurs enthält noch keine Themen.", + "course_lessonScreen_empty": "Dieses Thema enthält keine Inhalte.", + "course_contentView_unsupported": "Dieser Inhalt wird in der App noch nicht unterstützt.", "dashboard": "Übersicht", "file": "Dateien", + "file_chooseDestination_content": "Bislang kannst du noch kein Uploadziel wählen.\nDie Datei wird in deinen persönlichen Ordner hochgeladen.", + "file_chooseDestination_upload": "Wähle ein Uploadziel", + "file_chooseDestination_upload_button": "Hochladen", "file_delete_loading": "{name} wird gelöscht…", "file_delete_success": "{name} gelöscht 😊", "file_delete_failure": "{name} konnte nicht gelöscht werden", @@ -94,6 +100,7 @@ "file_renameDialog_inputHint": "Neuer Dateiname", "file_renameDialog_rename": "Umbenennen", "file_uploadCompletedSnackBar": "Fertig hochgeladen 😊", + "file_uploadFailedSnackBar": "Upload fehlgeschlagen 😬", "file_uploadProgressSnackBarContent": "{total, plural, one{{fileName} wird hochgeladen…} other{{fileName} wird hochgeladen… ({current} / {total})}}", "general_cancel": "Abbrechen", "general_dismiss": "Verstanden", @@ -105,6 +112,7 @@ "general_signOut": "Abmelden", "general_undo": "Rückgängig machen", "general_user_unknown": "Unbekannt", + "general_viewInBrowser": "Im Browser ansehen", "news": "Neuigkeiten", "news_articlePreview_subtitle": "Veröffentlicht am {published} von {author}", "news_authorView": "von {author}", @@ -132,5 +140,5 @@ "signIn_form_signIn": "Einloggen", "signIn_signInScreen_about": "Das Hasso-Plattner-Institut für Digital Engineering entwickelt unter der Leitung von Prof. Dr. Christoph Meinel zusammen mit MINT-EC, dem nationalen Excellence-Schulnetzwerk von über 300 Schulen bundesweit und unterstützt vom Bundesministerium für Bildung und Forschung die HPI Schul-Cloud. Sie soll die technische Grundlage schaffen, dass Lehrkräfte und Schüler in jedem Unterrichtsfach auch moderne digitale Lehr- und Lerninhalte nutzen können, und zwar so, wie Apps über Smartphones oder Tablets nutzbar sind.", "signIn_signInScreen_moreInformation": "Scrolle herunter für mehr Informationen", - "signIn_signInScreen_placeholder": "Hier könnten noch weitere Informationen stehen." + "signIn_signOutScreen_message": "Abmelden…" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 69269b69..999e78be 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -16,8 +16,9 @@ "app_navigation_userDataEmpty": "—", "app_notFound": "Page not found", "app_notFound_message": "404 – We couldn't find the page you were looking for:\n{uri}", - "app_signOut_content": "You will need to provide your login credentials to use the app again.", + "app_signOut_content": "You will need to provide your signin credentials to use the app again.", "app_signOut_title": "Sign out?", + "app_topLevelScreenWrapper_signInFirst": "Please sign in first and then open the link", "assignment": "Assignments", "assignment_assignment_isArchived": "Archived", "assignment_assignment_isPrivate": "Private", @@ -65,8 +66,13 @@ "course": "Courses", "course_coursesScreen_empty": "Seems like you're currently not enrolled in any courses.", "course_detailsScreen_empty": "This course doesn't contain any topics.", + "course_lessonScreen_empty": "This topic doesn't contain any content.", + "course_contentView_unsupported": "This content is not yet supported in this app.", "dashboard": "Dashboard", "file": "Files", + "file_chooseDestination_content": "For now, you can't choose a destination.\nThe destination will be the root of your personal files.", + "file_chooseDestination_upload": "Where to upload the file?", + "file_chooseDestination_upload_button": "Upload here", "file_delete_loading": "Deleting {name}…", "file_delete_success": "Deleted {name} 😊", "file_delete_failure": "Couldn't delete {name}", @@ -94,6 +100,7 @@ "file_renameDialog_inputHint": "New file name", "file_renameDialog_rename": "Rename", "file_uploadCompletedSnackBar": "Upload completed 😊", + "file_uploadFailedSnackBar": "Upload failed 😬", "file_uploadProgressSnackBarContent": "{total, plural, one{Uploading {fileName}…} other{Uploading {fileName}… ({current} / {total})}}", "general_cancel": "Cancel", "general_dismiss": "Dismiss", @@ -105,6 +112,7 @@ "general_signOut": "Sign out", "general_undo": "Undo", "general_user_unknown": "unknown", + "general_viewInBrowser": "View in browser", "news": "News", "news_articlePreview_subtitle": "published on {published} by {author}", "news_authorView": "by {author}", @@ -132,5 +140,5 @@ "signIn_form_signIn": "Sign in", "signIn_signInScreen_about": "Das Hasso-Plattner-Institut für Digital Engineering entwickelt unter der Leitung von Prof. Dr. Christoph Meinel zusammen mit MINT-EC, dem nationalen Excellence-Schulnetzwerk von über 300 Schulen bundesweit und unterstützt vom Bundesministerium für Bildung und Forschung die HPI Schul-Cloud. Sie soll die technische Grundlage schaffen, dass Lehrkräfte und Schüler in jedem Unterrichtsfach auch moderne digitale Lehr- und Lerninhalte nutzen können, und zwar so, wie Apps über Smartphones oder Tablets nutzbar sind.", "signIn_signInScreen_moreInformation": "scroll down for more information", - "signIn_signInScreen_placeholder": "There could go some other information down here." + "signIn_signOutScreen_message": "Signing out…" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e2953da8..84754409 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,17 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_native_timezone/flutter_native_timezone.dart'; +import 'package:logger/logger.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/calendar/calendar.dart'; import 'package:schulcloud/file/file.dart'; import 'package:schulcloud/sign_in/sign_in.dart'; import 'package:time_machine/time_machine.dart'; +import 'app/services/deep_linking.dart'; import 'settings/settings.dart'; const _schulCloudRed = MaterialColor(0xffb10438, { @@ -49,7 +53,7 @@ const _schulCloudYellow = MaterialColor(0xffe2661d, { const schulCloudAppConfig = AppConfig( name: 'sc', - domain: 'schul-cloud.org', + host: 'schul-cloud.org', title: 'Schul-Cloud', primaryColor: _schulCloudRed, secondaryColor: _schulCloudOrange, @@ -57,8 +61,13 @@ const schulCloudAppConfig = AppConfig( ); Future main({AppConfig appConfig = schulCloudAppConfig}) async { + Logger.level = Level.debug; + logger + ..i('Starting…') + ..d('Initializing hive…'); await initializeHive(); + logger.d('Initializing services…'); services ..registerSingletonAsync((_) async { // We need to initialize TimeMachine before launching the app, and using @@ -78,14 +87,17 @@ Future main({AppConfig appConfig = schulCloudAppConfig}) async { ..registerSingleton(SnackBarService()) ..registerSingleton(NetworkService()) ..registerSingleton(ApiNetworkService()) + ..registerSingletonAsync((_) => DeepLinkingService.create()) ..registerSingleton(CalendarBloc()) - ..registerSingletonAsync((_) => FileBloc.create()) + ..registerSingletonAsync((_) => FileService.create()) ..registerSingleton(SignInBloc()); + logger.d('Adding custom licenses to registry…'); LicenseRegistry.addLicense(() async* { yield EmptyStateLicense(); }); + logger.d('Running…'); runApp( FutureBuilder( future: services.allReady(), diff --git a/lib/main_brb.dart b/lib/main_brb.dart index cbbb0ac4..06ebefd2 100644 --- a/lib/main_brb.dart +++ b/lib/main_brb.dart @@ -41,7 +41,7 @@ const _brbBlue = MaterialColor(0xff0b2bdf, { const brbAppConfig = AppConfig( name: 'brb', - domain: 'brandenburg.schul-cloud.org', + host: 'brandenburg.schul-cloud.org', title: 'Schul-Cloud Brandenburg', primaryColor: _brbCyan, secondaryColor: _brbRed, diff --git a/lib/main_n21.dart b/lib/main_n21.dart index 8acf9e9b..2e1e9c34 100644 --- a/lib/main_n21.dart +++ b/lib/main_n21.dart @@ -17,7 +17,7 @@ const _n21Blue = MaterialColor(0xff78aae5, { const n21AppConfig = AppConfig( name: 'n21', - domain: 'niedersachsen.cloud', + host: 'niedersachsen.cloud', title: 'Niedersächsische Bildungscloud', primaryColor: _n21Blue, secondaryColor: _n21Blue, diff --git a/lib/main_open.dart b/lib/main_open.dart index 2930fdb3..e6271b3f 100644 --- a/lib/main_open.dart +++ b/lib/main_open.dart @@ -29,7 +29,7 @@ const _openRed = MaterialColor(0xffdf0b40, { const openAppConfig = AppConfig( name: 'open', - domain: 'open.schul-cloud.org', + host: 'open.schul-cloud.org', title: 'Open Schul-Cloud', primaryColor: _openBlue, secondaryColor: _openRed, diff --git a/lib/main_thr.dart b/lib/main_thr.dart index 9a25ce06..b66973bd 100644 --- a/lib/main_thr.dart +++ b/lib/main_thr.dart @@ -29,7 +29,7 @@ const _thrOrange = MaterialColor(0xfff56b00, { const thrAppConfig = AppConfig( name: 'thr', - domain: 'schulcloud-thueringen.de', + host: 'schulcloud-thueringen.de', title: 'Thüringer Schulcloud', primaryColor: _thrBlue, secondaryColor: _thrOrange, diff --git a/lib/news/news.dart b/lib/news/news.dart index 34763889..9b4152af 100644 --- a/lib/news/news.dart +++ b/lib/news/news.dart @@ -1,3 +1,4 @@ export 'data.dart'; +export 'routes.dart'; export 'widgets/dashboard_card.dart'; export 'widgets/news_screen.dart'; diff --git a/lib/news/routes.dart b/lib/news/routes.dart new file mode 100644 index 00000000..5527b457 --- /dev/null +++ b/lib/news/routes.dart @@ -0,0 +1,18 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; + +import 'data.dart'; +import 'widgets/article_screen.dart'; +import 'widgets/news_screen.dart'; + +final newsRoutes = Route( + matcher: Matcher.path('news'), + materialBuilder: (_, __) => NewsScreen(), + routes: [ + Route( + matcher: Matcher.path('{newsId}'), + materialBuilder: (_, result) => + ArticleScreen(Id
(result['newsId'])), + ), + ], +); diff --git a/lib/news/widgets/article_preview.dart b/lib/news/widgets/article_preview.dart index 3ab7cf25..6d28242e 100644 --- a/lib/news/widgets/article_preview.dart +++ b/lib/news/widgets/article_preview.dart @@ -6,7 +6,6 @@ import 'package:schulcloud/app/app.dart'; import '../data.dart'; import 'article_image.dart'; -import 'article_screen.dart'; import 'section.dart'; import 'theme.dart'; @@ -29,18 +28,14 @@ class ArticlePreview extends StatelessWidget { bool get _isPlaceholder => article == null; - void _openArticle(BuildContext context) { - context.navigator.push(MaterialPageRoute( - builder: (_) => ArticleScreen(article: article), - )); - } - @override Widget build(BuildContext context) { final theme = context.theme; return FancyCard( - onTap: _isPlaceholder ? null : () => _openArticle(context), + onTap: _isPlaceholder + ? null + : () => context.navigator.pushNamed('/news/${article.id}'), child: Provider( create: (_) => ArticleTheme(darkColor: Colors.purple, padding: 16), child: Column( diff --git a/lib/news/widgets/article_screen.dart b/lib/news/widgets/article_screen.dart index 0dccd63a..024fa33b 100644 --- a/lib/news/widgets/article_screen.dart +++ b/lib/news/widgets/article_screen.dart @@ -15,39 +15,53 @@ import 'theme.dart'; /// If a landscape image is provided, it's displayed above the headline. /// If a portrait image is provided, it's displayed below it. class ArticleScreen extends StatelessWidget { - const ArticleScreen({@required this.article}) : assert(article != null); + const ArticleScreen(this.articleId) : assert(articleId != null); - final Article article; + final Id
articleId; @override Widget build(BuildContext context) { - return Scaffold( - body: LayoutBuilder( - builder: (ctx, constraints) { - final width = constraints.maxWidth; - final margin = width < 500 ? 0 : width * 0.08; - final padding = (width * 0.06).clamp(32.0, 64.0); - - return Provider( - create: (_) => - ArticleTheme(darkColor: Colors.purple, padding: padding), - child: ListView( - padding: MediaQuery.of(context).padding + - EdgeInsets.symmetric(horizontal: margin.toDouble()) + - EdgeInsets.symmetric(vertical: 16), - children: [ - ArticleView(article: article), - ], - ), + return CachedRawBuilder
( + controller: articleId.controller, + builder: (context, update) { + if (!update.hasData) { + return Center( + child: update.hasError + ? ErrorScreen(update.error, update.stackTrace) + : CircularProgressIndicator(), ); - }, - ), + } + + final article = update.data; + return Scaffold( + body: LayoutBuilder( + builder: (ctx, constraints) { + final width = constraints.maxWidth; + final margin = width < 500 ? 0 : width * 0.08; + final padding = (width * 0.06).clamp(32.0, 64.0); + + return Provider( + create: (_) => + ArticleTheme(darkColor: Colors.purple, padding: padding), + child: ListView( + padding: MediaQuery.of(context).padding + + EdgeInsets.symmetric(horizontal: margin.toDouble()) + + EdgeInsets.symmetric(vertical: 16), + children: [ + ArticleView(article), + ], + ), + ); + }, + ), + ); + }, ); } } class ArticleView extends StatefulWidget { - const ArticleView({@required this.article}) : assert(article != null); + const ArticleView(this.article) : assert(article != null); final Article article; diff --git a/lib/news/widgets/dashboard_card.dart b/lib/news/widgets/dashboard_card.dart index f8a87efd..e211d141 100644 --- a/lib/news/widgets/dashboard_card.dart +++ b/lib/news/widgets/dashboard_card.dart @@ -6,7 +6,6 @@ import 'package:schulcloud/dashboard/dashboard.dart'; import '../data.dart'; import '../news.dart'; -import 'article_screen.dart'; class NewsDashboardCard extends StatelessWidget { static const articleCount = 3; @@ -18,8 +17,7 @@ class NewsDashboardCard extends StatelessWidget { return DashboardCard( title: s.news_dashboardCard, footerButtonText: s.news_dashboardCard_all, - onFooterButtonPressed: () => context.navigator - .push(MaterialPageRoute(builder: (context) => NewsScreen())), + onFooterButtonPressed: () => context.navigator.pushNamed('/news'), child: CachedRawBuilder>( controller: services.storage.root.news.controller, builder: (context, update) { @@ -54,9 +52,7 @@ class NewsDashboardCard extends StatelessWidget { Widget _buildArticlePreview(BuildContext context, Article article) { return InkWell( - onTap: () => context.navigator.push(MaterialPageRoute( - builder: (context) => ArticleScreen(article: article), - )), + onTap: () => context.navigator.pushNamed('/news/${article.id}'), child: Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( diff --git a/lib/settings/routes.dart b/lib/settings/routes.dart new file mode 100644 index 00000000..6691fee2 --- /dev/null +++ b/lib/settings/routes.dart @@ -0,0 +1,8 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; + +import 'widgets/settings_screen.dart'; + +final settingsRoutes = Route( + matcher: Matcher.path('settings'), + materialBuilder: (_, __) => SettingsScreen(), +); diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 824f2e65..836c724d 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -1,3 +1,4 @@ export 'licenses.dart'; +export 'routes.dart'; export 'widgets/legal_bar.dart'; export 'widgets/settings_screen.dart'; diff --git a/lib/sign_in/bloc.dart b/lib/sign_in/bloc.dart index 0886bd3b..d066c5a2 100644 --- a/lib/sign_in/bloc.dart +++ b/lib/sign_in/bloc.dart @@ -39,16 +39,12 @@ class SignInBloc { body: SignInRequest(email: email, password: password).toJson(), ); - final storage = services.get(); - await storage.email.setValue(email); - final response = SignInResponse.fromJson(json.decode(rawResponse.body)); await services.storage.setUserInfo( - email: email, userId: response.userId, token: response.accessToken, ); - logger.i('Logged in with userId ${response.userId}!'); + logger.i('Signed in with userId ${response.userId}!'); } Future signInAsDemoStudent() => diff --git a/lib/sign_in/data.dart b/lib/sign_in/data.dart index db4c5bc2..af80a632 100644 --- a/lib/sign_in/data.dart +++ b/lib/sign_in/data.dart @@ -36,3 +36,21 @@ class SignInResponse { final String accessToken; final String userId; } + +@immutable +class UserResponse { + const UserResponse({ + @required this.userId, + @required this.email, + }) : assert(userId != null), + assert(email != null); + + UserResponse.fromJson(Map data) + : this( + userId: data['_id'], + email: data['email'], + ); + + final String userId; + final String email; +} diff --git a/lib/sign_in/routes.dart b/lib/sign_in/routes.dart new file mode 100644 index 00000000..ff5bb1ec --- /dev/null +++ b/lib/sign_in/routes.dart @@ -0,0 +1,24 @@ +import 'package:flutter_deep_linking/flutter_deep_linking.dart'; +import 'package:schulcloud/app/app.dart'; + +import 'widgets/sign_in_screen.dart'; +import 'widgets/sign_out_screen.dart'; + +final signInRoutes = Route( + routes: [ + Route( + matcher: Matcher.path('login'), + builder: (result) => TopLevelPageRoute( + builder: (_) => SignInScreen(), + settings: result.settings, + ), + ), + Route( + matcher: Matcher.path('logout'), + builder: (result) => TopLevelPageRoute( + builder: (_) => SignOutScreen(), + settings: result.settings, + ), + ), + ], +); diff --git a/lib/sign_in/sign_in.dart b/lib/sign_in/sign_in.dart index 57b39cec..1e1f0eaf 100644 --- a/lib/sign_in/sign_in.dart +++ b/lib/sign_in/sign_in.dart @@ -1,3 +1,4 @@ export 'bloc.dart'; +export 'routes.dart'; export 'utils.dart'; export 'widgets/sign_in_screen.dart'; diff --git a/lib/sign_in/utils.dart b/lib/sign_in/utils.dart index 073897a0..755faa7b 100644 --- a/lib/sign_in/utils.dart +++ b/lib/sign_in/utils.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; import 'package:schulcloud/app/app.dart'; -import 'widgets/sign_in_screen.dart'; - Future signOut(BuildContext context) async { logger.i('Signing out…'); + if (services.storage.isSignedOut) { + logger.i('Already signed out'); + return true; + } final s = context.s; final confirmed = await showDialog( @@ -29,19 +31,10 @@ Future signOut(BuildContext context) async { }, ); - if (confirmed) { - // Actually log out. - - // This should probably be awaited, but right now awaiting it - // leads to the issue that logging out becomes impossible. - unawaited(services.storage.clear()); - - final navigator = context.rootNavigator..popUntil((route) => route.isFirst); - unawaited(navigator.pushReplacement(TopLevelPageRoute( - builder: (_) => SignInScreen(), - ))); + if (confirmed == true) { + // There may be multiple routes in the back stack, e.g. when signing out + // from inside the [AccountDialog]. + unawaited(context.rootNavigator.pushNamedAndRemoveAll('/logout')); } - - logger.i('Signed out!'); - return confirmed; + return confirmed ?? false; } diff --git a/lib/sign_in/widgets/form.dart b/lib/sign_in/widgets/form.dart index 9523d58f..3d63904f 100644 --- a/lib/sign_in/widgets/form.dart +++ b/lib/sign_in/widgets/form.dart @@ -1,12 +1,12 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:pedantic/pedantic.dart'; import 'package:schulcloud/app/app.dart'; +import 'package:schulcloud/app/routing.dart'; import '../bloc.dart'; -import 'input.dart'; -import 'morphing_loading_button.dart'; +import 'sign_in_browser.dart'; class SignInForm extends StatefulWidget { @override @@ -14,123 +14,102 @@ class SignInForm extends StatefulWidget { } class _SignInFormState extends State { - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - bool _isEmailValid = true; - bool _isPasswordValid = true; + SignInBrowser browser; + bool get _isSigningIn => + _isSigningInViaWeb || + _isSigningInAsDemoStudent || + _isSigningInAsDemoTeacher; + bool _isSigningInViaWeb = false; + bool _isSigningInAsDemoStudent = false; + bool _isSigningInAsDemoTeacher = false; - bool _isLoading = false; - String _ambientError; - - Future _executeSignIn(Future Function() signIn) async { - setState(() => _isLoading = true); - - try { - await signIn(); - setState(() => _ambientError = null); - - // Logged in. - unawaited(context.navigator.pushReplacement(TopLevelPageRoute( - builder: (_) => SignedInScreen(), - ))); - } on InvalidSignInSyntaxError catch (e) { - // We will display syntax errors on the text fields themselves. - _ambientError = null; - _isEmailValid = e.isEmailValid; - _isPasswordValid = e.isPasswordValid; - } on NoConnectionToServerError { - _ambientError = context.s.signIn_form_errorNoConnection; - } on AuthenticationError { - _ambientError = context.s.signIn_form_errorAuth; - } on TooManyRequestsError catch (error) { - _ambientError = context.s.signIn_form_errorRateLimit(error.timeToWait); - } finally { - setState(() => _isLoading = false); - } - } - - Future _signIn() async { - await _executeSignIn( - () => services - .get() - .signIn(_emailController.text, _passwordController.text), - ); + @override + void initState() { + browser = SignInBrowser(signedInCallback: _pushSignedInPage); + super.initState(); } - Future _signInAsDemoStudent() => - _executeSignIn(() => services.get().signInAsDemoStudent()); - - Future _signInAsDemoTeacher() => - _executeSignIn(() => services.get().signInAsDemoTeacher()); - @override Widget build(BuildContext context) { - final s = context.s; - return Container( alignment: Alignment.center, - padding: EdgeInsets.symmetric(horizontal: 16), - width: 400, - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 32), - child: SvgPicture.asset( - services - .get() - .assetName(context, 'logo/logo_with_text.svg'), - height: 64, + padding: EdgeInsets.symmetric(horizontal: 32), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 384), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + services.config.assetName(context, 'logo/logo_with_text.svg'), + height: 128, alignment: Alignment.bottomCenter, ), - ), - SizedBox(height: 16), - SignInInput( - controller: _emailController, - label: s.signIn_form_email, - error: _isEmailValid ? null : s.signIn_form_email_error, - onChanged: () => setState(() {}), - ), - SizedBox(height: 16), - SignInInput( - controller: _passwordController, - label: s.signIn_form_password, - obscureText: true, - error: _isPasswordValid ? null : s.signIn_form_password_error, - onChanged: () => setState(() {}), - ), - SizedBox(height: 16), - MorphingLoadingButton( - onPressed: _signIn, - isLoading: _isLoading, - child: Padding( - padding: EdgeInsets.all(12), - child: Text( - _isLoading ? s.general_loading : s.signIn_form_signIn, - style: TextStyle(fontSize: 20), + SizedBox(height: 64), + SizedBox( + height: 48, + child: PrimaryButton( + isEnabled: !_isSigningIn, + isLoading: _isSigningInViaWeb, + onPressed: () async { + setState(() => _isSigningInViaWeb = true); + await browser.open( + url: services.config.webUrl('login'), + options: InAppBrowserClassOptions( + inAppBrowserOptions: + InAppBrowserOptions(toolbarTop: false), + ), + ); + setState(() => _isSigningInViaWeb = false); + }, + child: Text(context.s.signIn_form_signIn), ), ), + SizedBox(height: 12), + _buildDemoButtons(context), + ], + ), + ), + ); + } + + Widget _buildDemoButtons(BuildContext context) { + final s = context.s; + + return Row( + children: [ + Expanded( + child: SecondaryButton( + isEnabled: !_isSigningIn, + isLoading: _isSigningInAsDemoStudent, + onPressed: () async { + setState(() => _isSigningInAsDemoStudent = true); + await services.get().signInAsDemoStudent(); + _pushSignedInPage(); + setState(() => _isSigningInAsDemoStudent = false); + }, + child: Text(s.signIn_form_demo_student), ), - SizedBox(height: 8), - if (_ambientError != null) Text(_ambientError), - Divider(), - SizedBox(height: 8), - Text(s.signIn_form_demo), - SizedBox(height: 8), - Wrap( - children: [ - SecondaryButton( - onPressed: _signInAsDemoStudent, - child: Text(s.signIn_form_demo_student), - ), - SizedBox(width: 8), - SecondaryButton( - onPressed: _signInAsDemoTeacher, - child: Text(s.signIn_form_demo_teacher), - ), - ], + ), + SizedBox(width: 16), + Expanded( + child: SecondaryButton( + key: ValueKey('signIn-demoTeacher'), + isEnabled: !_isSigningIn, + isLoading: _isSigningInAsDemoTeacher, + onPressed: () async { + setState(() => _isSigningInAsDemoTeacher = true); + await services.get().signInAsDemoTeacher(); + _pushSignedInPage(); + setState(() => _isSigningInAsDemoTeacher = false); + }, + child: Text(s.signIn_form_demo_teacher), ), - ], - ), + ), + ], ); } + + void _pushSignedInPage() => context.rootNavigator + .pushReplacementNamed(appSchemeLink('signedInScreen')); } diff --git a/lib/sign_in/widgets/input.dart b/lib/sign_in/widgets/input.dart deleted file mode 100644 index 10bd0fb7..00000000 --- a/lib/sign_in/widgets/input.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; - -class SignInInput extends StatelessWidget { - const SignInInput({ - @required this.controller, - @required this.label, - this.error, - this.obscureText = false, - this.onChanged, - }) : assert(controller != null), - assert(label != null); - - final TextEditingController controller; - final String label; - final String error; - final bool obscureText; - final VoidCallback onChanged; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - obscureText: obscureText, - onChanged: (_) => onChanged(), - decoration: InputDecoration( - border: OutlineInputBorder(), - labelText: label, - errorText: error, - ), - ); - } -} diff --git a/lib/sign_in/widgets/morphing_loading_button.dart b/lib/sign_in/widgets/morphing_loading_button.dart deleted file mode 100644 index a6aecd08..00000000 --- a/lib/sign_in/widgets/morphing_loading_button.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/material.dart'; - -/// A button that can morph into a loading spinner. -/// -/// This button can be given a child to display. The passed [onPressed] callback -/// is called if the button is pressed. -/// If [isLoading] is true, it displays a loading spinner instead. -class MorphingLoadingButton extends StatefulWidget { - const MorphingLoadingButton({ - @required this.child, - @required this.onPressed, - this.isLoading = false, - }) : assert(child != null), - assert(onPressed != null), - assert(isLoading != null); - - final Widget child; - final VoidCallback onPressed; - final bool isLoading; - - @override - _MorphingLoadingButtonState createState() => _MorphingLoadingButtonState(); -} - -class _MorphingLoadingButtonState extends State { - bool get _isLoading => widget.isLoading; - - @override - Widget build(BuildContext context) { - final theme = context.theme; - - return RawMaterialButton( - // Do not handle touch events if the button is already loading. - onPressed: _isLoading ? () {} : widget.onPressed, - fillColor: theme.primaryColor, - highlightColor: Colors.black.withOpacity(0.08), - splashColor: _isLoading ? Colors.transparent : Colors.black26, - elevation: 0, - highlightElevation: 2, - shape: _isLoading - ? CircleBorder() - : RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - animationDuration: Duration(milliseconds: 200), - child: Container( - width: _isLoading ? 52 : null, - height: _isLoading ? 52 : null, - child: DefaultTextStyle( - style: context.textTheme.button - .copyWith(color: theme.primaryColor.highEmphasisOnColor), - child: _isLoading ? _buildLoadingContent(theme) : widget.child, - ), - ), - ); - } - - Widget _buildLoadingContent(ThemeData theme) { - return Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ); - } -} diff --git a/lib/sign_in/widgets/sign_in_browser.dart b/lib/sign_in/widgets/sign_in_browser.dart new file mode 100644 index 00000000..98a61366 --- /dev/null +++ b/lib/sign_in/widgets/sign_in_browser.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:meta/meta.dart'; + +import 'package:schulcloud/app/app.dart'; + +class SignInBrowser extends InAppBrowser { + SignInBrowser({@required this.signedInCallback}) + : assert(signedInCallback != null); + + VoidCallback signedInCallback; + + @override + Future onLoadStart(String url) async { + final firstPathSegment = Uri.parse(url).pathSegments.first; + if (firstPathSegment == 'dashboard') { + logger.i('Signing in…'); + + final jwt = await CookieManager().getCookie(url: url, name: 'jwt'); + final userIdJson = json + .decode(String.fromCharCodes(base64Decode(jwt.value.split('.')[1]))); + await services.storage.setUserInfo( + userId: userIdJson['userId'], + token: jwt.value, + ); + logger.i('Signed in with userId ${userIdJson['userId']}'); + + signedInCallback(); + await close(); + } + } +} diff --git a/lib/sign_in/widgets/sign_in_screen.dart b/lib/sign_in/widgets/sign_in_screen.dart index 99e57197..c3aeaf8e 100644 --- a/lib/sign_in/widgets/sign_in_screen.dart +++ b/lib/sign_in/widgets/sign_in_screen.dart @@ -1,6 +1,5 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; -import 'package:logger_flutter/logger_flutter.dart'; import 'package:schulcloud/app/app.dart'; import 'package:schulcloud/settings/settings.dart'; @@ -10,15 +9,13 @@ import 'slanted_section.dart'; class SignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return LogConsoleOnShake( - child: Scaffold( - body: CustomScrollView( - slivers: [ - SliverList( - delegate: SliverChildListDelegate(_buildContent(context)), - ), - ], - ), + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildListDelegate(_buildContent(context)), + ), + ], ), ); } @@ -60,11 +57,6 @@ class SignInScreen extends StatelessWidget { ), ), ), - Container( - padding: EdgeInsets.all(16), - alignment: Alignment.center, - child: Text(s.signIn_signInScreen_placeholder), - ), LegalBar(), ]; } diff --git a/lib/sign_in/widgets/sign_out_screen.dart b/lib/sign_in/widgets/sign_out_screen.dart new file mode 100644 index 00000000..d290e824 --- /dev/null +++ b/lib/sign_in/widgets/sign_out_screen.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:black_hole_flutter/black_hole_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:schulcloud/app/app.dart'; + +class SignOutScreen extends StatefulWidget { + @override + _SignOutScreenState createState() => _SignOutScreenState(); +} + +class _SignOutScreenState extends State { + @override + void initState() { + super.initState(); + + scheduleMicrotask(() async { + try { + await services.api.delete('authentication'); + } on AuthenticationError { + // Authentication has already expired. + } + + await CookieManager().deleteAllCookies(); + // This should probably be awaited, but right now awaiting it + // leads to the issue that logging out becomes impossible. + unawaited(services.storage.clear()); + + unawaited(context.rootNavigator.pushReplacementNamed('/login')); + logger.i('Signed out!'); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center(child: CircularProgressIndicator()), + SizedBox(height: 8), + Text( + context.s.signIn_signOutScreen_message, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4a0bdb55..6a382edf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -15,13 +15,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.39.4" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "1.5.3" + version: "1.5.2" async: dependency: transitive description: @@ -35,7 +42,14 @@ packages: name: black_hole_flutter url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.2.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" build: dependency: transitive description: @@ -70,14 +84,14 @@ packages: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "1.7.4" + version: "1.8.0" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "4.5.3" built_collection: dependency: transitive description: @@ -105,7 +119,7 @@ packages: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.1.3" + version: "1.1.2" checked_yaml: dependency: transitive description: @@ -113,6 +127,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + chewie: + dependency: transitive + description: + name: chewie + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.10" + chewie_audio: + dependency: transitive + description: + name: chewie_audio + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+1" code_builder: dependency: transitive description: @@ -134,13 +162,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.9" crypto: dependency: transitive description: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.3" + css_colors: + dependency: transitive + description: + name: css_colors + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" csslib: dependency: transitive description: @@ -183,13 +225,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" file_picker: dependency: "direct main" description: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "1.4.3+2" + version: "1.5.0+2" fixnum: dependency: transitive description: @@ -216,6 +265,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_absolute_path: + dependency: "direct main" + description: + name: flutter_absolute_path + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" flutter_cached: dependency: "direct main" description: @@ -223,6 +279,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.7" + flutter_deep_linking: + dependency: "direct main" + description: + name: flutter_deep_linking + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" flutter_downloader: dependency: "direct main" description: @@ -230,13 +293,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.9" + flutter_driver: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" flutter_html: dependency: "direct main" description: - name: flutter_html + path: "." + ref: b5046d9 + resolved-ref: b5046d9dbed099b89ccb5b022dddf0c3f05063a5 + url: "git://github.com/Sub6Resources/flutter_html" + source: git + version: "1.0.0-pre.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview url: "https://pub.dartlang.org" source: hosted - version: "0.10.4" + version: "2.1.0+1" flutter_localizations: dependency: "direct main" description: flutter @@ -249,6 +326,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" flutter_sequence_animation: dependency: transitive description: @@ -263,6 +347,11 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.14.4" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_villains: dependency: "direct main" description: @@ -275,6 +364,18 @@ packages: description: flutter source: sdk version: "0.0.0" + font_awesome_flutter: + dependency: "direct main" + description: + name: font_awesome_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "8.7.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get_it: dependency: "direct main" description: @@ -309,7 +410,7 @@ packages: name: hive url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.4.1+1" hive_cache: dependency: "direct main" description: @@ -360,7 +461,14 @@ packages: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.1.4" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" intl: dependency: "direct main" description: @@ -381,7 +489,7 @@ packages: name: intl_utils url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" io: dependency: transitive description: @@ -403,6 +511,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" list_diff: dependency: "direct main" description: @@ -452,6 +567,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.6+3" + multi_server_socket: + dependency: transitive + description: + name: multi_server_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" nested: dependency: transitive description: @@ -473,6 +595,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1+2" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.8" open_file: dependency: "direct main" description: @@ -480,20 +609,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + open_iconic_flutter: + dependency: transitive + description: + name: open_iconic_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" package_config: dependency: transitive description: name: package_config url: "https://pub.dartlang.org" source: hosted - version: "1.9.1" + version: "1.9.2" package_info: dependency: "direct main" description: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "0.4.0+14" + version: "0.4.0+16" package_resolver: dependency: transitive description: @@ -528,14 +664,28 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "1.6.1" + version: "1.6.5" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" pedantic: dependency: "direct main" description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.9.0" + version: "1.8.0+1" permission_handler: dependency: "direct main" description: @@ -564,20 +714,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" - pointycastle: + pool: dependency: transitive description: - name: pointycastle + name: pool url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" - pool: + version: "1.4.0" + process: dependency: transitive description: - name: pool + name: process url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "3.0.12" provider: dependency: "direct main" description: @@ -585,13 +735,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.4" + pub_cache: + dependency: transitive + description: + name: pub_cache + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "1.4.3" + version: "1.4.2" pubspec_parse: dependency: transitive description: @@ -627,20 +784,43 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.22.6" + screen: + dependency: transitive + description: + name: screen + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.5" + screenshots: + dependency: "direct dev" + description: + name: screenshots + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" sensors: dependency: transitive description: name: sensors url: "https://pub.dartlang.org" source: hosted - version: "0.4.1+8" + version: "0.4.1+10" + share: + dependency: "direct main" + description: + path: "." + ref: "1f8b139" + resolved-ref: "1f8b139ca0bd35b643ef4f5ccce3a1b09931f16a" + url: "https://github.com/d-silveira/flutter-share.git" + source: git + version: "0.6.0" shared_preferences: dependency: transitive description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "0.5.6+2" + version: "0.5.6+3" shared_preferences_macos: dependency: transitive description: @@ -669,6 +849,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.7.5" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.8" shelf_web_socket: dependency: transitive description: @@ -688,13 +882,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.5" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.5" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.9" source_span: dependency: transitive description: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "1.5.5" sprintf: dependency: "direct main" description: @@ -744,6 +952,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + test: + dependency: "direct dev" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.4" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.11" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.15" time: dependency: transitive description: @@ -765,6 +994,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1+2" + tool_base: + dependency: transitive + description: + name: tool_base + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.5+3" + tool_mobile: + dependency: transitive + description: + name: tool_mobile + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.5+1" transparent_image: dependency: "direct main" description: @@ -779,6 +1022,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.6" + uni_links: + dependency: "direct main" + description: + name: uni_links + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" url_launcher: dependency: "direct main" description: @@ -807,6 +1057,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1+1" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" vector_math: dependency: transitive description: @@ -814,13 +1071,55 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + video_player: + dependency: transitive + description: + name: video_player + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.8+1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + video_player_web: + dependency: transitive + description: + name: video_player_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2+1" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + vm_service_client: + dependency: transitive + description: + name: vm_service_client + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.6+2" + wakelock: + dependency: transitive + description: + name: wakelock + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4+1" watcher: dependency: transitive description: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "0.9.7+13" + version: "0.9.7+14" web_socket_channel: dependency: transitive description: @@ -829,21 +1128,19 @@ packages: source: hosted version: "1.1.0" webview_flutter: - dependency: "direct main" + dependency: transitive description: - path: "packages/webview_flutter" - ref: "feature/webview_transparency" - resolved-ref: dfb6d637297c2a716afcd723fb901abf52db57a3 - url: "git://github.com/jaumard/plugins" - source: git - version: "0.3.5+2" + name: webview_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.19+9" xml: dependency: transitive description: name: xml url: "https://pub.dartlang.org" source: hosted - version: "3.6.1" + version: "3.5.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 13431b73..137efbd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,14 @@ name: schulcloud description: A Flutter–based mobile app for the HPI Schul–Cloud. -version: 1.0.0+1 +# Version Code: MM mm pp P nn (max: 2 100 000 000) +# MM: Major version (0 – 21) +# mm: Minor version (0 – 99) +# pp: Patch version (0 – 99) +# P: Preview (2: canary, 4: alpha, 5: beta, 8: RC) +# nn: Preview version (0 – 999) +# +# e.g. 1.23.4-beta.12 ≙ 01 23 04 5 012 +version: 0.3.5 homepage: https://github.com/schul-cloud/schulcloud-flutter environment: @@ -13,19 +21,26 @@ dependencies: sdk: flutter analyzer: ^0.39.4 - black_hole_flutter: ^0.1.0 + black_hole_flutter: ^0.2.0 cupertino_icons: ^0.1.2 dartx: ^0.2.0 datetime_picker_formfield: ^1.0.0 file_picker: ^1.4.3+2 flare_flutter: ^1.5.8 + flutter_absolute_path: ^1.0.6 flutter_cached: ^4.2.4 + flutter_deep_linking: ^0.1.0 # The current version of flutter_downloader, 1.2.1, causes the build to fail. Until this is fixed, we rely on an older version. flutter_downloader: 1.1.9 - flutter_html: ^0.10.4 + flutter_html: + git: + url: git://github.com/Sub6Resources/flutter_html + ref: b5046d9 + flutter_inappwebview: ^2.1.0+1 flutter_native_timezone: ^1.0.4 flutter_svg: ^0.14.2 flutter_villains: ^1.2.0 + font_awesome_flutter: ^8.7.0 get_it: 4.0.0-beta3 grec_minimal: ^0.0.3 hive: ^1.2.0 @@ -46,29 +61,31 @@ dependencies: permission_handler: ^3.3.0 provider: ^4.0.0 rxdart: ^0.22.3 + share: + git: + url: https://github.com/d-silveira/flutter-share.git + ref: 1f8b139 sprintf: ^4.0.2 streaming_shared_preferences: ^1.0.1 time_machine: ^0.9.12 transparent_image: ^1.0.0 + uni_links: ^0.2.0 url_launcher: ^5.1.2 - # Using a fork until the main plugin supports transparent backgrounds: - # https://github.com/flutter/flutter/issues/29300 - # webview_flutter: ^0.3.10+4 - webview_flutter: - git: - url: git://github.com/jaumard/plugins - ref: feature/webview_transparency - path: packages/webview_flutter dev_dependencies: build_resolvers: ^1.1.1 build_runner: ^1.0.0 + flutter_driver: + sdk: flutter hive_generator: git: url: git://github.com/hivedb/hive ref: f8bdb804da56d0863e03316842317b27f47d2bdf path: hive_generator intl_utils: ^1.0.2 + screenshots: ^2.1.1 + # We need to use a version compatible with test_api 0.2.11 required by flutter_driver. + test: 1.9.4 flutter: uses-material-design: true @@ -79,6 +96,7 @@ flutter: - assets/empty_states/files.flr - assets/icon_signOut.svg - assets/file_thumbnails/ + - assets/sloth_error.svg - assets/theme/sc/logo/logo_with_text.svg - assets/theme/brb/logo/logo_with_text.svg - assets/theme/n21/logo/logo_with_text.svg diff --git a/screenshots.yaml b/screenshots.yaml new file mode 100644 index 00000000..aeaebfa2 --- /dev/null +++ b/screenshots.yaml @@ -0,0 +1,16 @@ +tests: + - test_driver/screenshots.dart + +staging: /tmp/screenshots +recording: /tmp/screenshots_record + +locales: + - en + - de + +devices: + android: + Pixel XL: + orientation: Portrait + +frame: false diff --git a/test_driver/screenshots.dart b/test_driver/screenshots.dart new file mode 100644 index 00000000..f85dac20 --- /dev/null +++ b/test_driver/screenshots.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:schulcloud/main.dart' as app; + +void main() { + enableFlutterDriverExtension(); + WidgetsApp.debugAllowBannerOverride = false; + app.main(); +} diff --git a/test_driver/screenshots_test.dart b/test_driver/screenshots_test.dart new file mode 100644 index 00000000..1df3702a --- /dev/null +++ b/test_driver/screenshots_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:screenshots/screenshots.dart'; +import 'package:test/test.dart'; + +void main() { + group('Screenshot', () { + // Note: We use the same app instance for all "tests". + + final config = Config(); + + FlutterDriver driver; + setUpAll(() async { + driver = await FlutterDriver.connect(); + + // TODO(JonasWanke): remove when flutter driver supports async main functions, https://github.com/flutter/flutter/issues/41029 + await Future.delayed(Duration(seconds: 10)); + }); + tearDownAll(() async { + await driver.close(); + }); + + test('SignInScreen', () async { + await screenshot(driver, config, 'signIn'); + }); + + test('DashboardScreen', () async { + await driver.tap(find.byValueKey('signIn-demoTeacher')); + await screenshot(driver, config, 'dashboard'); + }); + + group('course', () { + test('CoursesScreen', () async { + await driver.tap(find.byValueKey('navigation-course')); + await screenshot(driver, config, 'courses'); + }); + test('CourseDetailScreen', () async { + await driver.tap(find.text('🦠 Biologie 10b')); + await screenshot(driver, config, 'course'); + }); + }); + + group('assignment', () { + test('AssignmentsScreen', () async { + await driver.tap(find.byValueKey('navigation-assignment')); + await driver.waitUntilNoTransientCallbacks(); + await screenshot(driver, config, 'assignments'); + }); + // TODO(JonasWanke): Add screenshot when better filtering is supported or we mock network calls, https://github.com/flutter/flutter/issues/12810 + // test('AssignmentDetailsScreen', () async { + // await driver.tap(find.text('Würfelspiel - Gruppenarbeit')); + // await screenshot(driver, config, 'assignment'); + // }); + }); + + group('file', () { + test('FilesScreen', () async { + await driver.tap(find.byValueKey('navigation-file')); + await screenshot(driver, config, 'file'); + }); + }); + + group('news', () { + test('NewsScreen', () async { + await driver.tap(find.byValueKey('navigation-news')); + await screenshot(driver, config, 'news'); + }); + }); + }); +} From 0564133c63f31e4d8d9f2d8dfa62435c477ba882 Mon Sep 17 00:00:00 2001 From: Marcel Garus Date: Wed, 25 Mar 2020 17:57:20 +0100 Subject: [PATCH 11/11] fix errors --- lib/file/service.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/file/service.dart b/lib/file/service.dart index c84b2e94..ba0bd636 100644 --- a/lib/file/service.dart +++ b/lib/file/service.dart @@ -77,17 +77,18 @@ class FileService { savedDir: actualFile.dirName, fileName: actualFile.name, showNotification: true, - openFileFromNotification: true, + openFileFromNotification: false, ); // TODO(marcelgarus): Do this when the file downloaded successfully: - final localFile = LocalFile( - fileId: file.id, - downloadedAt: Instant.now(), - actualFile: actualFile, - ); - await localFiles.put(file.id.value, localFile); - return localFile; + // final localFile = LocalFile( + // fileId: file.id, + // downloadedAt: Instant.now(), + // actualFile: actualFile, + // ); + // await localFiles.put(file.id.value, localFile); + // return localFile; + return null; } Future ensureStoragePermissionGranted() async {