commit 6c69f978f3e00822062bf9292bd3ab4e61df73be Author: jld3103 Date: Wed Jul 6 15:01:54 2022 +0200 Initial commit diff --git a/.fvm/.gitignore b/.fvm/.gitignore new file mode 100644 index 00000000..3a0b12c0 --- /dev/null +++ b/.fvm/.gitignore @@ -0,0 +1 @@ +/flutter_sdk diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json new file mode 100644 index 00000000..cd329468 --- /dev/null +++ b/.fvm/fvm_config.json @@ -0,0 +1,4 @@ +{ + "flutterSdkVersion": "3.0.4@stable", + "flavors": {} +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dbe95075 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.g.dart -diff diff --git a/.github/workflows/.gitattributes b/.github/workflows/.gitattributes new file mode 100644 index 00000000..16c890c6 --- /dev/null +++ b/.github/workflows/.gitattributes @@ -0,0 +1 @@ +dart.yml -diff diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 00000000..08b11ef1 --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,273 @@ +# Created with package:mono_repo v6.3.0 +name: Dart CI +on: + push: + branches: + - main + pull_request: +defaults: + run: + shell: bash +env: + PUB_ENVIRONMENT: bot.github +permissions: read-all + +jobs: + job_001: + name: mono_repo self validate + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d + with: + sdk: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - name: mono_repo self validate + run: dart pub global activate mono_repo 6.3.0 + - name: mono_repo self validate + run: dart pub global run mono_repo generate --validate + job_002: + name: "analyze; PKGS: packages/file_icons, packages/harbour, packages/nextcloud, packages/settings, packages/sort_box, packages/spec_templates; `dart format --output=none --set-exit-if-changed --line-length 120 .`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/file_icons-packages/harbour-packages/nextcloud-packages/settings-packages/sort_box-packages/spec_templates;commands:format" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/file_icons-packages/harbour-packages/nextcloud-packages/settings-packages/sort_box-packages/spec_templates + os:ubuntu-latest;pub-cache-hosted;sdk:stable + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: subosito/flutter-action@2fb73e25c9488eb544b9b14b2ce00c4c2baf789e + with: + channel: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - id: packages_file_icons_pub_upgrade + name: packages/file_icons; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/file_icons + run: flutter pub pub upgrade + - name: "packages/file_icons; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_file_icons_pub_upgrade.conclusion == 'success'" + working-directory: packages/file_icons + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + - id: packages_harbour_pub_upgrade + name: packages/harbour; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/harbour + run: flutter pub pub upgrade + - name: "packages/harbour; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_harbour_pub_upgrade.conclusion == 'success'" + working-directory: packages/harbour + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + - id: packages_nextcloud_pub_upgrade + name: packages/nextcloud; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/nextcloud + run: flutter pub pub upgrade + - name: "packages/nextcloud; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_nextcloud_pub_upgrade.conclusion == 'success'" + working-directory: packages/nextcloud + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + - id: packages_settings_pub_upgrade + name: packages/settings; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/settings + run: flutter pub pub upgrade + - name: "packages/settings; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_settings_pub_upgrade.conclusion == 'success'" + working-directory: packages/settings + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + - id: packages_sort_box_pub_upgrade + name: packages/sort_box; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/sort_box + run: flutter pub pub upgrade + - name: "packages/sort_box; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_sort_box_pub_upgrade.conclusion == 'success'" + working-directory: packages/sort_box + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + - id: packages_spec_templates_pub_upgrade + name: packages/spec_templates; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/spec_templates + run: flutter pub pub upgrade + - name: "packages/spec_templates; dart format --output=none --set-exit-if-changed --line-length 120 ." + if: "always() && steps.packages_spec_templates_pub_upgrade.conclusion == 'success'" + working-directory: packages/spec_templates + run: "dart format --output=none --set-exit-if-changed --line-length 120 ." + needs: + - job_001 + job_003: + name: "analyze; PKGS: packages/file_icons, packages/harbour, packages/settings; `flutter analyze`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/file_icons-packages/harbour-packages/settings;commands:analyze_0" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/file_icons-packages/harbour-packages/settings + os:ubuntu-latest;pub-cache-hosted;sdk:stable + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: subosito/flutter-action@2fb73e25c9488eb544b9b14b2ce00c4c2baf789e + with: + channel: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - id: packages_file_icons_pub_upgrade + name: packages/file_icons; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/file_icons + run: flutter pub pub upgrade + - name: packages/file_icons; flutter analyze + if: "always() && steps.packages_file_icons_pub_upgrade.conclusion == 'success'" + working-directory: packages/file_icons + run: flutter analyze + - id: packages_harbour_pub_upgrade + name: packages/harbour; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/harbour + run: flutter pub pub upgrade + - name: packages/harbour; flutter analyze + if: "always() && steps.packages_harbour_pub_upgrade.conclusion == 'success'" + working-directory: packages/harbour + run: flutter analyze + - id: packages_settings_pub_upgrade + name: packages/settings; flutter pub pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/settings + run: flutter pub pub upgrade + - name: packages/settings; flutter analyze + if: "always() && steps.packages_settings_pub_upgrade.conclusion == 'success'" + working-directory: packages/settings + run: flutter analyze + needs: + - job_001 + job_004: + name: "analyze; PKGS: packages/nextcloud, packages/sort_box, packages/spec_templates; `dart analyze`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud-packages/sort_box-packages/spec_templates;commands:analyze_1" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud-packages/sort_box-packages/spec_templates + os:ubuntu-latest;pub-cache-hosted;sdk:stable + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d + with: + sdk: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - id: packages_nextcloud_pub_upgrade + name: packages/nextcloud; dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/nextcloud + run: dart pub upgrade + - name: packages/nextcloud; dart analyze + if: "always() && steps.packages_nextcloud_pub_upgrade.conclusion == 'success'" + working-directory: packages/nextcloud + run: dart analyze + - id: packages_sort_box_pub_upgrade + name: packages/sort_box; dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/sort_box + run: dart pub upgrade + - name: packages/sort_box; dart analyze + if: "always() && steps.packages_sort_box_pub_upgrade.conclusion == 'success'" + working-directory: packages/sort_box + run: dart analyze + - id: packages_spec_templates_pub_upgrade + name: packages/spec_templates; dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/spec_templates + run: dart pub upgrade + - name: packages/spec_templates; dart analyze + if: "always() && steps.packages_spec_templates_pub_upgrade.conclusion == 'success'" + working-directory: packages/spec_templates + run: dart analyze + needs: + - job_001 + job_005: + name: "unit_test; PKG: packages/nextcloud; `dart test`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud;commands:test" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud + os:ubuntu-latest;pub-cache-hosted;sdk:stable + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d + with: + sdk: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - id: packages_nextcloud_pub_upgrade + name: packages/nextcloud; dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/nextcloud + run: dart pub upgrade + - name: packages/nextcloud; dart test + if: "always() && steps.packages_nextcloud_pub_upgrade.conclusion == 'success'" + working-directory: packages/nextcloud + run: dart test + needs: + - job_001 + - job_002 + - job_003 + - job_004 + job_006: + name: "unit_test; PKG: packages/sort_box; `dart test`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@4504faf7e9bcf8f3ed0bc863c4e1d21499ab8ef8 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/sort_box;commands:test" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/sort_box + os:ubuntu-latest;pub-cache-hosted;sdk:stable + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d + with: + sdk: stable + - id: checkout + uses: actions/checkout@d0651293c4a5a52e711f25b41b05b2212f385d28 + - id: packages_sort_box_pub_upgrade + name: packages/sort_box; dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: packages/sort_box + run: dart pub upgrade + - name: packages/sort_box; dart test + if: "always() && steps.packages_sort_box_pub_upgrade.conclusion == 'success'" + working-directory: packages/sort_box + run: dart test + needs: + - job_001 + - job_002 + - job_003 + - job_004 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..787dc672 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "external/nextcloud-server"] + path = external/nextcloud-server + url = https://github.com/nextcloud/server +[submodule "external/nextcloud-news"] + path = external/nextcloud-news + url = https://github.com/nextcloud/news +[submodule "external/openapi-generator"] + path = external/openapi-generator + url = https://github.com/OpenAPITools/openapi-generator +[submodule "external/seti-ui"] + path = external/seti-ui + url = https://github.com/jesseweed/seti-ui +[submodule "external/nextcloud-notes"] + path = external/nextcloud-notes + url = https://github.com/nextcloud/notes diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..59053622 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +/libraries +*.iml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..9ea02b1c --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..f0c57faa --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..5c1b9a43 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 00000000..7e5d55ab --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Debug.xml b/.idea/runConfigurations/Debug.xml new file mode 100644 index 00000000..f9dc914b --- /dev/null +++ b/.idea/runConfigurations/Debug.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Release.xml b/.idea/runConfigurations/Release.xml new file mode 100644 index 00000000..9f4cacbc --- /dev/null +++ b/.idea/runConfigurations/Release.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..611fc781 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..195a2697 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2022, jld3103 +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..36858a48 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# nextcloud-harbour + +A beautiful convergent cross-platform client for Nextcloud written in Flutter. + +See [here](./packages/harbour/README.md) for screenshots and other material regarding the app. + +This repository not only contains the Harbour app, but also a Nextcloud client written in Dart. +The client will replace https://github.com/jld3103/dart-nextcloud which is an older unmaintained client I wrote some time ago. + +The development of this app and client just started, there will be a lot of changes and new features coming soon. + +Additional documentation is very much appreciated. If you find something that you think should be documented, please open an issue or pull request. + +## Features + +There are a lot of planned features that still need help. Go [here](https://github.com/jld3103/nextcloud-harbour/issues?q=is%3Aopen+is%3Aissue+label%3Afeature) and grab an issue to work on. +Even if a new feature is not listed yet, please open an issue. + +- :heavy_check_mark: Fully supported +- :white_check_mark: Fully supported, but new features planned +- :warning: Partially supported +- :rocket: Planned + +| App | Status | +|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| Files | :white_check_mark: [See here](https://github.com/jld3103/nextcloud-harbour/issues?q=is%3Aopen+is%3Aissue+label%3A%22harbour%3A+files%22+label%3Afeature) | +| Notes | :heavy_check_mark: | +| News | :white_check_mark: [See here](https://github.com/jld3103/nextcloud-harbour/issues?q=is%3Aopen+is%3Aissue+label%3Afeature+label%3A%22harbour%3A+news%22) | +| Contacts | :rocket: | +| Calendar | :rocket: | +| Tasks | :rocket: | +| Cookbook | :rocket: | + + +## Platform support + +Except for web, Harbour should run on all supported Flutter platforms in the future. +Right now this is not the case, only Android and Linux are supported and tested, but this can easily be extended to other platforms. + +The features and problems of all platforms should be considered when implementing new features. diff --git a/external/nextcloud-news b/external/nextcloud-news new file mode 160000 index 00000000..01e4adfe --- /dev/null +++ b/external/nextcloud-news @@ -0,0 +1 @@ +Subproject commit 01e4adfee2307a7a4c51b1f793e50d7d4f9325b8 diff --git a/external/nextcloud-notes b/external/nextcloud-notes new file mode 160000 index 00000000..e7e9ea03 --- /dev/null +++ b/external/nextcloud-notes @@ -0,0 +1 @@ +Subproject commit e7e9ea03c714198a7dada6494d51e77baa747812 diff --git a/external/nextcloud-server b/external/nextcloud-server new file mode 160000 index 00000000..2764c381 --- /dev/null +++ b/external/nextcloud-server @@ -0,0 +1 @@ +Subproject commit 2764c381a054ca8284975e70037e96411d2ce8f8 diff --git a/external/openapi-generator b/external/openapi-generator new file mode 160000 index 00000000..69f79fb7 --- /dev/null +++ b/external/openapi-generator @@ -0,0 +1 @@ +Subproject commit 69f79fb7892948590a9ffe46754c47ddd2634be1 diff --git a/external/seti-ui b/external/seti-ui new file mode 160000 index 00000000..6b83574d --- /dev/null +++ b/external/seti-ui @@ -0,0 +1 @@ +Subproject commit 6b83574de165123583d6d8d5b3b6c91f04b7153d diff --git a/mono_repo.yaml b/mono_repo.yaml new file mode 100644 index 00000000..01e4197a --- /dev/null +++ b/mono_repo.yaml @@ -0,0 +1,11 @@ +github: + on: + push: + branches: + - main + pull_request: + +self_validate: true + +merge_stages: + - analyze diff --git a/packages/file_icons/.gitignore b/packages/file_icons/.gitignore new file mode 100644 index 00000000..610713c1 --- /dev/null +++ b/packages/file_icons/.gitignore @@ -0,0 +1,12 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock + +fonts/ diff --git a/packages/file_icons/LICENSE b/packages/file_icons/LICENSE new file mode 100644 index 00000000..195a2697 --- /dev/null +++ b/packages/file_icons/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2022, jld3103 +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/file_icons/README.md b/packages/file_icons/README.md new file mode 100644 index 00000000..870f1640 --- /dev/null +++ b/packages/file_icons/README.md @@ -0,0 +1,6 @@ +# file_icons + +This is loosely ported from https://github.com/git-touch/file-icon. +I rewrote the script for generating the code in dart, improved the script and fixed some issues with the resulting output. + +To regenerate the data run `fvm dart run`. diff --git a/packages/file_icons/analysis_options.yaml b/packages/file_icons/analysis_options.yaml new file mode 100644 index 00000000..0cc523ce --- /dev/null +++ b/packages/file_icons/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:nit_picking/dart.yaml + +linter: + rules: + prefer_final_parameters: false # Disabled until super.X is no longer complained about in constructors diff --git a/packages/file_icons/bin/file_icons.dart b/packages/file_icons/bin/file_icons.dart new file mode 100644 index 00000000..15899b9c --- /dev/null +++ b/packages/file_icons/bin/file_icons.dart @@ -0,0 +1,159 @@ +// This script was ported from https://github.com/git-touch/file-icon/blob/master/tool/gulpfile.esm.js and improved in many ways + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +final setiUIPath = p.join( + '..', + '..', + 'external', + 'seti-ui', +); + +void main() { + copyFont(); + generateData(); +} + +void copyFont() { + final fontsDir = Directory('fonts'); + if (!fontsDir.existsSync()) { + fontsDir.createSync(); + } + File( + p.join( + setiUIPath, + 'styles', + '_fonts', + 'seti', + 'seti.ttf', + ), + ).copySync(p.join('fonts', 'seti.ttf')); +} + +void generateData() { + final mappingLess = File( + p.join( + setiUIPath, + 'styles', + 'components', + 'icons', + 'mapping.less', + ), + ).readAsStringSync(); + final setiLess = File( + p.join( + setiUIPath, + 'styles', + '_fonts', + 'seti.less', + ), + ).readAsStringSync(); + final uiVariablesLess = File( + p.join( + setiUIPath, + 'styles', + 'ui-variables.less', + ), + ).readAsStringSync(); + + final colors = {'@seti-primary': '_blue'}; + final iconSet = >{}; + final codePoints = {}; + + // https://github.com/microsoft/vscode/blob/554182620f43390075d8c7e7fa36634288ef4e2d/extensions/theme-seti/build/update-icon-theme.js#L345 + for (final match + in RegExp('\\.icon-(?:set|partial)\\([\'"]([\\w-.+]+)[\'"],\\s*[\'"]([\\w-]+)[\'"],\\s*(@[\\w-]+)\\)') + .allMatches(mappingLess)) { + final pattern = match.group(1)!.toLowerCase(); + final type = match.group(2)!; + final colorName = match.group(3)!; + + if (colorName != '@seti-primary') { + colors[colorName] = ''; + } + + if (iconSet[pattern] == null) { + iconSet[pattern] = [type, colorName]; + } + } + + for (final match in RegExp("^\t@([a-zA-Z0-9-_]+): '\\\\([A-Z0-9]+)';\$", multiLine: true).allMatches(setiLess)) { + codePoints[match.group(1)!] = '0x${match.group(2)!}'; + } + + for (final match + in RegExp('^(${colors.keys.join('|')}): #([a-f0-9]+);\$', multiLine: true).allMatches(uiVariablesLess)) { + final colorName = match.group(1)!; + final hexCode = match.group(2)!; + assert(hexCode.length == 6, 'CSS hex color needs to be six characters long'); + colors[colorName] = '0xff$hexCode'; + } + + final code = [ + '// THIS CODE IS GENERATED - DO NOT EDIT MANUALLY', + '', + "import 'package:file_icons/src/meta.dart';", + '', + '// Code points', + // This filters unused codepoints. + for (final type in codePoints.keys + .where((final type) => iconSet.keys.map((final pattern) => iconSet[pattern]![0] == type).contains(true))) ...[ + 'const ${_toVariableName(type)} = ${codePoints[type]};', + ], + '', + '// Colors', + for (final colorName in colors.keys) ...[ + 'const ${_toVariableName(colorName)} = ${colors[colorName]};', + ], + '', + '/// Mapping between file extensions and code points and colors', + 'const iconSetMap = {', + // This filters icons where the code points are missing. That indicates the fonts in seti-ui are not up-to-date. + // Run `gulp icons` in the seti-ui repository and everything should be there. + // Please submit the changes upstream if you can. + for (final pattern in iconSet.keys.where((final pattern) => codePoints.keys.contains(iconSet[pattern]![0]))) ...[ + " '$pattern': SetiMeta(${_toVariableName(iconSet[pattern]![0])}, ${_toVariableName(iconSet[pattern]![1])}),", + ], + '};', + '', + ]; + + final missingCodePoints = iconSet.keys.where((final pattern) => !codePoints.keys.contains(iconSet[pattern]![0])); + if (missingCodePoints.isNotEmpty) { + print( + 'WARNING: Missing code points for ${missingCodePoints.map((final pattern) => iconSet[pattern]![0]).toSet().join(', ')}', + ); + } + + File( + p.join( + 'lib', + 'src', + 'data.dart', + ), + ).writeAsStringSync(code.join('\n')); +} + +String _toVariableName(final String key) { + final result = StringBuffer('_'); + + final parts = key.split(''); + for (var i = 0; i < parts.length; i++) { + var char = parts[i]; + final prevChar = i > 0 ? parts[i - 1] : null; + if (char == '@' || char == '-' || char == '_') { + continue; + } + if (prevChar == '-' || prevChar == '_') { + char = char.toUpperCase(); + } + if (i == 0) { + char = char.toLowerCase(); + } + result.write(char); + } + + return result.toString(); +} diff --git a/packages/file_icons/lib/file_icons.dart b/packages/file_icons/lib/file_icons.dart new file mode 100644 index 00000000..ea7d53ba --- /dev/null +++ b/packages/file_icons/lib/file_icons.dart @@ -0,0 +1,52 @@ +import 'package:file_icons/src/data.dart'; +import 'package:flutter/widgets.dart'; + +// ignore: public_member_api_docs +class FileIcon extends StatelessWidget { + // ignore: public_member_api_docs + FileIcon( + final String fileName, { + this.size, + this.color, + super.key, + }) : fileName = fileName.toLowerCase(); + + /// Name of the file + final String fileName; + + /// Size of the icon + final double? size; + + /// This color will override the color provided from Seti icons + final Color? color; + + @override + Widget build(final BuildContext context) { + String? key; + + if (iconSetMap.containsKey(fileName)) { + key = fileName; + } else { + var chunks = fileName.split('.').sublist(1); + while (chunks.isNotEmpty) { + final k = '.${chunks.join()}'; + if (iconSetMap.containsKey(k)) { + key = k; + break; + } + chunks = chunks.sublist(1); + } + } + + final meta = iconSetMap[key ?? '.txt']!; + return Icon( + IconData( + meta.codePoint, + fontFamily: 'Seti', + fontPackage: 'file_icons', + ), + color: color ?? Color(meta.color), + size: size, + ); + } +} diff --git a/packages/file_icons/lib/src/data.dart b/packages/file_icons/lib/src/data.dart new file mode 100644 index 00000000..5cc2a33e --- /dev/null +++ b/packages/file_icons/lib/src/data.dart @@ -0,0 +1,558 @@ +// THIS CODE IS GENERATED - DO NOT EDIT MANUALLY + +import 'package:file_icons/src/meta.dart'; + +// Code points +const _r = 0xE001; +const _argdown = 0xE003; +const _asm = 0xE004; +const _audio = 0xE005; +const _babel = 0xE006; +const _bazel = 0xE007; +const _bicep = 0xE008; +const _bower = 0xE009; +const _bsl = 0xE00A; +const _cSharp = 0xE00B; +const _c = 0xE00C; +const _cake = 0xE00D; +const _cakePhp = 0xE00E; +const _clock = 0xE012; +const _clojure = 0xE013; +const _codeClimate = 0xE014; +const _codeSearch = 0xE015; +const _coffee = 0xE016; +const _coldfusion = 0xE018; +const _config = 0xE019; +const _cpp = 0xE01A; +const _crystal = 0xE01B; +const _crystalEmbedded = 0xE01C; +const _css = 0xE01D; +const _csv = 0xE01E; +const _cu = 0xE01F; +const _d = 0xE020; +const _dart = 0xE021; +const _db = 0xE022; +const _default = 0xE023; +const _docker = 0xE025; +const _ejs = 0xE027; +const _elixir = 0xE028; +const _elixirScript = 0xE029; +const _elm = 0xE02A; +const _eslint = 0xE02C; +const _ethereum = 0xE02D; +const _fSharp = 0xE02E; +const _favicon = 0xE02F; +const _firebase = 0xE030; +const _firefox = 0xE031; +const _font = 0xE033; +const _git = 0xE034; +const _github = 0xE037; +const _gitlab = 0xE038; +const _go = 0xE039; +const _go2 = 0xE03A; +const _godot = 0xE03B; +const _gradle = 0xE03C; +const _grails = 0xE03D; +const _graphql = 0xE03E; +const _grunt = 0xE03F; +const _gulp = 0xE040; +const _hacklang = 0xE041; +const _haml = 0xE042; +const _happenings = 0xE043; +const _haskell = 0xE044; +const _haxe = 0xE045; +const _heroku = 0xE046; +const _hex = 0xE047; +const _html = 0xE048; +const _htmlErb = 0xE049; +const _ignored = 0xE04A; +const _illustrator = 0xE04B; +const _image = 0xE04C; +const _info = 0xE04D; +const _ionic = 0xE04E; +const _jade = 0xE04F; +const _java = 0xE050; +const _javascript = 0xE051; +const _jenkins = 0xE052; +const _jinja = 0xE053; +const _json = 0xE055; +const _julia = 0xE056; +const _karma = 0xE057; +const _kotlin = 0xE058; +const _less = 0xE059; +const _license = 0xE05A; +const _liquid = 0xE05B; +const _livescript = 0xE05C; +const _lock = 0xE05D; +const _lua = 0xE05E; +const _makefile = 0xE05F; +const _markdown = 0xE060; +const _maven = 0xE061; +const _mdo = 0xE062; +const _mustache = 0xE063; +const _nim = 0xE065; +const _notebook = 0xE066; +const _npm = 0xE067; +const _npmIgnored = 0xE068; +const _nunjucks = 0xE069; +const _ocaml = 0xE06A; +const _odata = 0xE06B; +const _pddl = 0xE06C; +const _pdf = 0xE06D; +const _perl = 0xE06E; +const _photoshop = 0xE06F; +const _php = 0xE070; +const _pipeline = 0xE071; +const _plan = 0xE072; +const _platformio = 0xE073; +const _powershell = 0xE074; +const _prisma = 0xE075; +const _prolog = 0xE077; +const _pug = 0xE078; +const _puppet = 0xE079; +const _purescript = 0xE07A; +const _python = 0xE07B; +const _react = 0xE07D; +const _reasonml = 0xE07E; +const _rescript = 0xE07F; +const _rollup = 0xE080; +const _ruby = 0xE081; +const _rust = 0xE082; +const _salesforce = 0xE083; +const _sass = 0xE084; +const _sbt = 0xE085; +const _scala = 0xE086; +const _shell = 0xE089; +const _slim = 0xE08A; +const _smarty = 0xE08B; +const _spring = 0xE08C; +const _stylelint = 0xE08D; +const _stylus = 0xE08E; +const _sublime = 0xE08F; +const _svelte = 0xE090; +const _svg = 0xE091; +const _swift = 0xE092; +const _terraform = 0xE093; +const _tex = 0xE094; +const _todo = 0xE096; +const _tsconfig = 0xE097; +const _twig = 0xE098; +const _typescript = 0xE099; +const _vala = 0xE09A; +const _video = 0xE09B; +const _vue = 0xE09C; +const _wasm = 0xE09D; +const _wat = 0xE09E; +const _webpack = 0xE09F; +const _wgt = 0xE0A0; +const _windows = 0xE0A1; +const _word = 0xE0A2; +const _xls = 0xE0A3; +const _xml = 0xE0A4; +const _yarn = 0xE0A5; +const _yml = 0xE0A6; +const _zig = 0xE0A7; +const _zip = 0xE0A8; + +// Colors +const _setiPrimary = _blue; +const _red = 0xffcc3e44; +const _blue = 0xff519aba; +const _green = 0xff8dc149; +const _purple = 0xffa074c4; +const _yellow = 0xffcbcb41; +const _greyLight = 0xff6d8086; +const _white = 0xffd4d7d6; +const _ignore = 0xff41535b; +const _pink = 0xfff55385; +const _orange = 0xffe37933; +const _grey = 0xff4d5a5e; + +/// Mapping between file extensions and code points and colors +const iconSetMap = { + '.bsl': SetiMeta(_bsl, _red), + '.mdo': SetiMeta(_mdo, _red), + '.cls': SetiMeta(_salesforce, _blue), + '.apex': SetiMeta(_salesforce, _blue), + '.asm': SetiMeta(_asm, _red), + '.s': SetiMeta(_asm, _red), + '.bicep': SetiMeta(_bicep, _blue), + '.bzl': SetiMeta(_bazel, _green), + '.bazel': SetiMeta(_bazel, _green), + '.build': SetiMeta(_bazel, _green), + '.workspace': SetiMeta(_bazel, _green), + '.bazelignore': SetiMeta(_bazel, _green), + '.bazelversion': SetiMeta(_bazel, _green), + '.c': SetiMeta(_c, _blue), + '.h': SetiMeta(_c, _purple), + '.m': SetiMeta(_c, _yellow), + '.cs': SetiMeta(_cSharp, _blue), + '.cshtml': SetiMeta(_html, _blue), + '.aspx': SetiMeta(_html, _blue), + '.ascx': SetiMeta(_html, _green), + '.asax': SetiMeta(_html, _yellow), + '.master': SetiMeta(_html, _yellow), + '.cc': SetiMeta(_cpp, _blue), + '.cpp': SetiMeta(_cpp, _blue), + '.cxx': SetiMeta(_cpp, _blue), + '.c++': SetiMeta(_cpp, _blue), + '.hh': SetiMeta(_cpp, _purple), + '.hpp': SetiMeta(_cpp, _purple), + '.hxx': SetiMeta(_cpp, _purple), + '.h++': SetiMeta(_cpp, _purple), + '.mm': SetiMeta(_cpp, _yellow), + '.clj': SetiMeta(_clojure, _green), + '.cljs': SetiMeta(_clojure, _green), + '.cljc': SetiMeta(_clojure, _green), + '.edn': SetiMeta(_clojure, _blue), + '.cfc': SetiMeta(_coldfusion, _blue), + '.cfm': SetiMeta(_coldfusion, _blue), + '.coffee': SetiMeta(_coffee, _yellow), + '.litcoffee': SetiMeta(_coffee, _yellow), + '.config': SetiMeta(_config, _greyLight), + '.cfg': SetiMeta(_config, _greyLight), + '.conf': SetiMeta(_config, _greyLight), + '.cr': SetiMeta(_crystal, _white), + '.ecr': SetiMeta(_crystalEmbedded, _white), + '.slang': SetiMeta(_crystalEmbedded, _white), + '.cson': SetiMeta(_json, _yellow), + '.css': SetiMeta(_css, _blue), + '.css.map': SetiMeta(_css, _blue), + '.sss': SetiMeta(_css, _blue), + '.csv': SetiMeta(_csv, _green), + '.xls': SetiMeta(_xls, _green), + '.xlsx': SetiMeta(_xls, _green), + '.cu': SetiMeta(_cu, _green), + '.cuh': SetiMeta(_cu, _purple), + '.hu': SetiMeta(_cu, _purple), + '.cake': SetiMeta(_cake, _red), + '.ctp': SetiMeta(_cakePhp, _red), + '.d': SetiMeta(_d, _red), + '.doc': SetiMeta(_word, _blue), + '.docx': SetiMeta(_word, _blue), + '.ejs': SetiMeta(_ejs, _yellow), + '.ex': SetiMeta(_elixir, _purple), + '.exs': SetiMeta(_elixirScript, _purple), + 'mix': SetiMeta(_hex, _red), + '.elm': SetiMeta(_elm, _blue), + '.ico': SetiMeta(_favicon, _yellow), + '.fs': SetiMeta(_fSharp, _blue), + '.fsx': SetiMeta(_fSharp, _blue), + '.gitignore': SetiMeta(_git, _ignore), + '.gitconfig': SetiMeta(_git, _ignore), + '.gitkeep': SetiMeta(_git, _ignore), + '.gitattributes': SetiMeta(_git, _ignore), + '.gitmodules': SetiMeta(_git, _ignore), + '.go': SetiMeta(_go2, _blue), + '.slide': SetiMeta(_go, _blue), + '.article': SetiMeta(_go, _blue), + '.gd': SetiMeta(_godot, _blue), + '.godot': SetiMeta(_godot, _red), + '.tres': SetiMeta(_godot, _yellow), + '.tscn': SetiMeta(_godot, _purple), + '.gradle': SetiMeta(_gradle, _blue), + '.groovy': SetiMeta(_grails, _green), + '.gsp': SetiMeta(_grails, _green), + '.gql': SetiMeta(_graphql, _pink), + '.graphql': SetiMeta(_graphql, _pink), + '.graphqls': SetiMeta(_graphql, _pink), + '.hack': SetiMeta(_hacklang, _orange), + '.haml': SetiMeta(_haml, _red), + '.handlebars': SetiMeta(_mustache, _orange), + '.hbs': SetiMeta(_mustache, _orange), + '.hjs': SetiMeta(_mustache, _orange), + '.hs': SetiMeta(_haskell, _purple), + '.lhs': SetiMeta(_haskell, _purple), + '.hx': SetiMeta(_haxe, _orange), + '.hxs': SetiMeta(_haxe, _yellow), + '.hxp': SetiMeta(_haxe, _blue), + '.hxml': SetiMeta(_haxe, _purple), + '.html': SetiMeta(_html, _orange), + '.jade': SetiMeta(_jade, _red), + '.java': SetiMeta(_java, _red), + '.class': SetiMeta(_java, _blue), + '.classpath': SetiMeta(_java, _red), + '.properties': SetiMeta(_java, _red), + '.js': SetiMeta(_javascript, _yellow), + '.js.map': SetiMeta(_javascript, _yellow), + '.spec.js': SetiMeta(_javascript, _orange), + '.test.js': SetiMeta(_javascript, _orange), + '.es': SetiMeta(_javascript, _yellow), + '.es5': SetiMeta(_javascript, _yellow), + '.es6': SetiMeta(_javascript, _yellow), + '.es7': SetiMeta(_javascript, _yellow), + '.jinja': SetiMeta(_jinja, _red), + '.jinja2': SetiMeta(_jinja, _red), + '.json': SetiMeta(_json, _yellow), + '.jl': SetiMeta(_julia, _purple), + 'karma.conf.js': SetiMeta(_karma, _green), + 'karma.conf.coffee': SetiMeta(_karma, _green), + '.kt': SetiMeta(_kotlin, _orange), + '.kts': SetiMeta(_kotlin, _orange), + '.dart': SetiMeta(_dart, _blue), + '.less': SetiMeta(_less, _blue), + '.liquid': SetiMeta(_liquid, _green), + '.ls': SetiMeta(_livescript, _blue), + '.lua': SetiMeta(_lua, _blue), + '.markdown': SetiMeta(_markdown, _blue), + '.md': SetiMeta(_markdown, _blue), + '.argdown': SetiMeta(_argdown, _blue), + '.ad': SetiMeta(_argdown, _blue), + 'readme.md': SetiMeta(_info, _blue), + 'readme.txt': SetiMeta(_info, _blue), + 'readme': SetiMeta(_info, _blue), + 'changelog.md': SetiMeta(_clock, _blue), + 'changelog.txt': SetiMeta(_clock, _blue), + 'changelog': SetiMeta(_clock, _blue), + 'changes.md': SetiMeta(_clock, _blue), + 'changes.txt': SetiMeta(_clock, _blue), + 'changes': SetiMeta(_clock, _blue), + 'version.md': SetiMeta(_clock, _blue), + 'version.txt': SetiMeta(_clock, _blue), + 'version': SetiMeta(_clock, _blue), + 'mvnw': SetiMeta(_maven, _red), + '.mustache': SetiMeta(_mustache, _orange), + '.stache': SetiMeta(_mustache, _orange), + '.nim': SetiMeta(_nim, _yellow), + '.nims': SetiMeta(_nim, _yellow), + '.github-issues': SetiMeta(_github, _white), + '.ipynb': SetiMeta(_notebook, _blue), + '.njk': SetiMeta(_nunjucks, _green), + '.nunjucks': SetiMeta(_nunjucks, _green), + '.nunjs': SetiMeta(_nunjucks, _green), + '.nunj': SetiMeta(_nunjucks, _green), + '.njs': SetiMeta(_nunjucks, _green), + '.nj': SetiMeta(_nunjucks, _green), + '.npm-debug.log': SetiMeta(_npm, _ignore), + '.npmignore': SetiMeta(_npm, _red), + '.npmrc': SetiMeta(_npm, _red), + '.ml': SetiMeta(_ocaml, _orange), + '.mli': SetiMeta(_ocaml, _orange), + '.cmx': SetiMeta(_ocaml, _orange), + '.cmxa': SetiMeta(_ocaml, _orange), + '.odata': SetiMeta(_odata, _orange), + '.pl': SetiMeta(_perl, _blue), + '.php': SetiMeta(_php, _purple), + '.php.inc': SetiMeta(_php, _purple), + '.pipeline': SetiMeta(_pipeline, _orange), + '.pddl': SetiMeta(_pddl, _purple), + '.plan': SetiMeta(_plan, _green), + '.happenings': SetiMeta(_happenings, _blue), + '.ps1': SetiMeta(_powershell, _blue), + '.psd1': SetiMeta(_powershell, _blue), + '.psm1': SetiMeta(_powershell, _blue), + '.prisma': SetiMeta(_prisma, _blue), + '.pug': SetiMeta(_pug, _red), + '.pp': SetiMeta(_puppet, _yellow), + '.epp': SetiMeta(_puppet, _yellow), + '.purs': SetiMeta(_purescript, _white), + '.py': SetiMeta(_python, _blue), + '.jsx': SetiMeta(_react, _blue), + '.spec.jsx': SetiMeta(_react, _orange), + '.test.jsx': SetiMeta(_react, _orange), + '.cjsx': SetiMeta(_react, _blue), + '.spec.tsx': SetiMeta(_react, _orange), + '.test.tsx': SetiMeta(_react, _orange), + '.re': SetiMeta(_reasonml, _red), + '.res': SetiMeta(_rescript, _red), + '.resi': SetiMeta(_rescript, _pink), + '.r': SetiMeta(_r, _blue), + '.rmd': SetiMeta(_r, _blue), + '.rb': SetiMeta(_ruby, _red), + 'gemfile': SetiMeta(_ruby, _red), + '.erb': SetiMeta(_htmlErb, _red), + '.erb.html': SetiMeta(_htmlErb, _red), + '.html.erb': SetiMeta(_htmlErb, _red), + '.rs': SetiMeta(_rust, _greyLight), + '.sass': SetiMeta(_sass, _pink), + '.scss': SetiMeta(_sass, _pink), + '.springbeans': SetiMeta(_spring, _green), + '.slim': SetiMeta(_slim, _orange), + '.smarty.tpl': SetiMeta(_smarty, _yellow), + '.tpl': SetiMeta(_smarty, _yellow), + '.sbt': SetiMeta(_sbt, _blue), + '.scala': SetiMeta(_scala, _red), + '.sol': SetiMeta(_ethereum, _blue), + '.styl': SetiMeta(_stylus, _green), + '.svelte': SetiMeta(_svelte, _red), + '.swift': SetiMeta(_swift, _orange), + '.sql': SetiMeta(_db, _pink), + '.soql': SetiMeta(_db, _blue), + '.tf': SetiMeta(_terraform, _purple), + '.tf.json': SetiMeta(_terraform, _purple), + '.tfvars': SetiMeta(_terraform, _purple), + '.tex': SetiMeta(_tex, _blue), + '.sty': SetiMeta(_tex, _yellow), + '.dtx': SetiMeta(_tex, _orange), + '.ins': SetiMeta(_tex, _white), + '.txt': SetiMeta(_default, _white), + '.toml': SetiMeta(_config, _greyLight), + '.twig': SetiMeta(_twig, _green), + '.ts': SetiMeta(_typescript, _blue), + '.tsx': SetiMeta(_typescript, _blue), + '.spec.ts': SetiMeta(_typescript, _orange), + '.test.ts': SetiMeta(_typescript, _orange), + 'tsconfig.json': SetiMeta(_tsconfig, _blue), + '.vala': SetiMeta(_vala, _greyLight), + '.vapi': SetiMeta(_vala, _greyLight), + '.component': SetiMeta(_html, _orange), + '.vue': SetiMeta(_vue, _green), + '.wasm': SetiMeta(_wasm, _purple), + '.wat': SetiMeta(_wat, _purple), + '.xml': SetiMeta(_xml, _orange), + '.yml': SetiMeta(_yml, _purple), + '.yaml': SetiMeta(_yml, _purple), + 'swagger.json': SetiMeta(_json, _green), + 'swagger.yml': SetiMeta(_json, _green), + 'swagger.yaml': SetiMeta(_json, _green), + '.pro': SetiMeta(_prolog, _orange), + '.zig': SetiMeta(_zig, _orange), + '.jar': SetiMeta(_zip, _red), + '.zip': SetiMeta(_zip, _greyLight), + '.wgt': SetiMeta(_wgt, _blue), + '.ai': SetiMeta(_illustrator, _yellow), + '.psd': SetiMeta(_photoshop, _blue), + '.pdf': SetiMeta(_pdf, _red), + '.eot': SetiMeta(_font, _red), + '.ttf': SetiMeta(_font, _red), + '.woff': SetiMeta(_font, _red), + '.woff2': SetiMeta(_font, _red), + '.avif': SetiMeta(_image, _purple), + '.gif': SetiMeta(_image, _purple), + '.jpg': SetiMeta(_image, _purple), + '.jpeg': SetiMeta(_image, _purple), + '.png': SetiMeta(_image, _purple), + '.pxm': SetiMeta(_image, _purple), + '.svg': SetiMeta(_svg, _purple), + '.svgx': SetiMeta(_image, _purple), + '.tiff': SetiMeta(_image, _purple), + '.webp': SetiMeta(_image, _purple), + '.sublime-project': SetiMeta(_sublime, _orange), + '.sublime-workspace': SetiMeta(_sublime, _orange), + '.code-search': SetiMeta(_codeSearch, _purple), + '.sh': SetiMeta(_shell, _green), + '.zsh': SetiMeta(_shell, _green), + '.fish': SetiMeta(_shell, _green), + '.zshrc': SetiMeta(_shell, _green), + '.bashrc': SetiMeta(_shell, _green), + '.mov': SetiMeta(_video, _pink), + '.ogv': SetiMeta(_video, _pink), + '.webm': SetiMeta(_video, _pink), + '.avi': SetiMeta(_video, _pink), + '.mpg': SetiMeta(_video, _pink), + '.mp4': SetiMeta(_video, _pink), + '.mp3': SetiMeta(_audio, _purple), + '.ogg': SetiMeta(_audio, _purple), + '.wav': SetiMeta(_audio, _purple), + '.flac': SetiMeta(_audio, _purple), + '.3ds': SetiMeta(_svg, _blue), + '.3dm': SetiMeta(_svg, _blue), + '.stl': SetiMeta(_svg, _blue), + '.obj': SetiMeta(_svg, _blue), + '.dae': SetiMeta(_svg, _blue), + '.bat': SetiMeta(_windows, _blue), + '.cmd': SetiMeta(_windows, _blue), + 'mime.types': SetiMeta(_config, _greyLight), + 'jenkinsfile': SetiMeta(_jenkins, _red), + '.babelrc': SetiMeta(_babel, _yellow), + '.babelrc.js': SetiMeta(_babel, _yellow), + '.babelrc.cjs': SetiMeta(_babel, _yellow), + 'babel.config.js': SetiMeta(_babel, _yellow), + 'babel.config.json': SetiMeta(_babel, _yellow), + 'babel.config.cjs': SetiMeta(_babel, _yellow), + 'build': SetiMeta(_bazel, _green), + 'build.bazel': SetiMeta(_bazel, _green), + 'workspace': SetiMeta(_bazel, _green), + 'workspace.bazel': SetiMeta(_bazel, _green), + '.bazelrc': SetiMeta(_bazel, _grey), + 'bower.json': SetiMeta(_bower, _orange), + '.bowerrc': SetiMeta(_bower, _orange), + 'dockerfile': SetiMeta(_docker, _blue), + '.dockerignore': SetiMeta(_docker, _grey), + 'docker-healthcheck': SetiMeta(_docker, _green), + 'docker-compose.yml': SetiMeta(_docker, _pink), + 'docker-compose.yaml': SetiMeta(_docker, _pink), + 'docker-compose.override.yml': SetiMeta(_docker, _pink), + 'docker-compose.override.yaml': SetiMeta(_docker, _pink), + '.codeclimate.yml': SetiMeta(_codeClimate, _green), + '.eslintrc': SetiMeta(_eslint, _purple), + '.eslintrc.js': SetiMeta(_eslint, _purple), + '.eslintrc.cjs': SetiMeta(_eslint, _purple), + '.eslintrc.yaml': SetiMeta(_eslint, _purple), + '.eslintrc.yml': SetiMeta(_eslint, _purple), + '.eslintrc.json': SetiMeta(_eslint, _purple), + '.eslintignore': SetiMeta(_eslint, _grey), + '.firebaserc': SetiMeta(_firebase, _orange), + 'firebase.json': SetiMeta(_firebase, _orange), + 'geckodriver': SetiMeta(_firefox, _orange), + '.gitlab-ci.yml': SetiMeta(_gitlab, _orange), + 'gruntfile.js': SetiMeta(_grunt, _orange), + 'gruntfile.babel.js': SetiMeta(_grunt, _orange), + 'gruntfile.coffee': SetiMeta(_grunt, _orange), + 'gulpfile': SetiMeta(_gulp, _red), + 'gulpfile.js': SetiMeta(_gulp, _red), + 'ionic.config.json': SetiMeta(_ionic, _blue), + 'ionic.project': SetiMeta(_ionic, _blue), + '.jshintrc': SetiMeta(_javascript, _blue), + '.jscsrc': SetiMeta(_javascript, _blue), + 'platformio.ini': SetiMeta(_platformio, _orange), + 'rollup.config.js': SetiMeta(_rollup, _red), + 'sass-lint.yml': SetiMeta(_sass, _pink), + '.stylelintrc': SetiMeta(_stylelint, _white), + '.stylelintrc.json': SetiMeta(_stylelint, _white), + '.stylelintrc.yaml': SetiMeta(_stylelint, _white), + '.stylelintrc.yml': SetiMeta(_stylelint, _white), + '.stylelintrc.js': SetiMeta(_stylelint, _white), + '.stylelintignore': SetiMeta(_stylelint, _grey), + 'stylelint.config.js': SetiMeta(_stylelint, _white), + 'stylelint.config.cjs': SetiMeta(_stylelint, _white), + 'yarn.clean': SetiMeta(_yarn, _blue), + 'yarn.lock': SetiMeta(_yarn, _blue), + 'webpack.config.js': SetiMeta(_webpack, _blue), + 'webpack.config.cjs': SetiMeta(_webpack, _blue), + 'webpack.config.build.js': SetiMeta(_webpack, _blue), + 'webpack.config.build.cjs': SetiMeta(_webpack, _blue), + 'webpack.common.js': SetiMeta(_webpack, _blue), + 'webpack.common.cjs': SetiMeta(_webpack, _blue), + 'webpack.dev.js': SetiMeta(_webpack, _blue), + 'webpack.dev.cjs': SetiMeta(_webpack, _blue), + 'webpack.prod.js': SetiMeta(_webpack, _blue), + 'webpack.prod.cjs': SetiMeta(_webpack, _blue), + '.direnv': SetiMeta(_config, _greyLight), + '.env': SetiMeta(_config, _greyLight), + '.static': SetiMeta(_config, _greyLight), + '.editorconfig': SetiMeta(_config, _greyLight), + '.slugignore': SetiMeta(_config, _greyLight), + '.tmp': SetiMeta(_clock, _greyLight), + '.htaccess': SetiMeta(_config, _greyLight), + '.key': SetiMeta(_lock, _green), + '.cert': SetiMeta(_lock, _green), + '.cer': SetiMeta(_lock, _green), + '.crt': SetiMeta(_lock, _green), + '.pem': SetiMeta(_lock, _green), + 'license': SetiMeta(_license, _yellow), + 'licence': SetiMeta(_license, _yellow), + 'license.txt': SetiMeta(_license, _yellow), + 'licence.txt': SetiMeta(_license, _yellow), + 'license.md': SetiMeta(_license, _yellow), + 'licence.md': SetiMeta(_license, _yellow), + 'copying': SetiMeta(_license, _yellow), + 'copying.txt': SetiMeta(_license, _yellow), + 'copying.md': SetiMeta(_license, _yellow), + 'compiling': SetiMeta(_license, _orange), + 'compiling.txt': SetiMeta(_license, _orange), + 'compiling.md': SetiMeta(_license, _orange), + 'contributing': SetiMeta(_license, _red), + 'contributing.txt': SetiMeta(_license, _red), + 'contributing.md': SetiMeta(_license, _red), + 'makefile': SetiMeta(_makefile, _orange), + 'qmakefile': SetiMeta(_makefile, _purple), + 'omakefile': SetiMeta(_makefile, _greyLight), + 'cmakelists.txt': SetiMeta(_makefile, _blue), + 'procfile': SetiMeta(_heroku, _purple), + 'todo': SetiMeta(_todo, _setiPrimary), + 'todo.txt': SetiMeta(_todo, _setiPrimary), + 'todo.md': SetiMeta(_todo, _setiPrimary), + 'npm-debug.log': SetiMeta(_npmIgnored, _ignore), + '.ds_store': SetiMeta(_ignored, _ignore), +}; diff --git a/packages/file_icons/lib/src/meta.dart b/packages/file_icons/lib/src/meta.dart new file mode 100644 index 00000000..c26d3af5 --- /dev/null +++ b/packages/file_icons/lib/src/meta.dart @@ -0,0 +1,9 @@ +// ignore: public_member_api_docs +class SetiMeta { + // ignore: public_member_api_docs + const SetiMeta(this.codePoint, this.color); + // ignore: public_member_api_docs + final int codePoint; + // ignore: public_member_api_docs + final int color; +} diff --git a/packages/file_icons/mono_pkg.yaml b/packages/file_icons/mono_pkg.yaml new file mode 100644 index 00000000..e3d1d89c --- /dev/null +++ b/packages/file_icons/mono_pkg.yaml @@ -0,0 +1,7 @@ +sdk: + - stable + +stages: + - analyze: + - analyze + - format: --output=none --set-exit-if-changed --line-length 120 . diff --git a/packages/file_icons/pubspec.yaml b/packages/file_icons/pubspec.yaml new file mode 100644 index 00000000..ac1c6dd5 --- /dev/null +++ b/packages/file_icons/pubspec.yaml @@ -0,0 +1,23 @@ +name: file_icons +version: 1.0.0 + +environment: + sdk: '>=2.17.0 <3.0.0' + flutter: '>=3.0.0' + +dependencies: + flutter: + sdk: flutter + path: ^1.8.1 + +dev_dependencies: + nit_picking: + git: + url: https://github.com/stack11/dart_nit_picking + ref: f29382f + +flutter: + fonts: + - family: Seti + fonts: + - asset: fonts/seti.ttf diff --git a/packages/harbour/.gitignore b/packages/harbour/.gitignore new file mode 100644 index 00000000..0fa6b675 --- /dev/null +++ b/packages/harbour/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/harbour/.metadata b/packages/harbour/.metadata new file mode 100644 index 00000000..e8149129 --- /dev/null +++ b/packages/harbour/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 657830b4c77aecfd0e32ec6504c859213dded97a + channel: master + +project_type: app diff --git a/packages/harbour/LICENSE b/packages/harbour/LICENSE new file mode 100644 index 00000000..195a2697 --- /dev/null +++ b/packages/harbour/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2022, jld3103 +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/harbour/README.md b/packages/harbour/README.md new file mode 100644 index 00000000..0a7a398d --- /dev/null +++ b/packages/harbour/README.md @@ -0,0 +1,11 @@ +# harbour + +A beautiful convergent cross-platform client for Nextcloud written in Flutter. + +## Screenshots + +For more screenshots see `./screenshots/`. + +| ![](screenshots/login_server_selection.png) | ![](screenshots/settings_oled.png) | ![](screenshots/settings_news.png) | +|------------------------------------------------|------------------------------------|------------------------------------| +| ![](screenshots/news_articles_unread_list.png) | ![](screenshots/files_photos.png) | ![](screenshots/notes_edit.png) | diff --git a/packages/harbour/analysis_options.yaml b/packages/harbour/analysis_options.yaml new file mode 100644 index 00000000..9184e2e9 --- /dev/null +++ b/packages/harbour/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:nit_picking/flutter.yaml + +linter: + rules: + prefer_final_parameters: false # Disabled until super.X is no longer complained about in constructors + +analyzer: + exclude: + - lib/src/l10n/** diff --git a/packages/harbour/android/.gitignore b/packages/harbour/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/packages/harbour/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/packages/harbour/android/app/build.gradle b/packages/harbour/android/app/build.gradle new file mode 100644 index 00000000..da88cf07 --- /dev/null +++ b/packages/harbour/android/app/build.gradle @@ -0,0 +1,67 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'news/main/kotlin' + } + + defaultConfig { + applicationId "de.provokateurin.harbour" + minSdkVersion 19 + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/packages/harbour/android/app/proguard-rules.pro b/packages/harbour/android/app/proguard-rules.pro new file mode 100644 index 00000000..c946bd53 --- /dev/null +++ b/packages/harbour/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class androidx.lifecycle.DefaultLifecycleObserver diff --git a/packages/harbour/android/app/src/debug/AndroidManifest.xml b/packages/harbour/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..6ea50f8d --- /dev/null +++ b/packages/harbour/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/harbour/android/app/src/main/AndroidManifest.xml b/packages/harbour/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c7c8c0e --- /dev/null +++ b/packages/harbour/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + diff --git a/packages/harbour/android/app/src/main/kotlin/de/provokateurin/harbour/MainActivity.kt b/packages/harbour/android/app/src/main/kotlin/de/provokateurin/harbour/MainActivity.kt new file mode 100644 index 00000000..f4f3ff11 --- /dev/null +++ b/packages/harbour/android/app/src/main/kotlin/de/provokateurin/harbour/MainActivity.kt @@ -0,0 +1,6 @@ +package de.provokateurin.harbour + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/packages/harbour/android/app/src/main/res/drawable-hdpi/android12splash.png b/packages/harbour/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 00000000..00ee9286 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-hdpi/splash.png b/packages/harbour/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 00000000..83f15b16 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-mdpi/android12splash.png b/packages/harbour/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 00000000..3ce81a20 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-mdpi/splash.png b/packages/harbour/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 00000000..f7dfd4c2 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-night-v21/background.png b/packages/harbour/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 00000000..e253ffe1 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-night-v21/launch_background.xml b/packages/harbour/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/drawable-night/background.png b/packages/harbour/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 00000000..e253ffe1 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-night/background.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-night/launch_background.xml b/packages/harbour/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/drawable-v21/background.png b/packages/harbour/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 00000000..e29b3b59 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-v21/background.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/harbour/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/drawable-xhdpi/android12splash.png b/packages/harbour/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 00000000..1d598d21 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-xhdpi/splash.png b/packages/harbour/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..27aadf8c Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/packages/harbour/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 00000000..4942715f Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-xxhdpi/splash.png b/packages/harbour/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..1d017a22 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 00000000..5255665f Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/splash.png b/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..d6a09632 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable/background.png b/packages/harbour/android/app/src/main/res/drawable/background.png new file mode 100644 index 00000000..e29b3b59 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/drawable/background.png differ diff --git a/packages/harbour/android/app/src/main/res/drawable/launch_background.xml b/packages/harbour/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_files.png b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_files.png new file mode 100644 index 00000000..8f8385c2 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_files.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_news.png b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_news.png new file mode 100644 index 00000000..aa9b7b35 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_news.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_notes.png b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_notes.png new file mode 100644 index 00000000..05f55f80 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-hdpi/app_notes.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/harbour/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..3eec68c5 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_files.png b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_files.png new file mode 100644 index 00000000..f1e6caa3 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_files.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_news.png b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_news.png new file mode 100644 index 00000000..08aea4c3 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_news.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_notes.png b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_notes.png new file mode 100644 index 00000000..1815607f Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-mdpi/app_notes.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/harbour/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..22f63190 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_files.png b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_files.png new file mode 100644 index 00000000..1d4d46d7 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_files.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_news.png b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_news.png new file mode 100644 index 00000000..bdfb9c28 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_news.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_notes.png b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_notes.png new file mode 100644 index 00000000..6c60c76d Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/app_notes.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..ab062122 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_files.png b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_files.png new file mode 100644 index 00000000..f00e483b Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_files.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_news.png b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_news.png new file mode 100644 index 00000000..de4c090e Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_news.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_notes.png b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_notes.png new file mode 100644 index 00000000..3c26c4f1 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/app_notes.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..7de7faa9 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_files.png b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_files.png new file mode 100644 index 00000000..0b8e34b4 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_files.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_news.png b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_news.png new file mode 100644 index 00000000..57f72f48 Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_news.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_notes.png b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_notes.png new file mode 100644 index 00000000..3ebd113b Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/app_notes.png differ diff --git a/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..09d33e5b Binary files /dev/null and b/packages/harbour/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/harbour/android/app/src/main/res/raw/keep.xml b/packages/harbour/android/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..c4cb8ab2 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/harbour/android/app/src/main/res/values-night-v31/styles.xml b/packages/harbour/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..58f53f4a --- /dev/null +++ b/packages/harbour/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/values-night/styles.xml b/packages/harbour/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..024d82a2 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/values-v31/styles.xml b/packages/harbour/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..2b3eb99d --- /dev/null +++ b/packages/harbour/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/main/res/values/styles.xml b/packages/harbour/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..f3ea2b93 --- /dev/null +++ b/packages/harbour/android/app/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/packages/harbour/android/app/src/profile/AndroidManifest.xml b/packages/harbour/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..6ea50f8d --- /dev/null +++ b/packages/harbour/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/harbour/android/build.gradle b/packages/harbour/android/build.gradle new file mode 100644 index 00000000..20dc4326 --- /dev/null +++ b/packages/harbour/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.0.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/harbour/android/gradle.properties b/packages/harbour/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/packages/harbour/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/harbour/android/gradle/wrapper/gradle-wrapper.properties b/packages/harbour/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b8793d3c --- /dev/null +++ b/packages/harbour/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/packages/harbour/android/settings.gradle b/packages/harbour/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/packages/harbour/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/packages/harbour/assets/.gitignore b/packages/harbour/assets/.gitignore new file mode 100644 index 00000000..4c49bd78 --- /dev/null +++ b/packages/harbour/assets/.gitignore @@ -0,0 +1 @@ +.env diff --git a/packages/harbour/assets/LEGALESE.txt b/packages/harbour/assets/LEGALESE.txt new file mode 100644 index 00000000..c1e93575 --- /dev/null +++ b/packages/harbour/assets/LEGALESE.txt @@ -0,0 +1,2 @@ +Copyright © 2022, Kate Döen +Under BSD-3 license diff --git a/packages/harbour/assets/apps/files.svg b/packages/harbour/assets/apps/files.svg new file mode 100644 index 00000000..93cb5a9e --- /dev/null +++ b/packages/harbour/assets/apps/files.svg @@ -0,0 +1 @@ + diff --git a/packages/harbour/assets/apps/news.svg b/packages/harbour/assets/apps/news.svg new file mode 100644 index 00000000..c27cae23 --- /dev/null +++ b/packages/harbour/assets/apps/news.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/harbour/assets/apps/notes.svg b/packages/harbour/assets/apps/notes.svg new file mode 100644 index 00000000..f2692d64 --- /dev/null +++ b/packages/harbour/assets/apps/notes.svg @@ -0,0 +1 @@ + diff --git a/packages/harbour/assets/logo_harbour.svg b/packages/harbour/assets/logo_harbour.svg new file mode 100644 index 00000000..851dc1df --- /dev/null +++ b/packages/harbour/assets/logo_harbour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/harbour/assets/logo_nextcloud.svg b/packages/harbour/assets/logo_nextcloud.svg new file mode 100644 index 00000000..7910fc00 --- /dev/null +++ b/packages/harbour/assets/logo_nextcloud.svg @@ -0,0 +1,77 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/packages/harbour/flutter_native_splash.yaml b/packages/harbour/flutter_native_splash.yaml new file mode 100644 index 00000000..176f77b5 --- /dev/null +++ b/packages/harbour/flutter_native_splash.yaml @@ -0,0 +1,10 @@ +flutter_native_splash: + color: "#ffffff" + color_dark: "#202020" + image: assets/splash_icon.png + android_12: + image: assets/splash_icon_android_12.png + icon_background_color: "#ffffff" + icon_background_color_dark: "#202020" + ios: false + web: false diff --git a/packages/harbour/l10n.yaml b/packages/harbour/l10n.yaml new file mode 100644 index 00000000..7974745e --- /dev/null +++ b/packages/harbour/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/l10n +template-arb-file: en.arb +output-localization-file: localizations.dart +synthetic-package: false +output-dir: lib/l10n +nullable-getter: false diff --git a/packages/harbour/lib/app.dart b/packages/harbour/lib/app.dart new file mode 100644 index 00000000..c13ecdfe --- /dev/null +++ b/packages/harbour/lib/app.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; + +class HarbourApp extends StatefulWidget { + const HarbourApp({ + super.key, + }); + + @override + State createState() => _HarbourAppState(); +} + +// ignore: prefer_mixin +class _HarbourAppState extends State with WidgetsBindingObserver { + final _navigatorKey = GlobalKey(); + NextcloudTheme? _userTheme; + + final _platformBrightness = BehaviorSubject.seeded( + WidgetsBinding.instance.window.platformBrightness, + ); + + @override + void didChangePlatformBrightness() { + _platformBrightness.add(WidgetsBinding.instance.window.platformBrightness); + + super.didChangePlatformBrightness(); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + + WidgetsBinding.instance.addPostFrameCallback((final _) { + RxBlocProvider.of(context).activeAccount.listen((final activeAccount) async { + FlutterNativeSplash.remove(); + + if (activeAccount == null) { + await _navigatorKey.currentState!.pushAndRemoveUntil( + MaterialPageRoute( + builder: (final context) => const LoginPage(), + ), + (final _) => false, + ); + } else { + await _navigatorKey.currentState!.pushAndRemoveUntil( + MaterialPageRoute( + builder: (final context) => HomePage( + account: activeAccount, + onThemeChanged: (final theme) { + setState(() { + _userTheme = theme; + }); + }, + ), + ), + (final _) => false, + ); + } + }); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + // ignore: discarded_futures + _platformBrightness.close(); + + super.dispose(); + } + + @override + Widget build(final BuildContext context) { + final globalOptions = Provider.of(context); + return StreamBuilder( + stream: _platformBrightness, + builder: (final context, final platformBrightnessSnapshot) => StreamBuilder( + stream: globalOptions.themeMode.stream, + builder: (final context, final themeModeSnapshot) => StreamBuilder( + stream: globalOptions.themeOLEDAsDark.stream, + builder: (final context, final themeOLEDAsDarkSnapshot) { + if (!platformBrightnessSnapshot.hasData || !themeOLEDAsDarkSnapshot.hasData || !themeModeSnapshot.hasData) { + return Container(); + } + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + navigatorKey: _navigatorKey, + debugShowCheckedModeBanner: false, + theme: getThemeFromNextcloudTheme( + _userTheme, + themeModeSnapshot.data!, + platformBrightnessSnapshot.data!, + oledAsDark: themeOLEDAsDarkSnapshot.data!, + ), + home: Container(), + ); + }, + ), + ), + ); + } +} diff --git a/packages/harbour/lib/l10n/en.arb b/packages/harbour/lib/l10n/en.arb new file mode 100644 index 00000000..73ba4c67 --- /dev/null +++ b/packages/harbour/lib/l10n/en.arb @@ -0,0 +1,245 @@ +{ + "@@locale": "en", + "appName": "Nextcloud Harbour", + "loginAccountAlreadyExists": "The account you are trying to add already exists", + "loginAgain": "Login again", + "loginOpenAgain": "Open again", + "loginSwitchToBrowserWindow": "Please switch to the browser window that just opened and proceed there", + "loginWorksWith": "works with", + "errorCredentialsForAccountNoLongerMatch": "The credentials for this account no longer match", + "errorServerHadAProblemProcessingYourRequest": "The server had a problem while processing your request. You might want to try again", + "errorSomethingWentWrongTryAgainLater": "Something went wrong. Please try again later", + "errorUnableToReachServer": "Unable to reach the server", + "errorUnableToReachServerAt": "Unable to reach the server at {url}", + "@errorUnableToReachServerAt": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "errorConnectionTimedOut": "Connection has timed out", + "errorNoCompatibleNextcloudAppsFound": "No compatible Nextcloud apps could be found.\nWe are working hard to implement more and more apps!", + "errorServerInMaintenanceMode": "The server is in maintenance mode. Please try again later or contact the server admin.", + "errorMissingPermission": "Permission for {name} is missing", + "@errorMissingPermission" : { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "validatorEmptyField": "This field can not be empty", + "validatorInvalidURL": "Invalid URL provided", + "delete": "Delete", + "remove": "Remove", + "rename": "Rename", + "move": "Move", + "copy": "Copy", + "yes": "Yes", + "no": "No", + "close": "Close", + "retry": "Retry", + "showSlashHide": "Show/Hide", + "exit": "Exit", + "settings": "Settings", + "settingsForApp": "Settings - {name}", + "@settingsForApp": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "settingsForAccount": "Settings - {username}@{host}", + "@settingsForAccount": { + "placeholders": { + "username": { + "type": "String" + }, + "host": { + "type": "String" + } + } + }, + "settingsApps": "Apps", + "settingsExport": "Export settings", + "settingsImport": "Import settings", + "settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)", + "optionsCategoryGeneral": "General", + "optionsCategoryTheme": "Theme", + "optionsCategoryOther": "Other", + "optionsCategoryAccounts": "Accounts", + "optionsCategoryStartup": "Startup", + "optionsCategorySystemTray": "System tray", + "optionsSortOrderAscending": "Ascending", + "optionsSortOrderDescending": "Descending", + "globalOptionsThemeMode": "Theme mode", + "globalOptionsThemeModeLight": "Light", + "globalOptionsThemeModeDark": "Dark", + "globalOptionsThemeModeAutomatic": "Automatic", + "globalOptionsThemeOLEDAsDark": "OLED theme as dark theme", + "globalOptionsStartupMinimized": "Start minimized", + "globalOptionsStartupMinimizeInsteadOfExit": "Minimize instead of exit", + "globalOptionsSystemTrayEnabled": "Enable system tray", + "globalOptionsSystemTrayHideToTrayWhenMinimized": "Hide to system tray when minimized", + "globalOptionsAccountsRememberLastUsedAccount": "Remember last used account", + "globalOptionsAccountsRemoveConfirm": "Are you sure you want to remove the account {name} from {url}?", + "@globalOptionsAccountsRemoveConfirm": { + "placeholders": { + "name": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "globalOptionsAccountsAdd": "Add account", + "accountOptionsInitialApp": "App to show initially", + "accountOptionsAutomatic": "Automatic", + "licenses": "Licenses", + "filesName": "Files", + "filesUploadFiles": "Upload files", + "filesUploadImages": "Upload images", + "filesCreateFolder": "Create folder", + "filesFolderName": "Folder name", + "filesRenameFolder": "Rename folder", + "filesRenameFile": "Rename file", + "filesDetails": "Details", + "filesDetailsFileName": "File name", + "filesDetailsFolderName": "Folder name", + "filesDetailsParentFolder": "Parent folder", + "filesDetailsFileSize": "File size", + "filesDetailsFolderSize": "Folder size", + "filesDetailsLastModified": "Last modified", + "filesDetailsIsFavorite": "Is favorite", + "filesSync": "Sync", + "filesDeleteFileConfirm": "Are you sure you want to delete the file '{name}'?", + "@filesDeleteFileConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "filesDeleteFolderConfirm": "Are you sure you want to delete the folder '{name}'?", + "@filesDeleteFolderConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "filesChooseFolder": "Choose folder", + "filesAddToFavorites": "Add to favorites", + "filesRemoveFromFavorites": "Remove from favorites", + "filesOptionsShowPreviews": "Show previews for files", + "filesOptionsUploadQueueParallelism": "Upload queue parallelism", + "filesOptionsDownloadQueueParallelism": "Download queue parallelism", + "newsName": "News", + "newsAddFeed": "Add feed", + "newsFolder": "Folder", + "newsFolderRoot": "Root Folder", + "newsCreateFolder": "Create folder", + "newsCreateFolderName": "Folder name", + "newsDeleteFolderConfirm": "Are you sure you want to delete the folder '{name}'?", + "@newsDeleteFolderConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "newsRenameFolder": "Rename folder", + "newsRemoveFeedConfirm": "Are you sure you want to remove the feed '{name}'?", + "@newsRemoveFeedConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "newsMoveFeed": "Move feed", + "newsRenameFeed": "Rename feed", + "newsArticles": "Articles", + "newsFolders": "Folders", + "newsFeeds": "Feeds", + "newsFilterAll": "All", + "newsFilterUnread": "Unread", + "newsFilterStarred": "Starred", + "newsUnreadArticles": "{count} unread", + "@newsUnreadArticles": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "newsShowFeedURL": "Show URL", + "newsCopyFeedURL": "Copy URL", + "newsCopiedFeedURL": "URL copied to clipboard", + "newsCopyFeedErrorMessage": "Copy error message", + "newsCopiedFeedErrorMessage": "Error message copied to clipboard", + "newsOptionsDefaultCategory": "Category to show by default", + "newsOptionsArticleViewType": "How to open article", + "newsOptionsArticleViewTypeDirect": "Show text directly", + "newsOptionsArticleViewTypeInternalBrowser": "Open in internal browser", + "newsOptionsArticleViewTypeExternalBrowser": "Open in external browser", + "newsOptionsDefaultArticlesFilter": "Articles to show by default", + "newsOptionsArticlesSortProperty": "How to sort articles", + "newsOptionsArticlesSortPropertyPublishDate": "Publish date", + "newsOptionsArticlesSortPropertyAlphabetical": "Alphabetical", + "newsOptionsArticlesSortPropertyFeed": "Feed", + "newsOptionsArticlesSortOrder": "Sort order of articles", + "newsOptionsFeedsSortProperty": "How to sort feeds", + "newsOptionsFeedsSortPropertyAlphabetical": "Alphabetical", + "newsOptionsFeedsSortPropertyUnreadCount": "Unread count", + "newsOptionsFeedsSortOrder": "Sort order of feeds", + "newsOptionsFoldersSortProperty": "How to sort folders", + "newsOptionsFoldersSortPropertyAlphabetical": "Alphabetical", + "newsOptionsFoldersSortPropertyUnreadCount": "Unread count", + "newsOptionsFoldersSortOrder": "Sort order of folders", + "newsOptionsDefaultFolderViewType": "What should be shown first when opening a folder", + "notesName": "Notes", + "notesNote": "Note", + "notesNotes": "Notes", + "notesCategories": "Categories", + "notesCreateNote": "Create note", + "notesCategory": "Category", + "notesChangeCategory": "Change category", + "notesSetCategory": "Set category", + "notesNoteTitle": "Title", + "notesNoteChangedOnServer": "The note has been changed on the server. Please refresh and try again", + "notesNotesInCategory": "{count} notes", + "@notesNotesInCategory": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "notesUncategorized": "Uncategorized", + "notesEdit": "Edit", + "notesPreview": "Preview", + "notesDeleteNoteConfirm": "Are you sure you want to delete the note '{name}'?", + "@notesDeleteNoteConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "notesOptionsDefaultCategory": "Category to show by default", + "notesOptionsDefaultNoteViewType": "How to show note", + "notesOptionsDefaultNoteViewTypePreview": "Preview", + "notesOptionsDefaultNoteViewTypeEdit": "Editor", + "notesOptionsNotesSortOrder": "Sort order of notes", + "notesOptionsNotesSortProperty": "How to sort notes", + "notesOptionsNotesSortPropertyLastModified": "Last modified", + "notesOptionsNotesSortPropertyAlphabetical": "Alphabetical", + "notesOptionsCategoriesSortOrder": "Sort order of categories", + "notesOptionsCategoriesSortProperty": "How to sort categories", + "notesOptionsCategoriesSortPropertyAlphabetical": "Alphabetical", + "notesOptionsCategoriesSortPropertyNotesCount": "Count of notes" +} diff --git a/packages/harbour/lib/l10n/localizations.dart b/packages/harbour/lib/l10n/localizations.dart new file mode 100644 index 00000000..f6554cc1 --- /dev/null +++ b/packages/harbour/lib/l10n/localizations.dart @@ -0,0 +1,1031 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'localizations_en.dart'; + +/// Callers can lookup localized strings with an instance of AppLocalizations returned +/// by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// localizationDelegates list, and the locales they support in the app's +/// supportedLocales list. For example: +/// +/// ``` +/// import 'l10n/localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ``` +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('en')]; + + /// No description provided for @appName. + /// + /// In en, this message translates to: + /// **'Nextcloud Harbour'** + String get appName; + + /// No description provided for @loginAccountAlreadyExists. + /// + /// In en, this message translates to: + /// **'The account you are trying to add already exists'** + String get loginAccountAlreadyExists; + + /// No description provided for @loginAgain. + /// + /// In en, this message translates to: + /// **'Login again'** + String get loginAgain; + + /// No description provided for @loginOpenAgain. + /// + /// In en, this message translates to: + /// **'Open again'** + String get loginOpenAgain; + + /// No description provided for @loginSwitchToBrowserWindow. + /// + /// In en, this message translates to: + /// **'Please switch to the browser window that just opened and proceed there'** + String get loginSwitchToBrowserWindow; + + /// No description provided for @loginWorksWith. + /// + /// In en, this message translates to: + /// **'works with'** + String get loginWorksWith; + + /// No description provided for @errorCredentialsForAccountNoLongerMatch. + /// + /// In en, this message translates to: + /// **'The credentials for this account no longer match'** + String get errorCredentialsForAccountNoLongerMatch; + + /// No description provided for @errorServerHadAProblemProcessingYourRequest. + /// + /// In en, this message translates to: + /// **'The server had a problem while processing your request. You might want to try again'** + String get errorServerHadAProblemProcessingYourRequest; + + /// No description provided for @errorSomethingWentWrongTryAgainLater. + /// + /// In en, this message translates to: + /// **'Something went wrong. Please try again later'** + String get errorSomethingWentWrongTryAgainLater; + + /// No description provided for @errorUnableToReachServer. + /// + /// In en, this message translates to: + /// **'Unable to reach the server'** + String get errorUnableToReachServer; + + /// No description provided for @errorUnableToReachServerAt. + /// + /// In en, this message translates to: + /// **'Unable to reach the server at {url}'** + String errorUnableToReachServerAt(String url); + + /// No description provided for @errorConnectionTimedOut. + /// + /// In en, this message translates to: + /// **'Connection has timed out'** + String get errorConnectionTimedOut; + + /// No description provided for @errorNoCompatibleNextcloudAppsFound. + /// + /// In en, this message translates to: + /// **'No compatible Nextcloud apps could be found.\nWe are working hard to implement more and more apps!'** + String get errorNoCompatibleNextcloudAppsFound; + + /// No description provided for @errorServerInMaintenanceMode. + /// + /// In en, this message translates to: + /// **'The server is in maintenance mode. Please try again later or contact the server admin.'** + String get errorServerInMaintenanceMode; + + /// No description provided for @errorMissingPermission. + /// + /// In en, this message translates to: + /// **'Permission for {name} is missing'** + String errorMissingPermission(String name); + + /// No description provided for @validatorEmptyField. + /// + /// In en, this message translates to: + /// **'This field can not be empty'** + String get validatorEmptyField; + + /// No description provided for @validatorInvalidURL. + /// + /// In en, this message translates to: + /// **'Invalid URL provided'** + String get validatorInvalidURL; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @rename. + /// + /// In en, this message translates to: + /// **'Rename'** + String get rename; + + /// No description provided for @move. + /// + /// In en, this message translates to: + /// **'Move'** + String get move; + + /// No description provided for @copy. + /// + /// In en, this message translates to: + /// **'Copy'** + String get copy; + + /// No description provided for @yes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get yes; + + /// No description provided for @no. + /// + /// In en, this message translates to: + /// **'No'** + String get no; + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @showSlashHide. + /// + /// In en, this message translates to: + /// **'Show/Hide'** + String get showSlashHide; + + /// No description provided for @exit. + /// + /// In en, this message translates to: + /// **'Exit'** + String get exit; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @settingsForApp. + /// + /// In en, this message translates to: + /// **'Settings - {name}'** + String settingsForApp(String name); + + /// No description provided for @settingsForAccount. + /// + /// In en, this message translates to: + /// **'Settings - {username}@{host}'** + String settingsForAccount(String username, String host); + + /// No description provided for @settingsApps. + /// + /// In en, this message translates to: + /// **'Apps'** + String get settingsApps; + + /// No description provided for @settingsExport. + /// + /// In en, this message translates to: + /// **'Export settings'** + String get settingsExport; + + /// No description provided for @settingsImport. + /// + /// In en, this message translates to: + /// **'Import settings'** + String get settingsImport; + + /// No description provided for @settingsImportWrongFileExtension. + /// + /// In en, this message translates to: + /// **'Settings import has wrong file extension (has to be .json.base64)'** + String get settingsImportWrongFileExtension; + + /// No description provided for @optionsCategoryGeneral. + /// + /// In en, this message translates to: + /// **'General'** + String get optionsCategoryGeneral; + + /// No description provided for @optionsCategoryTheme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get optionsCategoryTheme; + + /// No description provided for @optionsCategoryOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get optionsCategoryOther; + + /// No description provided for @optionsCategoryAccounts. + /// + /// In en, this message translates to: + /// **'Accounts'** + String get optionsCategoryAccounts; + + /// No description provided for @optionsCategoryStartup. + /// + /// In en, this message translates to: + /// **'Startup'** + String get optionsCategoryStartup; + + /// No description provided for @optionsCategorySystemTray. + /// + /// In en, this message translates to: + /// **'System tray'** + String get optionsCategorySystemTray; + + /// No description provided for @optionsSortOrderAscending. + /// + /// In en, this message translates to: + /// **'Ascending'** + String get optionsSortOrderAscending; + + /// No description provided for @optionsSortOrderDescending. + /// + /// In en, this message translates to: + /// **'Descending'** + String get optionsSortOrderDescending; + + /// No description provided for @globalOptionsThemeMode. + /// + /// In en, this message translates to: + /// **'Theme mode'** + String get globalOptionsThemeMode; + + /// No description provided for @globalOptionsThemeModeLight. + /// + /// In en, this message translates to: + /// **'Light'** + String get globalOptionsThemeModeLight; + + /// No description provided for @globalOptionsThemeModeDark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get globalOptionsThemeModeDark; + + /// No description provided for @globalOptionsThemeModeAutomatic. + /// + /// In en, this message translates to: + /// **'Automatic'** + String get globalOptionsThemeModeAutomatic; + + /// No description provided for @globalOptionsThemeOLEDAsDark. + /// + /// In en, this message translates to: + /// **'OLED theme as dark theme'** + String get globalOptionsThemeOLEDAsDark; + + /// No description provided for @globalOptionsStartupMinimized. + /// + /// In en, this message translates to: + /// **'Start minimized'** + String get globalOptionsStartupMinimized; + + /// No description provided for @globalOptionsStartupMinimizeInsteadOfExit. + /// + /// In en, this message translates to: + /// **'Minimize instead of exit'** + String get globalOptionsStartupMinimizeInsteadOfExit; + + /// No description provided for @globalOptionsSystemTrayEnabled. + /// + /// In en, this message translates to: + /// **'Enable system tray'** + String get globalOptionsSystemTrayEnabled; + + /// No description provided for @globalOptionsSystemTrayHideToTrayWhenMinimized. + /// + /// In en, this message translates to: + /// **'Hide to system tray when minimized'** + String get globalOptionsSystemTrayHideToTrayWhenMinimized; + + /// No description provided for @globalOptionsAccountsRememberLastUsedAccount. + /// + /// In en, this message translates to: + /// **'Remember last used account'** + String get globalOptionsAccountsRememberLastUsedAccount; + + /// No description provided for @globalOptionsAccountsRemoveConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to remove the account {name} from {url}?'** + String globalOptionsAccountsRemoveConfirm(String name, String url); + + /// No description provided for @globalOptionsAccountsAdd. + /// + /// In en, this message translates to: + /// **'Add account'** + String get globalOptionsAccountsAdd; + + /// No description provided for @accountOptionsInitialApp. + /// + /// In en, this message translates to: + /// **'App to show initially'** + String get accountOptionsInitialApp; + + /// No description provided for @accountOptionsAutomatic. + /// + /// In en, this message translates to: + /// **'Automatic'** + String get accountOptionsAutomatic; + + /// No description provided for @licenses. + /// + /// In en, this message translates to: + /// **'Licenses'** + String get licenses; + + /// No description provided for @filesName. + /// + /// In en, this message translates to: + /// **'Files'** + String get filesName; + + /// No description provided for @filesUploadFiles. + /// + /// In en, this message translates to: + /// **'Upload files'** + String get filesUploadFiles; + + /// No description provided for @filesUploadImages. + /// + /// In en, this message translates to: + /// **'Upload images'** + String get filesUploadImages; + + /// No description provided for @filesCreateFolder. + /// + /// In en, this message translates to: + /// **'Create folder'** + String get filesCreateFolder; + + /// No description provided for @filesFolderName. + /// + /// In en, this message translates to: + /// **'Folder name'** + String get filesFolderName; + + /// No description provided for @filesRenameFolder. + /// + /// In en, this message translates to: + /// **'Rename folder'** + String get filesRenameFolder; + + /// No description provided for @filesRenameFile. + /// + /// In en, this message translates to: + /// **'Rename file'** + String get filesRenameFile; + + /// No description provided for @filesDetails. + /// + /// In en, this message translates to: + /// **'Details'** + String get filesDetails; + + /// No description provided for @filesDetailsFileName. + /// + /// In en, this message translates to: + /// **'File name'** + String get filesDetailsFileName; + + /// No description provided for @filesDetailsFolderName. + /// + /// In en, this message translates to: + /// **'Folder name'** + String get filesDetailsFolderName; + + /// No description provided for @filesDetailsParentFolder. + /// + /// In en, this message translates to: + /// **'Parent folder'** + String get filesDetailsParentFolder; + + /// No description provided for @filesDetailsFileSize. + /// + /// In en, this message translates to: + /// **'File size'** + String get filesDetailsFileSize; + + /// No description provided for @filesDetailsFolderSize. + /// + /// In en, this message translates to: + /// **'Folder size'** + String get filesDetailsFolderSize; + + /// No description provided for @filesDetailsLastModified. + /// + /// In en, this message translates to: + /// **'Last modified'** + String get filesDetailsLastModified; + + /// No description provided for @filesDetailsIsFavorite. + /// + /// In en, this message translates to: + /// **'Is favorite'** + String get filesDetailsIsFavorite; + + /// No description provided for @filesSync. + /// + /// In en, this message translates to: + /// **'Sync'** + String get filesSync; + + /// No description provided for @filesDeleteFileConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the file \'{name}\'?'** + String filesDeleteFileConfirm(String name); + + /// No description provided for @filesDeleteFolderConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the folder \'{name}\'?'** + String filesDeleteFolderConfirm(String name); + + /// No description provided for @filesChooseFolder. + /// + /// In en, this message translates to: + /// **'Choose folder'** + String get filesChooseFolder; + + /// No description provided for @filesAddToFavorites. + /// + /// In en, this message translates to: + /// **'Add to favorites'** + String get filesAddToFavorites; + + /// No description provided for @filesRemoveFromFavorites. + /// + /// In en, this message translates to: + /// **'Remove from favorites'** + String get filesRemoveFromFavorites; + + /// No description provided for @filesOptionsShowPreviews. + /// + /// In en, this message translates to: + /// **'Show previews for files'** + String get filesOptionsShowPreviews; + + /// No description provided for @filesOptionsUploadQueueParallelism. + /// + /// In en, this message translates to: + /// **'Upload queue parallelism'** + String get filesOptionsUploadQueueParallelism; + + /// No description provided for @filesOptionsDownloadQueueParallelism. + /// + /// In en, this message translates to: + /// **'Download queue parallelism'** + String get filesOptionsDownloadQueueParallelism; + + /// No description provided for @newsName. + /// + /// In en, this message translates to: + /// **'News'** + String get newsName; + + /// No description provided for @newsAddFeed. + /// + /// In en, this message translates to: + /// **'Add feed'** + String get newsAddFeed; + + /// No description provided for @newsFolder. + /// + /// In en, this message translates to: + /// **'Folder'** + String get newsFolder; + + /// No description provided for @newsFolderRoot. + /// + /// In en, this message translates to: + /// **'Root Folder'** + String get newsFolderRoot; + + /// No description provided for @newsCreateFolder. + /// + /// In en, this message translates to: + /// **'Create folder'** + String get newsCreateFolder; + + /// No description provided for @newsCreateFolderName. + /// + /// In en, this message translates to: + /// **'Folder name'** + String get newsCreateFolderName; + + /// No description provided for @newsDeleteFolderConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the folder \'{name}\'?'** + String newsDeleteFolderConfirm(String name); + + /// No description provided for @newsRenameFolder. + /// + /// In en, this message translates to: + /// **'Rename folder'** + String get newsRenameFolder; + + /// No description provided for @newsRemoveFeedConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to remove the feed \'{name}\'?'** + String newsRemoveFeedConfirm(String name); + + /// No description provided for @newsMoveFeed. + /// + /// In en, this message translates to: + /// **'Move feed'** + String get newsMoveFeed; + + /// No description provided for @newsRenameFeed. + /// + /// In en, this message translates to: + /// **'Rename feed'** + String get newsRenameFeed; + + /// No description provided for @newsArticles. + /// + /// In en, this message translates to: + /// **'Articles'** + String get newsArticles; + + /// No description provided for @newsFolders. + /// + /// In en, this message translates to: + /// **'Folders'** + String get newsFolders; + + /// No description provided for @newsFeeds. + /// + /// In en, this message translates to: + /// **'Feeds'** + String get newsFeeds; + + /// No description provided for @newsFilterAll. + /// + /// In en, this message translates to: + /// **'All'** + String get newsFilterAll; + + /// No description provided for @newsFilterUnread. + /// + /// In en, this message translates to: + /// **'Unread'** + String get newsFilterUnread; + + /// No description provided for @newsFilterStarred. + /// + /// In en, this message translates to: + /// **'Starred'** + String get newsFilterStarred; + + /// No description provided for @newsUnreadArticles. + /// + /// In en, this message translates to: + /// **'{count} unread'** + String newsUnreadArticles(int count); + + /// No description provided for @newsShowFeedURL. + /// + /// In en, this message translates to: + /// **'Show URL'** + String get newsShowFeedURL; + + /// No description provided for @newsCopyFeedURL. + /// + /// In en, this message translates to: + /// **'Copy URL'** + String get newsCopyFeedURL; + + /// No description provided for @newsCopiedFeedURL. + /// + /// In en, this message translates to: + /// **'URL copied to clipboard'** + String get newsCopiedFeedURL; + + /// No description provided for @newsCopyFeedErrorMessage. + /// + /// In en, this message translates to: + /// **'Copy error message'** + String get newsCopyFeedErrorMessage; + + /// No description provided for @newsCopiedFeedErrorMessage. + /// + /// In en, this message translates to: + /// **'Error message copied to clipboard'** + String get newsCopiedFeedErrorMessage; + + /// No description provided for @newsOptionsDefaultCategory. + /// + /// In en, this message translates to: + /// **'Category to show by default'** + String get newsOptionsDefaultCategory; + + /// No description provided for @newsOptionsArticleViewType. + /// + /// In en, this message translates to: + /// **'How to open article'** + String get newsOptionsArticleViewType; + + /// No description provided for @newsOptionsArticleViewTypeDirect. + /// + /// In en, this message translates to: + /// **'Show text directly'** + String get newsOptionsArticleViewTypeDirect; + + /// No description provided for @newsOptionsArticleViewTypeInternalBrowser. + /// + /// In en, this message translates to: + /// **'Open in internal browser'** + String get newsOptionsArticleViewTypeInternalBrowser; + + /// No description provided for @newsOptionsArticleViewTypeExternalBrowser. + /// + /// In en, this message translates to: + /// **'Open in external browser'** + String get newsOptionsArticleViewTypeExternalBrowser; + + /// No description provided for @newsOptionsDefaultArticlesFilter. + /// + /// In en, this message translates to: + /// **'Articles to show by default'** + String get newsOptionsDefaultArticlesFilter; + + /// No description provided for @newsOptionsArticlesSortProperty. + /// + /// In en, this message translates to: + /// **'How to sort articles'** + String get newsOptionsArticlesSortProperty; + + /// No description provided for @newsOptionsArticlesSortPropertyPublishDate. + /// + /// In en, this message translates to: + /// **'Publish date'** + String get newsOptionsArticlesSortPropertyPublishDate; + + /// No description provided for @newsOptionsArticlesSortPropertyAlphabetical. + /// + /// In en, this message translates to: + /// **'Alphabetical'** + String get newsOptionsArticlesSortPropertyAlphabetical; + + /// No description provided for @newsOptionsArticlesSortPropertyFeed. + /// + /// In en, this message translates to: + /// **'Feed'** + String get newsOptionsArticlesSortPropertyFeed; + + /// No description provided for @newsOptionsArticlesSortOrder. + /// + /// In en, this message translates to: + /// **'Sort order of articles'** + String get newsOptionsArticlesSortOrder; + + /// No description provided for @newsOptionsFeedsSortProperty. + /// + /// In en, this message translates to: + /// **'How to sort feeds'** + String get newsOptionsFeedsSortProperty; + + /// No description provided for @newsOptionsFeedsSortPropertyAlphabetical. + /// + /// In en, this message translates to: + /// **'Alphabetical'** + String get newsOptionsFeedsSortPropertyAlphabetical; + + /// No description provided for @newsOptionsFeedsSortPropertyUnreadCount. + /// + /// In en, this message translates to: + /// **'Unread count'** + String get newsOptionsFeedsSortPropertyUnreadCount; + + /// No description provided for @newsOptionsFeedsSortOrder. + /// + /// In en, this message translates to: + /// **'Sort order of feeds'** + String get newsOptionsFeedsSortOrder; + + /// No description provided for @newsOptionsFoldersSortProperty. + /// + /// In en, this message translates to: + /// **'How to sort folders'** + String get newsOptionsFoldersSortProperty; + + /// No description provided for @newsOptionsFoldersSortPropertyAlphabetical. + /// + /// In en, this message translates to: + /// **'Alphabetical'** + String get newsOptionsFoldersSortPropertyAlphabetical; + + /// No description provided for @newsOptionsFoldersSortPropertyUnreadCount. + /// + /// In en, this message translates to: + /// **'Unread count'** + String get newsOptionsFoldersSortPropertyUnreadCount; + + /// No description provided for @newsOptionsFoldersSortOrder. + /// + /// In en, this message translates to: + /// **'Sort order of folders'** + String get newsOptionsFoldersSortOrder; + + /// No description provided for @newsOptionsDefaultFolderViewType. + /// + /// In en, this message translates to: + /// **'What should be shown first when opening a folder'** + String get newsOptionsDefaultFolderViewType; + + /// No description provided for @notesName. + /// + /// In en, this message translates to: + /// **'Notes'** + String get notesName; + + /// No description provided for @notesNote. + /// + /// In en, this message translates to: + /// **'Note'** + String get notesNote; + + /// No description provided for @notesNotes. + /// + /// In en, this message translates to: + /// **'Notes'** + String get notesNotes; + + /// No description provided for @notesCategories. + /// + /// In en, this message translates to: + /// **'Categories'** + String get notesCategories; + + /// No description provided for @notesCreateNote. + /// + /// In en, this message translates to: + /// **'Create note'** + String get notesCreateNote; + + /// No description provided for @notesCategory. + /// + /// In en, this message translates to: + /// **'Category'** + String get notesCategory; + + /// No description provided for @notesChangeCategory. + /// + /// In en, this message translates to: + /// **'Change category'** + String get notesChangeCategory; + + /// No description provided for @notesSetCategory. + /// + /// In en, this message translates to: + /// **'Set category'** + String get notesSetCategory; + + /// No description provided for @notesNoteTitle. + /// + /// In en, this message translates to: + /// **'Title'** + String get notesNoteTitle; + + /// No description provided for @notesNoteChangedOnServer. + /// + /// In en, this message translates to: + /// **'The note has been changed on the server. Please refresh and try again'** + String get notesNoteChangedOnServer; + + /// No description provided for @notesNotesInCategory. + /// + /// In en, this message translates to: + /// **'{count} notes'** + String notesNotesInCategory(int count); + + /// No description provided for @notesUncategorized. + /// + /// In en, this message translates to: + /// **'Uncategorized'** + String get notesUncategorized; + + /// No description provided for @notesEdit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get notesEdit; + + /// No description provided for @notesPreview. + /// + /// In en, this message translates to: + /// **'Preview'** + String get notesPreview; + + /// No description provided for @notesDeleteNoteConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the note \'{name}\'?'** + String notesDeleteNoteConfirm(String name); + + /// No description provided for @notesOptionsDefaultCategory. + /// + /// In en, this message translates to: + /// **'Category to show by default'** + String get notesOptionsDefaultCategory; + + /// No description provided for @notesOptionsDefaultNoteViewType. + /// + /// In en, this message translates to: + /// **'How to show note'** + String get notesOptionsDefaultNoteViewType; + + /// No description provided for @notesOptionsDefaultNoteViewTypePreview. + /// + /// In en, this message translates to: + /// **'Preview'** + String get notesOptionsDefaultNoteViewTypePreview; + + /// No description provided for @notesOptionsDefaultNoteViewTypeEdit. + /// + /// In en, this message translates to: + /// **'Editor'** + String get notesOptionsDefaultNoteViewTypeEdit; + + /// No description provided for @notesOptionsNotesSortOrder. + /// + /// In en, this message translates to: + /// **'Sort order of notes'** + String get notesOptionsNotesSortOrder; + + /// No description provided for @notesOptionsNotesSortProperty. + /// + /// In en, this message translates to: + /// **'How to sort notes'** + String get notesOptionsNotesSortProperty; + + /// No description provided for @notesOptionsNotesSortPropertyLastModified. + /// + /// In en, this message translates to: + /// **'Last modified'** + String get notesOptionsNotesSortPropertyLastModified; + + /// No description provided for @notesOptionsNotesSortPropertyAlphabetical. + /// + /// In en, this message translates to: + /// **'Alphabetical'** + String get notesOptionsNotesSortPropertyAlphabetical; + + /// No description provided for @notesOptionsCategoriesSortOrder. + /// + /// In en, this message translates to: + /// **'Sort order of categories'** + String get notesOptionsCategoriesSortOrder; + + /// No description provided for @notesOptionsCategoriesSortProperty. + /// + /// In en, this message translates to: + /// **'How to sort categories'** + String get notesOptionsCategoriesSortProperty; + + /// No description provided for @notesOptionsCategoriesSortPropertyAlphabetical. + /// + /// In en, this message translates to: + /// **'Alphabetical'** + String get notesOptionsCategoriesSortPropertyAlphabetical; + + /// No description provided for @notesOptionsCategoriesSortPropertyNotesCount. + /// + /// In en, this message translates to: + /// **'Count of notes'** + String get notesOptionsCategoriesSortPropertyNotesCount; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError('AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/packages/harbour/lib/l10n/localizations_en.dart b/packages/harbour/lib/l10n/localizations_en.dart new file mode 100644 index 00000000..4bfce803 --- /dev/null +++ b/packages/harbour/lib/l10n/localizations_en.dart @@ -0,0 +1,489 @@ +import 'localizations.dart'; + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appName => 'Nextcloud Harbour'; + + @override + String get loginAccountAlreadyExists => 'The account you are trying to add already exists'; + + @override + String get loginAgain => 'Login again'; + + @override + String get loginOpenAgain => 'Open again'; + + @override + String get loginSwitchToBrowserWindow => 'Please switch to the browser window that just opened and proceed there'; + + @override + String get loginWorksWith => 'works with'; + + @override + String get errorCredentialsForAccountNoLongerMatch => 'The credentials for this account no longer match'; + + @override + String get errorServerHadAProblemProcessingYourRequest => + 'The server had a problem while processing your request. You might want to try again'; + + @override + String get errorSomethingWentWrongTryAgainLater => 'Something went wrong. Please try again later'; + + @override + String get errorUnableToReachServer => 'Unable to reach the server'; + + @override + String errorUnableToReachServerAt(String url) { + return 'Unable to reach the server at $url'; + } + + @override + String get errorConnectionTimedOut => 'Connection has timed out'; + + @override + String get errorNoCompatibleNextcloudAppsFound => + 'No compatible Nextcloud apps could be found.\nWe are working hard to implement more and more apps!'; + + @override + String get errorServerInMaintenanceMode => + 'The server is in maintenance mode. Please try again later or contact the server admin.'; + + @override + String errorMissingPermission(String name) { + return 'Permission for $name is missing'; + } + + @override + String get validatorEmptyField => 'This field can not be empty'; + + @override + String get validatorInvalidURL => 'Invalid URL provided'; + + @override + String get delete => 'Delete'; + + @override + String get remove => 'Remove'; + + @override + String get rename => 'Rename'; + + @override + String get move => 'Move'; + + @override + String get copy => 'Copy'; + + @override + String get yes => 'Yes'; + + @override + String get no => 'No'; + + @override + String get close => 'Close'; + + @override + String get retry => 'Retry'; + + @override + String get showSlashHide => 'Show/Hide'; + + @override + String get exit => 'Exit'; + + @override + String get settings => 'Settings'; + + @override + String settingsForApp(String name) { + return 'Settings - $name'; + } + + @override + String settingsForAccount(String username, String host) { + return 'Settings - $username@$host'; + } + + @override + String get settingsApps => 'Apps'; + + @override + String get settingsExport => 'Export settings'; + + @override + String get settingsImport => 'Import settings'; + + @override + String get settingsImportWrongFileExtension => 'Settings import has wrong file extension (has to be .json.base64)'; + + @override + String get optionsCategoryGeneral => 'General'; + + @override + String get optionsCategoryTheme => 'Theme'; + + @override + String get optionsCategoryOther => 'Other'; + + @override + String get optionsCategoryAccounts => 'Accounts'; + + @override + String get optionsCategoryStartup => 'Startup'; + + @override + String get optionsCategorySystemTray => 'System tray'; + + @override + String get optionsSortOrderAscending => 'Ascending'; + + @override + String get optionsSortOrderDescending => 'Descending'; + + @override + String get globalOptionsThemeMode => 'Theme mode'; + + @override + String get globalOptionsThemeModeLight => 'Light'; + + @override + String get globalOptionsThemeModeDark => 'Dark'; + + @override + String get globalOptionsThemeModeAutomatic => 'Automatic'; + + @override + String get globalOptionsThemeOLEDAsDark => 'OLED theme as dark theme'; + + @override + String get globalOptionsStartupMinimized => 'Start minimized'; + + @override + String get globalOptionsStartupMinimizeInsteadOfExit => 'Minimize instead of exit'; + + @override + String get globalOptionsSystemTrayEnabled => 'Enable system tray'; + + @override + String get globalOptionsSystemTrayHideToTrayWhenMinimized => 'Hide to system tray when minimized'; + + @override + String get globalOptionsAccountsRememberLastUsedAccount => 'Remember last used account'; + + @override + String globalOptionsAccountsRemoveConfirm(String name, String url) { + return 'Are you sure you want to remove the account $name from $url?'; + } + + @override + String get globalOptionsAccountsAdd => 'Add account'; + + @override + String get accountOptionsInitialApp => 'App to show initially'; + + @override + String get accountOptionsAutomatic => 'Automatic'; + + @override + String get licenses => 'Licenses'; + + @override + String get filesName => 'Files'; + + @override + String get filesUploadFiles => 'Upload files'; + + @override + String get filesUploadImages => 'Upload images'; + + @override + String get filesCreateFolder => 'Create folder'; + + @override + String get filesFolderName => 'Folder name'; + + @override + String get filesRenameFolder => 'Rename folder'; + + @override + String get filesRenameFile => 'Rename file'; + + @override + String get filesDetails => 'Details'; + + @override + String get filesDetailsFileName => 'File name'; + + @override + String get filesDetailsFolderName => 'Folder name'; + + @override + String get filesDetailsParentFolder => 'Parent folder'; + + @override + String get filesDetailsFileSize => 'File size'; + + @override + String get filesDetailsFolderSize => 'Folder size'; + + @override + String get filesDetailsLastModified => 'Last modified'; + + @override + String get filesDetailsIsFavorite => 'Is favorite'; + + @override + String get filesSync => 'Sync'; + + @override + String filesDeleteFileConfirm(String name) { + return 'Are you sure you want to delete the file \'$name\'?'; + } + + @override + String filesDeleteFolderConfirm(String name) { + return 'Are you sure you want to delete the folder \'$name\'?'; + } + + @override + String get filesChooseFolder => 'Choose folder'; + + @override + String get filesAddToFavorites => 'Add to favorites'; + + @override + String get filesRemoveFromFavorites => 'Remove from favorites'; + + @override + String get filesOptionsShowPreviews => 'Show previews for files'; + + @override + String get filesOptionsUploadQueueParallelism => 'Upload queue parallelism'; + + @override + String get filesOptionsDownloadQueueParallelism => 'Download queue parallelism'; + + @override + String get newsName => 'News'; + + @override + String get newsAddFeed => 'Add feed'; + + @override + String get newsFolder => 'Folder'; + + @override + String get newsFolderRoot => 'Root Folder'; + + @override + String get newsCreateFolder => 'Create folder'; + + @override + String get newsCreateFolderName => 'Folder name'; + + @override + String newsDeleteFolderConfirm(String name) { + return 'Are you sure you want to delete the folder \'$name\'?'; + } + + @override + String get newsRenameFolder => 'Rename folder'; + + @override + String newsRemoveFeedConfirm(String name) { + return 'Are you sure you want to remove the feed \'$name\'?'; + } + + @override + String get newsMoveFeed => 'Move feed'; + + @override + String get newsRenameFeed => 'Rename feed'; + + @override + String get newsArticles => 'Articles'; + + @override + String get newsFolders => 'Folders'; + + @override + String get newsFeeds => 'Feeds'; + + @override + String get newsFilterAll => 'All'; + + @override + String get newsFilterUnread => 'Unread'; + + @override + String get newsFilterStarred => 'Starred'; + + @override + String newsUnreadArticles(int count) { + return '$count unread'; + } + + @override + String get newsShowFeedURL => 'Show URL'; + + @override + String get newsCopyFeedURL => 'Copy URL'; + + @override + String get newsCopiedFeedURL => 'URL copied to clipboard'; + + @override + String get newsCopyFeedErrorMessage => 'Copy error message'; + + @override + String get newsCopiedFeedErrorMessage => 'Error message copied to clipboard'; + + @override + String get newsOptionsDefaultCategory => 'Category to show by default'; + + @override + String get newsOptionsArticleViewType => 'How to open article'; + + @override + String get newsOptionsArticleViewTypeDirect => 'Show text directly'; + + @override + String get newsOptionsArticleViewTypeInternalBrowser => 'Open in internal browser'; + + @override + String get newsOptionsArticleViewTypeExternalBrowser => 'Open in external browser'; + + @override + String get newsOptionsDefaultArticlesFilter => 'Articles to show by default'; + + @override + String get newsOptionsArticlesSortProperty => 'How to sort articles'; + + @override + String get newsOptionsArticlesSortPropertyPublishDate => 'Publish date'; + + @override + String get newsOptionsArticlesSortPropertyAlphabetical => 'Alphabetical'; + + @override + String get newsOptionsArticlesSortPropertyFeed => 'Feed'; + + @override + String get newsOptionsArticlesSortOrder => 'Sort order of articles'; + + @override + String get newsOptionsFeedsSortProperty => 'How to sort feeds'; + + @override + String get newsOptionsFeedsSortPropertyAlphabetical => 'Alphabetical'; + + @override + String get newsOptionsFeedsSortPropertyUnreadCount => 'Unread count'; + + @override + String get newsOptionsFeedsSortOrder => 'Sort order of feeds'; + + @override + String get newsOptionsFoldersSortProperty => 'How to sort folders'; + + @override + String get newsOptionsFoldersSortPropertyAlphabetical => 'Alphabetical'; + + @override + String get newsOptionsFoldersSortPropertyUnreadCount => 'Unread count'; + + @override + String get newsOptionsFoldersSortOrder => 'Sort order of folders'; + + @override + String get newsOptionsDefaultFolderViewType => 'What should be shown first when opening a folder'; + + @override + String get notesName => 'Notes'; + + @override + String get notesNote => 'Note'; + + @override + String get notesNotes => 'Notes'; + + @override + String get notesCategories => 'Categories'; + + @override + String get notesCreateNote => 'Create note'; + + @override + String get notesCategory => 'Category'; + + @override + String get notesChangeCategory => 'Change category'; + + @override + String get notesSetCategory => 'Set category'; + + @override + String get notesNoteTitle => 'Title'; + + @override + String get notesNoteChangedOnServer => 'The note has been changed on the server. Please refresh and try again'; + + @override + String notesNotesInCategory(int count) { + return '$count notes'; + } + + @override + String get notesUncategorized => 'Uncategorized'; + + @override + String get notesEdit => 'Edit'; + + @override + String get notesPreview => 'Preview'; + + @override + String notesDeleteNoteConfirm(String name) { + return 'Are you sure you want to delete the note \'$name\'?'; + } + + @override + String get notesOptionsDefaultCategory => 'Category to show by default'; + + @override + String get notesOptionsDefaultNoteViewType => 'How to show note'; + + @override + String get notesOptionsDefaultNoteViewTypePreview => 'Preview'; + + @override + String get notesOptionsDefaultNoteViewTypeEdit => 'Editor'; + + @override + String get notesOptionsNotesSortOrder => 'Sort order of notes'; + + @override + String get notesOptionsNotesSortProperty => 'How to sort notes'; + + @override + String get notesOptionsNotesSortPropertyLastModified => 'Last modified'; + + @override + String get notesOptionsNotesSortPropertyAlphabetical => 'Alphabetical'; + + @override + String get notesOptionsCategoriesSortOrder => 'Sort order of categories'; + + @override + String get notesOptionsCategoriesSortProperty => 'How to sort categories'; + + @override + String get notesOptionsCategoriesSortPropertyAlphabetical => 'Alphabetical'; + + @override + String get notesOptionsCategoriesSortPropertyNotesCount => 'Count of notes'; +} diff --git a/packages/harbour/lib/main.dart b/packages/harbour/lib/main.dart new file mode 100644 index 00000000..d04ca74b --- /dev/null +++ b/packages/harbour/lib/main.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:harbour/app.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future main() async { + Env? env; + try { + await dotenv.load(fileName: 'assets/.env'); + if (dotenv.env.keys.isNotEmpty) { + if (kReleaseMode) { + throw Exception('A release build can not contain a .env file'); + } + env = Env.fromMap(dotenv.env); + } + } catch (e) {} + + WidgetsFlutterBinding.ensureInitialized(); + + FlutterNativeSplash.preserve(widgetsBinding: WidgetsBinding.instance); + + final platform = getHarbourPlatform(); + + await platform.init?.call(); + + final sharedPreferences = await SharedPreferences.getInstance(); + + final requestManager = RequestManager(platform); + + final globalOptions = GlobalOptions( + Storage('global', sharedPreferences), + ); + + final accountsBloc = AccountsBloc( + requestManager, + Storage('accounts', sharedPreferences), + sharedPreferences, + globalOptions, + ); + + final allAppImplementations = [ + FilesApp(sharedPreferences, requestManager, platform), + NewsApp(sharedPreferences, requestManager, platform), + NotesApp(sharedPreferences, requestManager), + ]; + + runApp( + MultiProvider( + providers: [ + Provider( + create: (final _) => env, + ), + Provider( + create: (final _) => platform, + ), + Provider( + create: (final _) => globalOptions, + ), + Provider( + create: (final _) => requestManager, + ), + Provider( + create: (final _) => accountsBloc, + ), + Provider>( + create: (final _) => allAppImplementations, + ), + ], + child: const HarbourApp(), + ), + ); +} + +HarbourPlatform getHarbourPlatform() { + if (Platform.isAndroid) { + return AndroidHarbourPlatform(); + } + if (Platform.isLinux) { + return LinuxHarbourPlatform(); + } + + throw UnimplementedError('No implementation for platform ${Platform.operatingSystem} found'); +} diff --git a/packages/harbour/lib/src/apps/files/app.dart b/packages/harbour/lib/src/apps/files/app.dart new file mode 100644 index 00000000..bba39fb6 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/app.dart @@ -0,0 +1,56 @@ +library files; + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:file_icons/file_icons.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; +import 'package:harbour/src/apps/files/blocs/browser.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:intersperse/intersperse.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:settings/settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'dialogs/choose_create.dart'; +part 'dialogs/choose_folder.dart'; +part 'dialogs/create_folder.dart'; +part 'models/file_details.dart'; +part 'options.dart'; +part 'pages/details.dart'; +part 'pages/main.dart'; +part 'utils/download_task.dart'; +part 'utils/upload_task.dart'; +part 'widgets/browser_view.dart'; +part 'widgets/file_preview.dart'; + +class FilesApp extends AppImplementation { + FilesApp( + final SharedPreferences sharedPreferences, + final RequestManager requestManager, + final HarbourPlatform platform, + ) : super( + 'files', + (final context) => AppLocalizations.of(context).filesName, + sharedPreferences, + FilesAppSpecificOptions.new, + (final options, final client) => FilesBloc( + options, + requestManager, + client, + platform, + ), + (final context, final bloc) => FilesMainPage( + bloc: bloc, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/files/blocs/browser.dart b/packages/harbour/lib/src/apps/files/blocs/browser.dart new file mode 100644 index 00000000..55a1cd03 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/blocs/browser.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'browser.rxb.g.dart'; + +abstract class FilesBrowserBlocEvents { + void refresh(); + + void setPath(final List path); + + void createFolder(final List path); +} + +abstract class FilesBrowserBlocStates { + BehaviorSubject>> get files; + + BehaviorSubject> get path; + + Stream get errors; +} + +@RxBloc() +class FilesBrowserBloc extends $FilesBrowserBloc { + FilesBrowserBloc( + this.options, + this._requestManager, + this.client, + ) { + _$refreshEvent.listen((final _) => _loadFiles()); + + _$setPathEvent.listen((final path) { + _pathSubject.add(path); + _loadFiles(); + }); + + _$createFolderEvent.listen((final path) { + _wrapAction( + () async => client.webdav!.mkdir( + path.join('/'), + safe: false, + ), + ); + }); + + _loadFiles(); + } + + void _wrapAction(final Future Function() call) { + final stream = _requestManager.wrapWithoutCache(call).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) async { + refresh(); + }); + } + + void _loadFiles() { + _requestManager + .wrapWithoutCache( + () async => client.webdav!.ls( + _pathSubject.value.join('/'), + props: { + WebDavProps.davContentType.name, + WebDavProps.davETag.name, + WebDavProps.davLastModified.name, + WebDavProps.ncHasPreview.name, + WebDavProps.ocSize.name, + WebDavProps.ocFavorite.name, + }, + ), + ) + .listen(_filesSubject.add); + } + + final FilesAppSpecificOptions options; + final RequestManager _requestManager; + final NextcloudClient client; + + final _filesSubject = BehaviorSubject>>(); + final _pathSubject = BehaviorSubject>.seeded([]); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + // ignore: discarded_futures + _filesSubject.close(); + // ignore: discarded_futures + _pathSubject.close(); + // ignore: discarded_futures + _errorsStreamController.close(); + super.dispose(); + } + + @override + BehaviorSubject>> _mapToFilesState() => _filesSubject; + + @override + BehaviorSubject> _mapToPathState() => _pathSubject; + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} diff --git a/packages/harbour/lib/src/apps/files/blocs/browser.rxb.g.dart b/packages/harbour/lib/src/apps/files/blocs/browser.rxb.g.dart new file mode 100644 index 00000000..aea3b2c8 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/blocs/browser.rxb.g.dart @@ -0,0 +1,78 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'browser.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class FilesBrowserBlocType extends RxBlocTypeBase { + FilesBrowserBlocEvents get events; + FilesBrowserBlocStates get states; +} + +/// [$FilesBrowserBloc] extended by the [FilesBrowserBloc] +/// {@nodoc} +abstract class $FilesBrowserBloc extends RxBlocBase + implements FilesBrowserBlocEvents, FilesBrowserBlocStates, FilesBrowserBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [setPath] + final _$setPathEvent = PublishSubject>(); + + /// Тhe [Subject] where events sink to by calling [createFolder] + final _$createFolderEvent = PublishSubject>(); + + /// The state of [files] implemented in [_mapToFilesState] + late final BehaviorSubject>> _filesState = _mapToFilesState(); + + /// The state of [path] implemented in [_mapToPathState] + late final BehaviorSubject> _pathState = _mapToPathState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void refresh() => _$refreshEvent.add(null); + + @override + void setPath(List path) => _$setPathEvent.add(path); + + @override + void createFolder(List path) => _$createFolderEvent.add(path); + + @override + BehaviorSubject>> get files => _filesState; + + @override + BehaviorSubject> get path => _pathState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject>> _mapToFilesState(); + + BehaviorSubject> _mapToPathState(); + + Stream _mapToErrorsState(); + + @override + FilesBrowserBlocEvents get events => this; + + @override + FilesBrowserBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$setPathEvent.close(); + _$createFolderEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/apps/files/blocs/files.dart b/packages/harbour/lib/src/apps/files/blocs/files.dart new file mode 100644 index 00000000..c1fb7e7a --- /dev/null +++ b/packages/harbour/lib/src/apps/files/blocs/files.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:harbour/src/apps/files/app.dart'; +import 'package:harbour/src/apps/files/blocs/browser.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:open_file/open_file.dart'; +import 'package:path/path.dart' as p; +import 'package:queue/queue.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'files.rxb.g.dart'; + +abstract class FilesBlocEvents { + void refresh(); + + void uploadFile(final List path, final String localPath); + + void syncFile(final List path); + + void openFile(final List path, final String etag, final String? mimeType); + + void delete(final List path); + + void rename(final List path, final String name); + + void move(final List path, final List destination); + + void copy(final List path, final List destination); + + void addFavorite(final List path); + + void removeFavorite(final List path); +} + +abstract class FilesBlocStates { + BehaviorSubject> get uploadTasks; + + BehaviorSubject> get downloadTasks; + + Stream get errors; +} + +@RxBloc() +class FilesBloc extends $FilesBloc { + FilesBloc( + this.options, + this._requestManager, + this.client, + this._platform, + ) { + _$refreshEvent.listen((final _) => browser.refresh()); + + _$uploadFileEvent.listen((final event) { + _wrapAction( + true, + () async { + final file = File(event.localPath); + // ignore: avoid_slow_async_io + final stat = await file.stat(); + final task = UploadTask( + path: event.path, + size: stat.size, + lastModified: stat.modified, + ); + _uploadTasksSubject.add(_uploadTasksSubject.value..add(task)); + await _uploadQueue.add(() => task.execute(client, file.openRead())); + _uploadTasksSubject.add(_uploadTasksSubject.value..removeWhere((final t) => t == task)); + }, + ); + }); + + _$syncFileEvent.listen((final path) { + final stream = _requestManager.wrapWithoutCache( + () async { + final file = File( + p.join( + await _platform.getUserAccessibleAppDataPath(), + '${client.username!}@${Uri.parse(client.baseURL).host}', + 'files', + path.join(Platform.pathSeparator), + ), + ); + if (!file.parent.existsSync()) { + file.parent.createSync(recursive: true); + } + return _downloadFile(path, file); + }, + disableTimeout: true, + ).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + }); + + _$openFileEvent.listen((final event) { + _wrapAction( + true, + () async { + final file = File( + p.join( + await _platform.getApplicationCachePath(), + 'files', + event.etag.replaceAll('"', ''), + event.path.last, + ), + ); + if (!file.existsSync()) { + debugPrint('Downloading ${event.path.join('/')} since it does not exist'); + if (!file.parent.existsSync()) { + await file.parent.create(recursive: true); + } + await _downloadFile(event.path, file); + } + await OpenFile.open(file.path, type: event.mimeType); + }, + ); + }); + + _$deleteEvent.listen((final path) { + _wrapAction(false, () async => client.webdav!.delete(path.join('/'))); + }); + + _$renameEvent.listen((final event) { + _wrapAction( + false, + () async => client.webdav!.move( + event.path.join('/'), + (event.path.sublist(0, event.path.length - 1)..add(event.name)).join('/'), + ), + ); + }); + + _$moveEvent.listen((final event) { + _wrapAction( + false, + () async => client.webdav!.move( + event.path.join('/'), + event.destination.join('/'), + ), + ); + }); + + _$copyEvent.listen((final event) { + _wrapAction( + false, + () async => client.webdav!.copy( + event.path.join('/'), + event.destination.join('/'), + ), + ); + }); + + _$addFavoriteEvent.listen((final path) { + _wrapAction( + false, + () async => client.webdav!.updateProps( + path.join('/'), + {WebDavProps.ocFavorite.name: '1'}, + ), + ); + }); + + _$removeFavoriteEvent.listen((final path) { + _wrapAction( + false, + () async => client.webdav!.updateProps( + path.join('/'), + {WebDavProps.ocFavorite.name: '0'}, + ), + ); + }); + + options.uploadQueueParallelism.stream.listen((final value) { + _uploadQueue.parallel = value; + }); + options.downloadQueueParallelism.stream.listen((final value) { + _downloadQueue.parallel = value; + }); + } + + Future _downloadFile( + final List path, + final File file, + ) async { + final sink = file.openWrite(); + try { + final task = DownloadTask( + path: path, + ); + _downloadTasksSubject.add(_downloadTasksSubject.value..add(task)); + await _downloadQueue.add(() => task.execute(client, sink)); + _downloadTasksSubject.add(_downloadTasksSubject.value..removeWhere((final t) => t == task)); + await sink.close(); + } catch (e) { + await sink.close(); + rethrow; + } + } + + void _wrapAction(final bool disableTimeout, final Future Function() call) { + final stream = _requestManager.wrapWithoutCache(call, disableTimeout: disableTimeout).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) async { + browser.refresh(); + }); + } + + FilesBrowserBloc getNewFilesBrowserBloc() => FilesBrowserBloc(options, _requestManager, client); + + final FilesAppSpecificOptions options; + final RequestManager _requestManager; + final NextcloudClient client; + final HarbourPlatform _platform; + late final browser = getNewFilesBrowserBloc(); + + final _uploadQueue = Queue(); + final _downloadQueue = Queue(); + final _uploadTasksSubject = BehaviorSubject>.seeded([]); + final _downloadTasksSubject = BehaviorSubject>.seeded([]); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + _uploadQueue.dispose(); + _downloadQueue.dispose(); + // ignore: discarded_futures + _uploadTasksSubject.close(); + // ignore: discarded_futures + _downloadTasksSubject.close(); + // ignore: discarded_futures + _errorsStreamController.close(); + super.dispose(); + } + + @override + BehaviorSubject> _mapToUploadTasksState() => _uploadTasksSubject; + + @override + BehaviorSubject> _mapToDownloadTasksState() => _downloadTasksSubject; + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} diff --git a/packages/harbour/lib/src/apps/files/blocs/files.rxb.g.dart b/packages/harbour/lib/src/apps/files/blocs/files.rxb.g.dart new file mode 100644 index 00000000..f3687cd5 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/blocs/files.rxb.g.dart @@ -0,0 +1,179 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'files.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class FilesBlocType extends RxBlocTypeBase { + FilesBlocEvents get events; + FilesBlocStates get states; +} + +/// [$FilesBloc] extended by the [FilesBloc] +/// {@nodoc} +abstract class $FilesBloc extends RxBlocBase implements FilesBlocEvents, FilesBlocStates, FilesBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [uploadFile] + final _$uploadFileEvent = PublishSubject<_UploadFileEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [syncFile] + final _$syncFileEvent = PublishSubject>(); + + /// Тhe [Subject] where events sink to by calling [openFile] + final _$openFileEvent = PublishSubject<_OpenFileEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [delete] + final _$deleteEvent = PublishSubject>(); + + /// Тhe [Subject] where events sink to by calling [rename] + final _$renameEvent = PublishSubject<_RenameEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [move] + final _$moveEvent = PublishSubject<_MoveEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [copy] + final _$copyEvent = PublishSubject<_CopyEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [addFavorite] + final _$addFavoriteEvent = PublishSubject>(); + + /// Тhe [Subject] where events sink to by calling [removeFavorite] + final _$removeFavoriteEvent = PublishSubject>(); + + /// The state of [uploadTasks] implemented in [_mapToUploadTasksState] + late final BehaviorSubject> _uploadTasksState = _mapToUploadTasksState(); + + /// The state of [downloadTasks] implemented in [_mapToDownloadTasksState] + late final BehaviorSubject> _downloadTasksState = _mapToDownloadTasksState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void refresh() => _$refreshEvent.add(null); + + @override + void uploadFile(List path, String localPath) => _$uploadFileEvent.add(_UploadFileEventArgs(path, localPath)); + + @override + void syncFile(List path) => _$syncFileEvent.add(path); + + @override + void openFile(List path, String etag, String? mimeType) => + _$openFileEvent.add(_OpenFileEventArgs(path, etag, mimeType)); + + @override + void delete(List path) => _$deleteEvent.add(path); + + @override + void rename(List path, String name) => _$renameEvent.add(_RenameEventArgs(path, name)); + + @override + void move(List path, List destination) => _$moveEvent.add(_MoveEventArgs(path, destination)); + + @override + void copy(List path, List destination) => _$copyEvent.add(_CopyEventArgs(path, destination)); + + @override + void addFavorite(List path) => _$addFavoriteEvent.add(path); + + @override + void removeFavorite(List path) => _$removeFavoriteEvent.add(path); + + @override + BehaviorSubject> get uploadTasks => _uploadTasksState; + + @override + BehaviorSubject> get downloadTasks => _downloadTasksState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject> _mapToUploadTasksState(); + + BehaviorSubject> _mapToDownloadTasksState(); + + Stream _mapToErrorsState(); + + @override + FilesBlocEvents get events => this; + + @override + FilesBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$uploadFileEvent.close(); + _$syncFileEvent.close(); + _$openFileEvent.close(); + _$deleteEvent.close(); + _$renameEvent.close(); + _$moveEvent.close(); + _$copyEvent.close(); + _$addFavoriteEvent.close(); + _$removeFavoriteEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} + +/// Helps providing the arguments in the [Subject.add] for +/// [FilesBlocEvents.uploadFile] event +class _UploadFileEventArgs { + const _UploadFileEventArgs(this.path, this.localPath); + + final List path; + + final String localPath; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [FilesBlocEvents.openFile] event +class _OpenFileEventArgs { + const _OpenFileEventArgs(this.path, this.etag, this.mimeType); + + final List path; + + final String etag; + + final String? mimeType; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [FilesBlocEvents.rename] event +class _RenameEventArgs { + const _RenameEventArgs(this.path, this.name); + + final List path; + + final String name; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [FilesBlocEvents.move] event +class _MoveEventArgs { + const _MoveEventArgs(this.path, this.destination); + + final List path; + + final List destination; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [FilesBlocEvents.copy] event +class _CopyEventArgs { + const _CopyEventArgs(this.path, this.destination); + + final List path; + + final List destination; +} diff --git a/packages/harbour/lib/src/apps/files/dialogs/choose_create.dart b/packages/harbour/lib/src/apps/files/dialogs/choose_create.dart new file mode 100644 index 00000000..8e5eff0c --- /dev/null +++ b/packages/harbour/lib/src/apps/files/dialogs/choose_create.dart @@ -0,0 +1,72 @@ +part of '../app.dart'; + +class FilesChooseCreateDialog extends StatelessWidget { + const FilesChooseCreateDialog({ + required this.bloc, + required this.basePath, + super.key, + }); + + final FilesBloc bloc; + final List basePath; + + Future upload(final FileType type) async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: type, + ); + if (result != null) { + for (final file in result.files) { + bloc.uploadFile([...basePath, file.name], file.path!); + } + } + } + + @override + Widget build(final BuildContext context) => CustomDialog( + children: [ + ListTile( + leading: Icon( + MdiIcons.filePlus, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).filesUploadFiles), + onTap: () async { + Navigator.of(context).pop(); + + await upload(FileType.any); + }, + ), + ListTile( + leading: Icon( + MdiIcons.fileImagePlus, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).filesUploadImages), + onTap: () async { + Navigator.of(context).pop(); + + await upload(FileType.image); + }, + ), + ListTile( + leading: Icon( + MdiIcons.folderPlus, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).filesCreateFolder), + onTap: () async { + Navigator.of(context).pop(); + + final result = await showDialog>( + context: context, + builder: (final context) => const FilesCreateFolderDialog(), + ); + if (result != null) { + bloc.browser.createFolder([...basePath, ...result]); + } + }, + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/files/dialogs/choose_folder.dart b/packages/harbour/lib/src/apps/files/dialogs/choose_folder.dart new file mode 100644 index 00000000..6302c5ee --- /dev/null +++ b/packages/harbour/lib/src/apps/files/dialogs/choose_folder.dart @@ -0,0 +1,68 @@ +part of '../app.dart'; + +class FilesChooseFolderDialog extends StatelessWidget { + const FilesChooseFolderDialog({ + required this.bloc, + required this.filesBloc, + required this.originalPath, + super.key, + }); + + final FilesBrowserBloc bloc; + final FilesBloc filesBloc; + + final List originalPath; + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(AppLocalizations.of(context).filesChooseFolder), + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: double.maxFinite, + child: Column( + children: [ + Expanded( + child: FilesBrowserView( + bloc: bloc, + filesBloc: filesBloc, + enableFileActions: false, + enableCreateActions: false, + onlyShowDirectories: true, + ), + ), + StreamBuilder>( + stream: bloc.path, + builder: (final context, final pathSnapshot) => pathSnapshot.hasData + ? Container( + margin: const EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () async { + final result = await showDialog>( + context: context, + builder: (final context) => const FilesCreateFolderDialog(), + ); + if (result != null) { + bloc.createFolder([...pathSnapshot.data!, ...result]); + } + }, + child: Text(AppLocalizations.of(context).filesCreateFolder), + ), + ElevatedButton( + onPressed: !(const ListEquality().equals(originalPath, pathSnapshot.data!)) + ? () => Navigator.of(context).pop(pathSnapshot.data!) + : null, + child: Text(AppLocalizations.of(context).filesChooseFolder), + ), + ], + ), + ) + : Container(), + ), + ], + ), + ), + ); +} diff --git a/packages/harbour/lib/src/apps/files/dialogs/create_folder.dart b/packages/harbour/lib/src/apps/files/dialogs/create_folder.dart new file mode 100644 index 00000000..e11af74e --- /dev/null +++ b/packages/harbour/lib/src/apps/files/dialogs/create_folder.dart @@ -0,0 +1,52 @@ +part of '../app.dart'; + +class FilesCreateFolderDialog extends StatefulWidget { + const FilesCreateFolderDialog({ + super.key, + }); + + @override + State createState() => _FilesCreateFolderDialogState(); +} + +class _FilesCreateFolderDialogState extends State { + final formKey = GlobalKey(); + + final controller = TextEditingController(); + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(controller.text.split('/')); + } + } + + @override + Widget build(final BuildContext context) => CustomDialog( + title: Text(AppLocalizations.of(context).filesCreateFolder), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextFormField( + controller: controller, + decoration: InputDecoration( + hintText: AppLocalizations.of(context).filesFolderName, + ), + autofocus: true, + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).filesCreateFolder), + ), + ], + ), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/files/models/file_details.dart b/packages/harbour/lib/src/apps/files/models/file_details.dart new file mode 100644 index 00000000..167f3471 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/models/file_details.dart @@ -0,0 +1,32 @@ +part of '../app.dart'; + +class FileDetails { + FileDetails({ + required this.path, + required this.isDirectory, + required this.size, + required this.etag, + required this.mimeType, + required this.lastModified, + required this.hasPreview, + required this.isFavorite, + }); + + String get name => path.last; + + final List path; + + final bool isDirectory; + + final int size; + + final String? etag; + + final String? mimeType; + + final DateTime lastModified; + + final bool? hasPreview; + + final bool? isFavorite; +} diff --git a/packages/harbour/lib/src/apps/files/options.dart b/packages/harbour/lib/src/apps/files/options.dart new file mode 100644 index 00000000..1696fe92 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/options.dart @@ -0,0 +1,52 @@ +part of 'app.dart'; + +class FilesAppSpecificOptions extends NextcloudAppSpecificOptions { + FilesAppSpecificOptions(super.storage) { + super.categories = [ + generalCategory, + ]; + super.options = [ + showPreviewsOption, + uploadQueueParallelism, + downloadQueueParallelism, + ]; + } + + final generalCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).optionsCategoryGeneral, + ); + + late final showPreviewsOption = ToggleOption( + storage: super.storage, + category: generalCategory, + key: 'show-previews', + label: (final context) => AppLocalizations.of(context).filesOptionsShowPreviews, + defaultValue: BehaviorSubject.seeded(true), + ); + + late final uploadQueueParallelism = SelectOption( + storage: super.storage, + category: generalCategory, + key: 'upload-queue-parallelism', + label: (final context) => AppLocalizations.of(context).filesOptionsUploadQueueParallelism, + defaultValue: BehaviorSubject.seeded(4), + values: BehaviorSubject.seeded({ + for (var i = 1; i <= 16; i = i * 2) ...{ + i: (final _) => i.toString(), + }, + }), + ); + + late final downloadQueueParallelism = SelectOption( + storage: super.storage, + category: generalCategory, + key: 'download-queue-parallelism', + label: (final context) => AppLocalizations.of(context).filesOptionsDownloadQueueParallelism, + defaultValue: BehaviorSubject.seeded(4), + values: BehaviorSubject.seeded({ + for (var i = 1; i <= 16; i = i * 2) ...{ + i: (final _) => i.toString(), + }, + }), + ); +} diff --git a/packages/harbour/lib/src/apps/files/pages/details.dart b/packages/harbour/lib/src/apps/files/pages/details.dart new file mode 100644 index 00000000..383af2ca --- /dev/null +++ b/packages/harbour/lib/src/apps/files/pages/details.dart @@ -0,0 +1,66 @@ +part of '../app.dart'; + +class FilesDetailsPage extends StatelessWidget { + const FilesDetailsPage({ + required this.bloc, + required this.details, + super.key, + }); + + final FilesBloc bloc; + final FileDetails details; + + @override + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(details.name), + ), + body: ListView( + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.primary, + child: FilePreview( + bloc: bloc, + details: details, + color: Theme.of(context).colorScheme.onPrimary, + width: MediaQuery.of(context).size.width.toInt(), + height: MediaQuery.of(context).size.height ~/ 4, + ), + ), + DataTable( + headingRowHeight: 0, + columns: [ + DataColumn(label: Container()), + DataColumn(label: Container()), + ], + rows: [ + for (final entry in { + details.isDirectory + ? AppLocalizations.of(context).filesDetailsFolderName + : AppLocalizations.of(context).filesDetailsFileName: details.name, + AppLocalizations.of(context).filesDetailsParentFolder: + details.path.length == 1 ? '/' : details.path.sublist(0, details.path.length - 1).join('/'), + details.isDirectory + ? AppLocalizations.of(context).filesDetailsFolderSize + : AppLocalizations.of(context).filesDetailsFileSize: filesize(details.size, 1), + AppLocalizations.of(context).filesDetailsLastModified: + details.lastModified.toLocal().toIso8601String(), + if (details.isFavorite != null) ...{ + AppLocalizations.of(context).filesDetailsIsFavorite: + details.isFavorite! ? AppLocalizations.of(context).yes : AppLocalizations.of(context).no, + }, + }.entries) ...[ + DataRow( + cells: [ + DataCell(Text(entry.key)), + DataCell(Text(entry.value)), + ], + ), + ], + ], + ), + ], + ), + ); +} diff --git a/packages/harbour/lib/src/apps/files/pages/main.dart b/packages/harbour/lib/src/apps/files/pages/main.dart new file mode 100644 index 00000000..9ed78141 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/pages/main.dart @@ -0,0 +1,33 @@ +part of '../app.dart'; + +class FilesMainPage extends StatefulWidget { + const FilesMainPage({ + required this.bloc, + super.key, + }); + + final FilesBloc bloc; + + @override + State createState() => _FilesMainPageState(); +} + +class _FilesMainPageState extends State { + @override + void initState() { + super.initState(); + + widget.bloc.errors.listen((final error) { + ExceptionWidget.showSnackbar(context, error); + }); + } + + @override + Widget build(BuildContext context) => FilesBrowserView( + bloc: widget.bloc.browser, + filesBloc: widget.bloc, + onPickFile: (final details) { + widget.bloc.openFile(details.path, details.etag!, details.mimeType); + }, + ); +} diff --git a/packages/harbour/lib/src/apps/files/utils/download_task.dart b/packages/harbour/lib/src/apps/files/utils/download_task.dart new file mode 100644 index 00000000..e0921af3 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/utils/download_task.dart @@ -0,0 +1,32 @@ +part of '../app.dart'; + +class DownloadTask { + DownloadTask({ + required this.path, + }); + + final List path; + + final _streamController = StreamController(); + late final progress = _streamController.stream.asBroadcastStream(); + + Future execute(final NextcloudClient client, final IOSink sink) async { + final completer = Completer(); + + final response = await client.webdav!.downloadStream(path.join('/')); + var downloaded = 0; + + response.listen((final chunk) async { + sink.add(chunk); + + downloaded += chunk.length; + _streamController.add((downloaded / response.contentLength * 100).toInt()); + + if (downloaded >= response.contentLength) { + completer.complete(); + } + }); + + return completer.future; + } +} diff --git a/packages/harbour/lib/src/apps/files/utils/upload_task.dart b/packages/harbour/lib/src/apps/files/utils/upload_task.dart new file mode 100644 index 00000000..9040381b --- /dev/null +++ b/packages/harbour/lib/src/apps/files/utils/upload_task.dart @@ -0,0 +1,29 @@ +part of '../app.dart'; + +class UploadTask { + UploadTask({ + required this.path, + required this.size, + required this.lastModified, + }); + + final List path; + final int size; + final DateTime lastModified; + + final _streamController = StreamController(); + late final progress = _streamController.stream.asBroadcastStream(); + + Future execute(final NextcloudClient client, final Stream> stream) async { + var uploaded = 0; + await client.webdav!.uploadStream( + stream.map((final chunk) { + uploaded += chunk.length; + _streamController.add((uploaded / size * 100).toInt()); + + return Uint8List.fromList(chunk); + }), + path.join('/'), + ); + } +} diff --git a/packages/harbour/lib/src/apps/files/widgets/browser_view.dart b/packages/harbour/lib/src/apps/files/widgets/browser_view.dart new file mode 100644 index 00000000..84ed7248 --- /dev/null +++ b/packages/harbour/lib/src/apps/files/widgets/browser_view.dart @@ -0,0 +1,458 @@ +part of '../app.dart'; + +class FilesBrowserView extends StatefulWidget { + const FilesBrowserView({ + required this.bloc, + required this.filesBloc, + this.onPickFile, + this.enableFileActions = true, + this.enableCreateActions = true, + this.onlyShowDirectories = false, + super.key, + // ignore: prefer_asserts_with_message + }) : assert((onPickFile == null) == onlyShowDirectories); + + final FilesBrowserBloc bloc; + final FilesBloc filesBloc; + final Function(FileDetails)? onPickFile; + final bool enableFileActions; + final bool enableCreateActions; + final bool onlyShowDirectories; + + @override + State createState() => _FilesBrowserViewState(); +} + +class _FilesBrowserViewState extends State { + @override + void initState() { + super.initState(); + + widget.bloc.errors.listen((final error) { + ExceptionWidget.showSnackbar(context, error); + }); + } + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: widget.bloc, + state: (final bloc) => bloc.files, + builder: ( + final context, + final filesData, + final filesError, + final filesLoading, + final _, + ) => + StreamBuilder>( + stream: widget.bloc.path, + builder: ( + final context, + final pathSnapshot, + ) => + StreamBuilder>( + stream: widget.filesBloc.uploadTasks, + builder: ( + final context, + final uploadTasksSnapshot, + ) => + StreamBuilder>( + stream: widget.filesBloc.downloadTasks, + builder: ( + final context, + final downloadTasksSnapshot, + ) => + !pathSnapshot.hasData || !uploadTasksSnapshot.hasData || !downloadTasksSnapshot.hasData + ? Container() + : Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: widget.enableCreateActions + ? FloatingActionButton( + onPressed: () async { + await showDialog( + context: context, + builder: (final context) => FilesChooseCreateDialog( + bloc: widget.filesBloc, + basePath: widget.bloc.path.value, + ), + ); + }, + child: const Icon(Icons.add), + ) + : null, + body: RefreshIndicator( + onRefresh: () async { + widget.bloc.refresh(); + }, + child: Column( + children: [ + ExceptionWidget( + filesError, + onRetry: () { + widget.bloc.refresh(); + }, + ), + CustomLinearProgressIndicator( + visible: filesLoading, + ), + Align( + alignment: Alignment.topLeft, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + height: 40, + child: InkWell( + onTap: () { + widget.bloc.setPath([]); + }, + child: const Icon(Icons.house), + ), + ), + for (var i = 0; i < pathSnapshot.data!.length; i++) ...[ + InkWell( + onTap: () { + widget.bloc.setPath(pathSnapshot.data!.sublist(0, i + 1)); + }, + child: Text(pathSnapshot.data![i]), + ), + ], + ] + .intersperse( + const Icon( + Icons.keyboard_arrow_right, + size: 40, + ), + ) + .toList(), + ), + ), + ), + if (filesData != null) ...[ + Builder( + builder: (final context) { + final uploadTasksWithoutExistingFile = uploadTasksSnapshot.data!.where( + (final task) => filesData + .where((final file) => _pathMatchesFile(task.path, file.name)) + .isEmpty, + ); + final widgets = [ + for (final uploadTask in uploadTasksWithoutExistingFile) ...[ + StreamBuilder( + stream: uploadTask.progress, + builder: (final context, final uploadTaskProgressSnapshot) => + !uploadTaskProgressSnapshot.hasData + ? Container() + : _buildFile( + context: context, + details: FileDetails( + path: uploadTask.path, + isDirectory: false, + size: uploadTask.size, + etag: null, + mimeType: null, + lastModified: uploadTask.lastModified, + hasPreview: null, + isFavorite: null, + ), + uploadProgress: uploadTaskProgressSnapshot.data!, + downloadProgress: null, + ), + ), + ], + for (final file in filesData) ...[ + if (!widget.onlyShowDirectories || file.isDirectory) ...[ + Builder( + builder: (final context) { + final matchingUploadTasks = uploadTasksSnapshot.data! + .where((final task) => _pathMatchesFile(task.path, file.name)); + final matchingDownloadTasks = downloadTasksSnapshot.data! + .where((final task) => _pathMatchesFile(task.path, file.name)); + + return StreamBuilder( + stream: matchingUploadTasks.isNotEmpty + ? matchingUploadTasks.first.progress + : Stream.value(null), + builder: (final context, final uploadTaskProgressSnapshot) => + StreamBuilder( + stream: matchingDownloadTasks.isNotEmpty + ? matchingDownloadTasks.first.progress + : Stream.value(null), + builder: (final context, final downloadTaskProgressSnapshot) => + _buildFile( + context: context, + details: FileDetails( + path: [...widget.bloc.path.value, file.name], + isDirectory: matchingUploadTasks.isEmpty && file.isDirectory, + size: matchingUploadTasks.isNotEmpty + ? matchingUploadTasks.first.size + : file.size!, + etag: matchingUploadTasks.isNotEmpty ? null : file.etag, + mimeType: matchingUploadTasks.isNotEmpty ? null : file.mimeType, + lastModified: matchingUploadTasks.isNotEmpty + ? matchingUploadTasks.first.lastModified + : file.lastModified!, + hasPreview: + matchingUploadTasks.isNotEmpty ? null : file.hasPreview, + isFavorite: + matchingUploadTasks.isNotEmpty ? null : file.favorite, + ), + uploadProgress: uploadTaskProgressSnapshot.data, + downloadProgress: downloadTaskProgressSnapshot.data, + ), + ), + ); + }, + ), + ], + ], + ]; + + return Expanded( + child: CustomListView( + scrollKey: 'files-${pathSnapshot.data!.join('/')}', + withFloatingActionButton: true, + items: widgets, + builder: (final context, final widget) => widget, + ), + ); + }, + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ), + ), + ), + ), + ); + + bool _pathMatchesFile(final List path, final String name) => const ListEquality().equals( + [...widget.bloc.path.value, name], + path, + ); + + Widget _buildFile({ + required final BuildContext context, + required final FileDetails details, + required final int? uploadProgress, + required final int? downloadProgress, + }) => + ListTile( + // When the ETag is null it means we are uploading this file right now + onTap: details.isDirectory || details.etag != null + ? () async { + if (details.isDirectory) { + widget.bloc.setPath(details.path); + } else { + if (widget.onPickFile != null) { + widget.onPickFile!.call(details); + } + } + } + : null, + title: Text( + details.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + Text(CustomTimeAgo.format(details.lastModified)), + if (details.size > 0) ...[ + const SizedBox( + width: 10, + ), + Text( + filesize(details.size, 1), + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.grey, + ), + ), + ], + ], + ), + leading: SizedBox( + height: 40, + width: 40, + child: Stack( + children: [ + Center( + child: uploadProgress != null || downloadProgress != null + ? Column( + children: [ + Icon( + uploadProgress != null ? MdiIcons.upload : MdiIcons.download, + color: Theme.of(context).colorScheme.primary, + ), + LinearProgressIndicator( + value: (uploadProgress ?? downloadProgress)! / 100, + ), + ], + ) + : FilePreview( + bloc: widget.filesBloc, + details: details, + ), + ), + if (details.isFavorite ?? false) ...[ + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.star, + size: 14, + color: Colors.yellow, + ), + ), + ], + ], + ), + ), + trailing: uploadProgress == null && downloadProgress == null && widget.enableFileActions + ? PopupMenuButton<_FileAction>( + itemBuilder: (final context) => [ + if (details.isFavorite != null) ...[ + PopupMenuItem( + value: _FileAction.toggleFavorite, + child: Text( + details.isFavorite! + ? AppLocalizations.of(context).filesRemoveFromFavorites + : AppLocalizations.of(context).filesAddToFavorites, + ), + ), + ], + PopupMenuItem( + value: _FileAction.details, + child: Text(AppLocalizations.of(context).filesDetails), + ), + PopupMenuItem( + value: _FileAction.rename, + child: Text(AppLocalizations.of(context).rename), + ), + PopupMenuItem( + value: _FileAction.move, + child: Text(AppLocalizations.of(context).move), + ), + PopupMenuItem( + value: _FileAction.copy, + child: Text(AppLocalizations.of(context).copy), + ), + // TODO: https://github.com/jld3103/nextcloud-harbour/issues/4 + if (!details.isDirectory) ...[ + PopupMenuItem( + value: _FileAction.sync, + child: Text(AppLocalizations.of(context).filesSync), + ), + ], + PopupMenuItem( + value: _FileAction.delete, + child: Text(AppLocalizations.of(context).delete), + ), + ], + onSelected: (final action) async { + switch (action) { + case _FileAction.toggleFavorite: + if (details.isFavorite ?? false) { + widget.filesBloc.removeFavorite(details.path); + } else { + widget.filesBloc.addFavorite(details.path); + } + break; + case _FileAction.details: + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => FilesDetailsPage( + bloc: widget.filesBloc, + details: details, + ), + ), + ); + break; + case _FileAction.rename: + final result = await showRenameDialog( + context: context, + title: details.isDirectory + ? AppLocalizations.of(context).filesRenameFolder + : AppLocalizations.of(context).filesRenameFile, + value: details.name, + ); + if (result != null) { + widget.filesBloc.rename(details.path, result); + } + break; + case _FileAction.move: + final b = widget.filesBloc.getNewFilesBrowserBloc(); + final originalPath = details.path.sublist(0, details.path.length - 1); + b.setPath(originalPath); + final result = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: widget.filesBloc, + originalPath: originalPath, + ), + ); + b.dispose(); + if (result != null) { + widget.filesBloc.move(details.path, result..add(details.name)); + } + break; + case _FileAction.copy: + final b = widget.filesBloc.getNewFilesBrowserBloc(); + final originalPath = details.path.sublist(0, details.path.length - 1); + b.setPath(originalPath); + final result = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: widget.filesBloc, + originalPath: originalPath, + ), + ); + b.dispose(); + if (result != null) { + widget.filesBloc.copy(details.path, result..add(details.name)); + } + break; + case _FileAction.sync: + widget.filesBloc.syncFile(details.path); + break; + case _FileAction.delete: + if (await showConfirmationDialog( + context, + details.isDirectory + ? AppLocalizations.of(context).filesDeleteFolderConfirm(details.name) + : AppLocalizations.of(context).filesDeleteFileConfirm(details.name), + )) { + widget.filesBloc.delete(details.path); + } + break; + } + }, + ) + : const SizedBox( + width: 48, + height: 48, + ), + ); +} + +enum _FileAction { + toggleFavorite, + details, + rename, + move, + copy, + sync, + delete, +} diff --git a/packages/harbour/lib/src/apps/files/widgets/file_preview.dart b/packages/harbour/lib/src/apps/files/widgets/file_preview.dart new file mode 100644 index 00000000..52f8750b --- /dev/null +++ b/packages/harbour/lib/src/apps/files/widgets/file_preview.dart @@ -0,0 +1,106 @@ +part of '../app.dart'; + +class FilePreview extends StatelessWidget { + const FilePreview({ + required this.bloc, + required this.details, + this.width = 40, + this.height = 40, + this.color, + super.key, + }); + + final FilesBloc bloc; + final FileDetails details; + final int width; + final int height; + final Color? color; + + @override + Widget build(BuildContext context) { + final color = this.color ?? Theme.of(context).colorScheme.primary; + return SizedBox( + width: width.toDouble(), + height: height.toDouble(), + child: StreamBuilder( + stream: bloc.options.showPreviewsOption.stream, + builder: (final context, final showPreviewsSnapshot) { + if (!showPreviewsSnapshot.hasData) { + return Container(); + } + if (showPreviewsSnapshot.data! && (details.hasPreview ?? false) && details.etag != null) { + return Builder( + builder: (final context) { + final account = RxBlocProvider.of(context).activeAccount.value; + if (account == null) { + return Container(); + } + + final stream = Provider.of(context).wrapBytes( + account.client.id, + 'files-preview-${details.etag}-$width-$height', + () async => (await account.client.core.getPreviewBytes( + details.path.join('/'), + width: width, + height: height, + ))!, + preferCache: true, + ); + + return ResultStreamBuilder( + stream: stream, + builder: ( + final context, + final previewData, + final previewError, + final previewLoading, + ) => + Stack( + children: [ + if (previewData != null) ...[ + Center( + child: Image.memory(previewData), + ), + ], + if (previewError != null) ...[ + Center( + child: Icon( + Icons.error_outline, + size: min(width.toDouble(), height.toDouble()), + color: color, + ), + ), + ], + if (previewLoading) ...[ + Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ), + ], + ], + ), + ); + }, + ); + } + + if (details.isDirectory) { + return Icon( + MdiIcons.folder, + color: color, + size: min(width.toDouble(), height.toDouble()), + ); + } + + return FileIcon( + details.name, + color: color, + size: min(width.toDouble(), height.toDouble()), + ); + }, + ), + ); + } +} diff --git a/packages/harbour/lib/src/apps/news/app.dart b/packages/harbour/lib/src/apps/news/app.dart new file mode 100644 index 00000000..6564168d --- /dev/null +++ b/packages/harbour/lib/src/apps/news/app.dart @@ -0,0 +1,64 @@ +library news; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:html/dom.dart' as html_dom; +import 'package:html/parser.dart' as html_parser; +import 'package:intersperse/intersperse.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:settings/settings.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sort_box/sort_box.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:wakelock/wakelock.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +part 'dialogs/add_feed.dart'; +part 'dialogs/create_folder.dart'; +part 'dialogs/feed_show_url.dart'; +part 'dialogs/feed_update_error.dart'; +part 'dialogs/move_feed.dart'; +part 'options.dart'; +part 'pages/article.dart'; +part 'pages/feed.dart'; +part 'pages/folder.dart'; +part 'pages/main.dart'; +part 'sort/articles.dart'; +part 'sort/feeds.dart'; +part 'sort/folders.dart'; +part 'widgets/articles_view.dart'; +part 'widgets/feed_icon.dart'; +part 'widgets/feeds_view.dart'; +part 'widgets/folder_select.dart'; +part 'widgets/folder_view.dart'; +part 'widgets/folders_view.dart'; + +class NewsApp extends AppImplementation { + NewsApp( + final SharedPreferences sharedPreferences, + final RequestManager requestManager, + final HarbourPlatform platform, + ) : super( + 'news', + (final context) => AppLocalizations.of(context).newsName, + sharedPreferences, + (final storage) => NewsAppSpecificOptions(storage, platform), + (final options, final client) => NewsBloc( + options, + requestManager, + client, + ), + (final context, final bloc) => NewsMainPage( + bloc: bloc, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/blocs/articles.dart b/packages/harbour/lib/src/apps/news/blocs/articles.dart new file mode 100644 index 00000000..1ee0919c --- /dev/null +++ b/packages/harbour/lib/src/apps/news/blocs/articles.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'articles.rxb.g.dart'; + +enum FilterType { + all, + unread, + starred, +} + +enum ListType { + feed, + folder, +} + +abstract class NewsArticlesBlocEvents { + void refresh(); + + void setFilterType(final FilterType type); + + void markArticleAsRead(final NewsArticle article); + + void markArticleAsUnread(final NewsArticle article); + + void starArticle(final NewsArticle article); + + void unstarArticle(final NewsArticle article); +} + +abstract class NewsArticlesBlocStates { + BehaviorSubject>> get articles; + + BehaviorSubject get filterType; + + Stream get articleUpdate; + + Stream get errors; +} + +@RxBloc() +class NewsArticlesBloc extends $NewsArticlesBloc { + NewsArticlesBloc( + this.newsBloc, { + required this.isMainArticlesBloc, + this.id, + this.listType, + }) { + var filterType = newsBloc.options.defaultArticlesFilterOption.value; + if (listType != null && filterType == FilterType.starred) { + filterType = FilterType.all; + } + _filterTypeSubject = BehaviorSubject.seeded(filterType); + + _$refreshEvent.listen((final _) { + loadArticles(); + refreshNewsBloc(); + }); + + _$setFilterTypeEvent.listen((final filterType) { + _filterTypeSubject.add(filterType); + loadArticles(); + }); + + _$markArticleAsReadEvent.listen((final article) { + _wrapArticleAction((final client) async { + await client.news.markArticleAsRead(article.id!); + _articleUpdateController.add(article..unread = false); + }); + }); + + _$markArticleAsUnreadEvent.listen((final article) { + _wrapArticleAction((final client) async { + await client.news.markArticleAsUnread(article.id!); + _articleUpdateController.add(article..unread = true); + }); + }); + + _$starArticleEvent.listen((final article) { + _wrapArticleAction((final client) async { + await client.news.starArticle(article.feedId!, article.guidHash!); + _articleUpdateController.add(article..starred = true); + }); + }); + + _$unstarArticleEvent.listen((final article) { + _wrapArticleAction((final client) async { + await client.news.unstarArticle(article.feedId!, article.guidHash!); + _articleUpdateController.add(article..starred = false); + }); + }); + + loadArticles(); + } + + void _wrapArticleAction(final Future Function(NextcloudClient client) call) { + final stream = newsBloc.requestManager.wrapWithoutCache(() async => call(newsBloc.client)).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) async { + loadArticles(); + refreshNewsBloc(); + }); + } + + void loadArticles() { + // The API for pagination is pretty useless in this case sadly. So no pagination for us :( + // https://github.com/nextcloud/news/blob/master/docs/api/api-v1-2.md#get-items + + // https://github.com/nextcloud/news/blob/48ee5ce4d135da20079961a62ae37958d6a6b628/lib/Db/ListType.php#L21 + late final int type; + bool? getRead; + if (listType != null) { + switch (_filterTypeSubject.value) { + case FilterType.all: + break; + case FilterType.unread: + getRead = false; + break; + default: + throw Exception('FilterType ${_filterTypeSubject.value} not allowed'); + } + } + switch (listType) { + case ListType.feed: + type = 0; + break; + case ListType.folder: + type = 1; + break; + case null: + switch (_filterTypeSubject.value) { + case FilterType.starred: + type = 2; + break; + case FilterType.all: + type = 3; + break; + case FilterType.unread: + type = 6; + break; + } + break; + } + + newsBloc.requestManager + .wrapNextcloud, NewsListArticles, void, NextcloudNewsClient>( + newsBloc.client.id, + newsBloc.client.news, + 'news-articles-$type-$id-$getRead', + () async => (await newsBloc.client.news.listArticles( + type: type, + id: id, + getRead: getRead, + ))!, + (final response) => response.items, + previousData: _articlesSubject.hasValue ? _articlesSubject.value.data : null, + ) + .listen(_articlesSubject.add); + } + + void refreshNewsBloc() { + newsBloc.refresh( + mainArticlesToo: !isMainArticlesBloc, + ); + } + + final NewsBloc newsBloc; + final bool isMainArticlesBloc; + final int? id; + final ListType? listType; + + final _articlesSubject = BehaviorSubject>>(); + late final BehaviorSubject _filterTypeSubject; + final _articleUpdateController = StreamController(); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + // ignore: discarded_futures + _articlesSubject.close(); + // ignore: discarded_futures + _filterTypeSubject.close(); + // ignore: discarded_futures + _articleUpdateController.close(); + // ignore: discarded_futures + _errorsStreamController.close(); + super.dispose(); + } + + @override + BehaviorSubject>> _mapToArticlesState() => _articlesSubject; + + @override + BehaviorSubject _mapToFilterTypeState() => _filterTypeSubject; + + @override + Stream _mapToArticleUpdateState() => _articleUpdateController.stream.asBroadcastStream(); + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} diff --git a/packages/harbour/lib/src/apps/news/blocs/articles.rxb.g.dart b/packages/harbour/lib/src/apps/news/blocs/articles.rxb.g.dart new file mode 100644 index 00000000..e3184e4a --- /dev/null +++ b/packages/harbour/lib/src/apps/news/blocs/articles.rxb.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'articles.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class NewsArticlesBlocType extends RxBlocTypeBase { + NewsArticlesBlocEvents get events; + NewsArticlesBlocStates get states; +} + +/// [$NewsArticlesBloc] extended by the [NewsArticlesBloc] +/// {@nodoc} +abstract class $NewsArticlesBloc extends RxBlocBase + implements NewsArticlesBlocEvents, NewsArticlesBlocStates, NewsArticlesBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [setFilterType] + final _$setFilterTypeEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [markArticleAsRead] + final _$markArticleAsReadEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [markArticleAsUnread] + final _$markArticleAsUnreadEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [starArticle] + final _$starArticleEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [unstarArticle] + final _$unstarArticleEvent = PublishSubject(); + + /// The state of [articles] implemented in [_mapToArticlesState] + late final BehaviorSubject>> _articlesState = _mapToArticlesState(); + + /// The state of [filterType] implemented in [_mapToFilterTypeState] + late final BehaviorSubject _filterTypeState = _mapToFilterTypeState(); + + /// The state of [articleUpdate] implemented in [_mapToArticleUpdateState] + late final Stream _articleUpdateState = _mapToArticleUpdateState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void refresh() => _$refreshEvent.add(null); + + @override + void setFilterType(FilterType type) => _$setFilterTypeEvent.add(type); + + @override + void markArticleAsRead(NewsArticle article) => _$markArticleAsReadEvent.add(article); + + @override + void markArticleAsUnread(NewsArticle article) => _$markArticleAsUnreadEvent.add(article); + + @override + void starArticle(NewsArticle article) => _$starArticleEvent.add(article); + + @override + void unstarArticle(NewsArticle article) => _$unstarArticleEvent.add(article); + + @override + BehaviorSubject>> get articles => _articlesState; + + @override + BehaviorSubject get filterType => _filterTypeState; + + @override + Stream get articleUpdate => _articleUpdateState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject>> _mapToArticlesState(); + + BehaviorSubject _mapToFilterTypeState(); + + Stream _mapToArticleUpdateState(); + + Stream _mapToErrorsState(); + + @override + NewsArticlesBlocEvents get events => this; + + @override + NewsArticlesBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$setFilterTypeEvent.close(); + _$markArticleAsReadEvent.close(); + _$markArticleAsUnreadEvent.close(); + _$starArticleEvent.close(); + _$unstarArticleEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/apps/news/blocs/news.dart b/packages/harbour/lib/src/apps/news/blocs/news.dart new file mode 100644 index 00000000..6a74945c --- /dev/null +++ b/packages/harbour/lib/src/apps/news/blocs/news.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'news.rxb.g.dart'; + +abstract class NewsBlocEvents { + void refresh({required final bool mainArticlesToo}); + + void addFeed(final String url, final int? folderID); + + void removeFeed(final int feedID); + + void renameFeed(final int feedID, final String name); + + void moveFeed(final int feedID, final int? folderID); + + void markFeedAsRead(final int feedID); + + void createFolder(final String name); + + void deleteFolder(final int folderID); + + void renameFolder(final int folderID, final String name); + + void markFolderAsRead(final int folderID); +} + +abstract class NewsBlocStates { + BehaviorSubject>> get folders; + + BehaviorSubject>> get feeds; + + Stream get errors; +} + +@RxBloc() +class NewsBloc extends $NewsBloc { + NewsBloc( + this.options, + this.requestManager, + this.client, + ) { + _$refreshEvent.listen(_loadAll); + + _$addFeedEvent.listen((final event) { + _wrapFeedAction( + (final client) async => client.news.addFeed( + NewsAddFeed( + url: event.url, + folderId: event.folderID, + ), + ), + ); + }); + + _$removeFeedEvent.listen((final feedID) { + _wrapFeedAction((final client) async => client.news.deleteFeed(feedID)); + }); + + _$renameFeedEvent.listen((final event) { + _wrapFeedAction( + (final client) async => client.news.renameFeed( + event.feedID, + NewsRenameFeed( + feedTitle: event.name, + ), + ), + ); + }); + + _$moveFeedEvent.listen((final event) { + final stream = requestManager + .wrapWithoutCache( + () async => client.news.moveFeed( + event.feedID, + NewsMoveFeed( + folderId: event.folderID, + ), + ), + ) + .asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) { + _loadFeeds(); + _loadFolders(); + }); + }); + + _$markFeedAsReadEvent.listen((final feedID) { + final stream = requestManager + .wrapWithoutCache( + () async => client.news.markFeedAsRead( + feedID, + NewsMarkAsRead( + newestItemId: _newestItemId, + ), + ), + ) + .asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) { + _loadAll(true); + }); + }); + + _$createFolderEvent.listen((final name) { + _wrapFolderAction((final client) async => client.news.createFolder(NewsCreateFolder(name: name))); + }); + + _$deleteFolderEvent.listen((final folderID) { + _wrapFolderAction((final client) async => client.news.deleteFolder(folderID)); + }); + + _$renameFolderEvent.listen((final event) { + _wrapFolderAction( + (final client) async => client.news.renameFolder( + event.folderID, + NewsRenameFolder( + name: event.name, + ), + ), + ); + }); + + _$markFolderAsReadEvent.listen((final folderID) { + final stream = requestManager + .wrapWithoutCache( + () async => client.news.markFolderAsRead( + folderID, + NewsMarkAsRead( + newestItemId: _newestItemId, + ), + ), + ) + .asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) { + _loadAll(true); + }); + }); + + _loadAll(false); + } + + void _wrapFolderAction(final Future Function(NextcloudClient client) call) { + final stream = requestManager + .wrapWithoutCache( + () async => call(client), + ) + .asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) { + _loadFolders(); + }); + } + + void _wrapFeedAction(final Future Function(NextcloudClient client) call) { + final stream = requestManager + .wrapWithoutCache( + () async => call(client), + ) + .asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) { + _loadFeeds(); + mainArticlesBloc.loadArticles(); + }); + } + + void _loadAll(final bool mainArticlesToo) { + if (mainArticlesToo) { + mainArticlesBloc.loadArticles(); + } + _loadFolders(); + _loadFeeds(); + } + + void _loadFolders() { + requestManager + .wrapNextcloud, NewsListFolders, void, NextcloudNewsClient>( + client.id, + client.news, + 'news-folders', + () async => (await client.news.listFolders())!, + (final response) => response.folders, + previousData: _foldersSubject.hasValue ? _foldersSubject.value.data : null, + ) + .listen(_foldersSubject.add); + } + + void _loadFeeds() { + requestManager.wrapNextcloud, NewsListFeeds, void, NextcloudNewsClient>( + client.id, + client.news, + 'news-feeds', + () async => (await client.news.listFeeds())!, + (final response) { + // This is a bit ugly, but IDGAF right now + _newestItemId = response.newestItemId; + return response.feeds; + }, + previousData: _feedsSubject.hasValue ? _feedsSubject.value.data : null, + ).listen(_feedsSubject.add); + } + + final NewsAppSpecificOptions options; + final RequestManager requestManager; + final NextcloudClient client; + late final mainArticlesBloc = NewsArticlesBloc( + this, + isMainArticlesBloc: true, + ); + + int? _newestItemId; + + final _foldersSubject = BehaviorSubject>>(); + final _feedsSubject = BehaviorSubject>>(); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + // ignore: discarded_futures + _foldersSubject.close(); + // ignore: discarded_futures + _feedsSubject.close(); + // ignore: discarded_futures + _errorsStreamController.close(); + super.dispose(); + } + + @override + BehaviorSubject>> _mapToFeedsState() => _feedsSubject; + + @override + BehaviorSubject>> _mapToFoldersState() => _foldersSubject; + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} diff --git a/packages/harbour/lib/src/apps/news/blocs/news.rxb.g.dart b/packages/harbour/lib/src/apps/news/blocs/news.rxb.g.dart new file mode 100644 index 00000000..ec39268d --- /dev/null +++ b/packages/harbour/lib/src/apps/news/blocs/news.rxb.g.dart @@ -0,0 +1,166 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'news.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class NewsBlocType extends RxBlocTypeBase { + NewsBlocEvents get events; + NewsBlocStates get states; +} + +/// [$NewsBloc] extended by the [NewsBloc] +/// {@nodoc} +abstract class $NewsBloc extends RxBlocBase implements NewsBlocEvents, NewsBlocStates, NewsBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [addFeed] + final _$addFeedEvent = PublishSubject<_AddFeedEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [removeFeed] + final _$removeFeedEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [renameFeed] + final _$renameFeedEvent = PublishSubject<_RenameFeedEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [moveFeed] + final _$moveFeedEvent = PublishSubject<_MoveFeedEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [markFeedAsRead] + final _$markFeedAsReadEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [createFolder] + final _$createFolderEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [deleteFolder] + final _$deleteFolderEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [renameFolder] + final _$renameFolderEvent = PublishSubject<_RenameFolderEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [markFolderAsRead] + final _$markFolderAsReadEvent = PublishSubject(); + + /// The state of [folders] implemented in [_mapToFoldersState] + late final BehaviorSubject>> _foldersState = _mapToFoldersState(); + + /// The state of [feeds] implemented in [_mapToFeedsState] + late final BehaviorSubject>> _feedsState = _mapToFeedsState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void refresh({required bool mainArticlesToo}) => _$refreshEvent.add(mainArticlesToo); + + @override + void addFeed(String url, int? folderID) => _$addFeedEvent.add(_AddFeedEventArgs(url, folderID)); + + @override + void removeFeed(int feedID) => _$removeFeedEvent.add(feedID); + + @override + void renameFeed(int feedID, String name) => _$renameFeedEvent.add(_RenameFeedEventArgs(feedID, name)); + + @override + void moveFeed(int feedID, int? folderID) => _$moveFeedEvent.add(_MoveFeedEventArgs(feedID, folderID)); + + @override + void markFeedAsRead(int feedID) => _$markFeedAsReadEvent.add(feedID); + + @override + void createFolder(String name) => _$createFolderEvent.add(name); + + @override + void deleteFolder(int folderID) => _$deleteFolderEvent.add(folderID); + + @override + void renameFolder(int folderID, String name) => _$renameFolderEvent.add(_RenameFolderEventArgs(folderID, name)); + + @override + void markFolderAsRead(int folderID) => _$markFolderAsReadEvent.add(folderID); + + @override + BehaviorSubject>> get folders => _foldersState; + + @override + BehaviorSubject>> get feeds => _feedsState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject>> _mapToFoldersState(); + + BehaviorSubject>> _mapToFeedsState(); + + Stream _mapToErrorsState(); + + @override + NewsBlocEvents get events => this; + + @override + NewsBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$addFeedEvent.close(); + _$removeFeedEvent.close(); + _$renameFeedEvent.close(); + _$moveFeedEvent.close(); + _$markFeedAsReadEvent.close(); + _$createFolderEvent.close(); + _$deleteFolderEvent.close(); + _$renameFolderEvent.close(); + _$markFolderAsReadEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NewsBlocEvents.addFeed] event +class _AddFeedEventArgs { + const _AddFeedEventArgs(this.url, this.folderID); + + final String url; + + final int? folderID; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NewsBlocEvents.renameFeed] event +class _RenameFeedEventArgs { + const _RenameFeedEventArgs(this.feedID, this.name); + + final int feedID; + + final String name; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NewsBlocEvents.moveFeed] event +class _MoveFeedEventArgs { + const _MoveFeedEventArgs(this.feedID, this.folderID); + + final int feedID; + + final int? folderID; +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NewsBlocEvents.renameFolder] event +class _RenameFolderEventArgs { + const _RenameFolderEventArgs(this.folderID, this.name); + + final int folderID; + + final String name; +} diff --git a/packages/harbour/lib/src/apps/news/dialogs/add_feed.dart b/packages/harbour/lib/src/apps/news/dialogs/add_feed.dart new file mode 100644 index 00000000..1081b76c --- /dev/null +++ b/packages/harbour/lib/src/apps/news/dialogs/add_feed.dart @@ -0,0 +1,97 @@ +part of '../app.dart'; + +class NewsAddFeedDialog extends StatefulWidget { + const NewsAddFeedDialog({ + required this.bloc, + this.folderID, + super.key, + }); + + final NewsBloc bloc; + final int? folderID; + + @override + State createState() => _NewsAddFeedDialogState(); +} + +class _NewsAddFeedDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + + NewsFolder? folder; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop([controller.text, widget.folderID ?? folder?.id]); + } + } + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: widget.bloc, + state: (final bloc) => bloc.folders, + builder: ( + final context, + final foldersData, + final foldersError, + final foldersLoading, + final _, + ) => + CustomDialog( + title: Text(AppLocalizations.of(context).newsAddFeed), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextFormField( + autofocus: true, + controller: controller, + decoration: const InputDecoration( + hintText: 'https://...', + ), + validator: (final input) => validateHttpUrl(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + if (widget.folderID == null) ...[ + Center( + child: ExceptionWidget( + foldersError, + onRetry: () { + widget.bloc.refresh( + mainArticlesToo: false, + ); + }, + ), + ), + Center( + child: CustomLinearProgressIndicator( + visible: foldersLoading, + ), + ), + if (foldersData != null) ...[ + NewsFolderSelect( + folders: foldersData, + value: folder, + onChanged: (final f) { + setState(() { + folder = f; + }); + }, + ), + ], + ], + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).newsAddFeed), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/dialogs/create_folder.dart b/packages/harbour/lib/src/apps/news/dialogs/create_folder.dart new file mode 100644 index 00000000..fe110b3a --- /dev/null +++ b/packages/harbour/lib/src/apps/news/dialogs/create_folder.dart @@ -0,0 +1,52 @@ +part of '../app.dart'; + +class NewsCreateFolderDialog extends StatefulWidget { + const NewsCreateFolderDialog({ + super.key, + }); + + @override + State createState() => _NewsCreateFolderDialogState(); +} + +class _NewsCreateFolderDialogState extends State { + final formKey = GlobalKey(); + + final controller = TextEditingController(); + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(controller.text); + } + } + + @override + Widget build(final BuildContext context) => CustomDialog( + title: Text(AppLocalizations.of(context).newsCreateFolder), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextFormField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + hintText: AppLocalizations.of(context).newsCreateFolderName, + ), + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).newsCreateFolder), + ), + ], + ), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/news/dialogs/feed_show_url.dart b/packages/harbour/lib/src/apps/news/dialogs/feed_show_url.dart new file mode 100644 index 00000000..27483d4f --- /dev/null +++ b/packages/harbour/lib/src/apps/news/dialogs/feed_show_url.dart @@ -0,0 +1,46 @@ +part of '../app.dart'; + +class NewsFeedShowURLDialog extends StatefulWidget { + const NewsFeedShowURLDialog({ + required this.feed, + super.key, + }); + + final NewsFeed feed; + + @override + State createState() => _NewsFeedShowURLDialogState(); +} + +class _NewsFeedShowURLDialogState extends State { + @override + Widget build(final BuildContext context) => AlertDialog( + title: Text(widget.feed.url!), + actions: [ + ElevatedButton( + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: widget.feed.url!, + ), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).newsCopiedFeedURL), + ), + ); + Navigator.of(context).pop(); + } + }, + child: Text(AppLocalizations.of(context).newsCopyFeedURL), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).close), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/news/dialogs/feed_update_error.dart b/packages/harbour/lib/src/apps/news/dialogs/feed_update_error.dart new file mode 100644 index 00000000..ad46e60d --- /dev/null +++ b/packages/harbour/lib/src/apps/news/dialogs/feed_update_error.dart @@ -0,0 +1,46 @@ +part of '../app.dart'; + +class NewsFeedUpdateErrorDialog extends StatefulWidget { + const NewsFeedUpdateErrorDialog({ + required this.feed, + super.key, + }); + + final NewsFeed feed; + + @override + State createState() => _NewsFeedUpdateErrorDialogState(); +} + +class _NewsFeedUpdateErrorDialogState extends State { + @override + Widget build(final BuildContext context) => AlertDialog( + title: Text(widget.feed.lastUpdateError!), + actions: [ + ElevatedButton( + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: widget.feed.lastUpdateError!, + ), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).newsCopiedFeedErrorMessage), + ), + ); + Navigator.of(context).pop(); + } + }, + child: Text(AppLocalizations.of(context).newsCopyFeedErrorMessage), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).close), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/news/dialogs/move_feed.dart b/packages/harbour/lib/src/apps/news/dialogs/move_feed.dart new file mode 100644 index 00000000..406b59a4 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/dialogs/move_feed.dart @@ -0,0 +1,57 @@ +part of '../app.dart'; + +class NewsMoveFeedDialog extends StatefulWidget { + const NewsMoveFeedDialog({ + required this.folders, + required this.feed, + super.key, + }); + + final List folders; + final NewsFeed feed; + + @override + State createState() => _NewsMoveFeedDialogState(); +} + +class _NewsMoveFeedDialogState extends State { + final formKey = GlobalKey(); + + NewsFolder? folder; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop([folder?.id]); + } + } + + @override + Widget build(final BuildContext context) => CustomDialog( + title: Text(AppLocalizations.of(context).newsMoveFeed), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + NewsFolderSelect( + folders: widget.folders, + value: widget.feed.folderId != null + ? widget.folders.singleWhere((final folder) => folder.id == widget.feed.folderId) + : null, + onChanged: (final f) { + setState(() { + folder = f; + }); + }, + ), + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).newsMoveFeed), + ), + ], + ), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/news/options.dart b/packages/harbour/lib/src/apps/news/options.dart new file mode 100644 index 00000000..152f8414 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/options.dart @@ -0,0 +1,202 @@ +part of 'app.dart'; + +class NewsAppSpecificOptions extends NextcloudAppSpecificOptions { + NewsAppSpecificOptions(super.storage, final HarbourPlatform platform) { + super.categories = [ + generalCategory, + articlesCategory, + foldersCategory, + feedsCategory, + ]; + super.options = [ + defaultCategoryOption, + articleViewTypeOption, + defaultArticlesFilterOption, + articlesSortPropertyOption, + articlesSortBoxOrderOption, + foldersSortPropertyOption, + foldersSortBoxOrderOption, + defaultFolderViewTypeOption, + feedsSortPropertyOption, + feedsSortBoxOrderOption, + ]; + + _articleViewTypeValuesSubject.add({ + ArticleViewType.direct: (final context) => AppLocalizations.of(context).newsOptionsArticleViewTypeDirect, + if (platform.canUseWebView) ...{ + ArticleViewType.internalBrowser: (final context) => + AppLocalizations.of(context).newsOptionsArticleViewTypeInternalBrowser, + }, + ArticleViewType.externalBrowser: (final context) => + AppLocalizations.of(context).newsOptionsArticleViewTypeExternalBrowser, + }); + } + + final _articleViewTypeValuesSubject = BehaviorSubject>(); + + final generalCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).optionsCategoryGeneral, + ); + + final articlesCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).newsArticles, + ); + + final foldersCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).newsFolders, + ); + + final feedsCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).newsFeeds, + ); + + late final defaultCategoryOption = SelectOption( + storage: super.storage, + category: generalCategory, + key: 'default-category', + label: (final context) => AppLocalizations.of(context).newsOptionsDefaultCategory, + defaultValue: BehaviorSubject.seeded(DefaultCategory.articles), + values: BehaviorSubject.seeded({ + DefaultCategory.articles: (final context) => AppLocalizations.of(context).newsArticles, + DefaultCategory.folders: (final context) => AppLocalizations.of(context).newsFolders, + DefaultCategory.feeds: (final context) => AppLocalizations.of(context).newsFeeds, + }), + ); + + late final articleViewTypeOption = SelectOption( + storage: super.storage, + category: articlesCategory, + key: 'article-view-type', + label: (final context) => AppLocalizations.of(context).newsOptionsArticleViewType, + defaultValue: BehaviorSubject.seeded(ArticleViewType.direct), + values: _articleViewTypeValuesSubject, + ); + + late final defaultArticlesFilterOption = SelectOption( + storage: super.storage, + category: articlesCategory, + key: 'default-articles-filter', + label: (final context) => AppLocalizations.of(context).newsOptionsDefaultArticlesFilter, + defaultValue: BehaviorSubject.seeded(FilterType.unread), + values: BehaviorSubject.seeded({ + FilterType.all: (final context) => AppLocalizations.of(context).newsFilterAll, + FilterType.unread: (final context) => AppLocalizations.of(context).newsFilterUnread, + FilterType.starred: (final context) => AppLocalizations.of(context).newsFilterStarred, + }), + ); + + late final articlesSortPropertyOption = SelectOption( + storage: super.storage, + category: articlesCategory, + key: 'articles-sort-property', + label: (final context) => AppLocalizations.of(context).newsOptionsArticlesSortProperty, + defaultValue: BehaviorSubject.seeded(ArticlesSortProperty.publishDate), + values: BehaviorSubject.seeded({ + ArticlesSortProperty.publishDate: (final context) => + AppLocalizations.of(context).newsOptionsArticlesSortPropertyPublishDate, + ArticlesSortProperty.alphabetical: (final context) => + AppLocalizations.of(context).newsOptionsArticlesSortPropertyAlphabetical, + ArticlesSortProperty.byFeed: (final context) => AppLocalizations.of(context).newsOptionsArticlesSortPropertyFeed, + }), + ); + + late final articlesSortBoxOrderOption = SelectOption( + storage: super.storage, + category: articlesCategory, + key: 'articles-sort-box-order', + label: (final context) => AppLocalizations.of(context).newsOptionsArticlesSortOrder, + defaultValue: BehaviorSubject.seeded(SortBoxOrder.descending), + values: BehaviorSubject.seeded(sortBoxOrderOptionValues), + ); + + late final foldersSortPropertyOption = SelectOption( + storage: super.storage, + category: foldersCategory, + key: 'folders-sort-property', + label: (final context) => AppLocalizations.of(context).newsOptionsFoldersSortProperty, + defaultValue: BehaviorSubject.seeded(FoldersSortProperty.alphabetical), + values: BehaviorSubject.seeded({ + FoldersSortProperty.alphabetical: (final context) => + AppLocalizations.of(context).newsOptionsFoldersSortPropertyAlphabetical, + FoldersSortProperty.unreadCount: (final context) => + AppLocalizations.of(context).newsOptionsFoldersSortPropertyUnreadCount, + }), + ); + + late final foldersSortBoxOrderOption = SelectOption( + storage: super.storage, + category: foldersCategory, + key: 'folders-sort-box-order', + label: (final context) => AppLocalizations.of(context).newsOptionsFoldersSortOrder, + defaultValue: BehaviorSubject.seeded(SortBoxOrder.ascending), + values: BehaviorSubject.seeded(sortBoxOrderOptionValues), + ); + + late final defaultFolderViewTypeOption = SelectOption( + storage: super.storage, + category: foldersCategory, + key: 'default-folder-view-type', + label: (final context) => AppLocalizations.of(context).newsOptionsDefaultFolderViewType, + defaultValue: BehaviorSubject.seeded(DefaultFolderViewType.articles), + values: BehaviorSubject.seeded({ + DefaultFolderViewType.articles: (final context) => AppLocalizations.of(context).newsArticles, + DefaultFolderViewType.feeds: (final context) => AppLocalizations.of(context).newsFeeds, + }), + ); + + late final feedsSortPropertyOption = SelectOption( + storage: super.storage, + category: feedsCategory, + key: 'feeds-sort-property', + label: (final context) => AppLocalizations.of(context).newsOptionsFeedsSortProperty, + defaultValue: BehaviorSubject.seeded(FeedsSortProperty.alphabetical), + values: BehaviorSubject.seeded({ + FeedsSortProperty.alphabetical: (final context) => + AppLocalizations.of(context).newsOptionsFeedsSortPropertyAlphabetical, + FeedsSortProperty.unreadCount: (final context) => + AppLocalizations.of(context).newsOptionsFeedsSortPropertyUnreadCount, + }), + ); + + late final feedsSortBoxOrderOption = SelectOption( + storage: super.storage, + category: feedsCategory, + key: 'feeds-sort-box-order', + label: (final context) => AppLocalizations.of(context).newsOptionsFeedsSortOrder, + defaultValue: BehaviorSubject.seeded(SortBoxOrder.ascending), + values: BehaviorSubject.seeded(sortBoxOrderOptionValues), + ); +} + +enum DefaultCategory { + articles, + folders, + feeds, +} + +enum ArticleViewType { + direct, + internalBrowser, + externalBrowser, +} + +enum ArticlesSortProperty { + publishDate, + alphabetical, + byFeed, +} + +enum FoldersSortProperty { + alphabetical, + unreadCount, +} + +enum DefaultFolderViewType { + articles, + feeds, +} + +enum FeedsSortProperty { + alphabetical, + unreadCount, +} diff --git a/packages/harbour/lib/src/apps/news/pages/article.dart b/packages/harbour/lib/src/apps/news/pages/article.dart new file mode 100644 index 00000000..d78a7d82 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/pages/article.dart @@ -0,0 +1,192 @@ +part of '../app.dart'; + +class NewsArticlePage extends StatefulWidget { + const NewsArticlePage({ + required this.bloc, + required this.article, + required this.useWebView, + this.bodyData, + super.key, + }) : assert(useWebView || bodyData != null, 'bodyData has to be set when not using a WebView'); + + final NewsArticlesBloc bloc; + final NewsArticle article; + final bool useWebView; + final String? bodyData; + + @override + State createState() => _NewsArticlePageState(); +} + +class _NewsArticlePageState extends State { + late NewsArticle article = widget.article; + + bool _webviewLoading = true; + WebViewController? _webviewController; + Timer? _markAsReadTimer; + + @override + void initState() { + super.initState(); + + widget.bloc.articleUpdate.listen((final a) { + if (mounted && a.id == article.id) { + setState(() { + article = a; + }); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((final _) { + if (Provider.of(context, listen: false).canUseWakelock) { + // ignore: discarded_futures + Wakelock.enable(); + } + }); + + if (!widget.useWebView) { + _startMarkAsReadTimer(); + } + } + + @override + void dispose() { + _cancelMarkAsReadTimer(); + + super.dispose(); + } + + void _startMarkAsReadTimer() { + if (article.unread!) { + _markAsReadTimer = Timer(const Duration(seconds: 3), () { + if (article.unread!) { + widget.bloc.markArticleAsRead(article); + } + }); + } + } + + void _cancelMarkAsReadTimer() { + if (_markAsReadTimer != null) { + _markAsReadTimer!.cancel(); + _markAsReadTimer = null; + } + } + + Future _getURL() async { + if (_webviewController != null) { + return (await _webviewController!.currentUrl())!; + } + + return article.url!; + } + + @override + Widget build(final BuildContext context) => WillPopScope( + onWillPop: () async { + if (_webviewController != null && await _webviewController!.canGoBack()) { + await _webviewController!.goBack(); + return false; + } + + if (Provider.of(context, listen: false).canUseWakelock) { + await Wakelock.disable(); + } + return true; + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + actions: [ + IconButton( + onPressed: () async { + if (article.starred!) { + widget.bloc.unstarArticle(article); + } else { + widget.bloc.starArticle(article); + } + }, + icon: Icon(article.starred! ? Icons.star : Icons.star_outline), + ), + IconButton( + onPressed: () async { + if (article.unread!) { + widget.bloc.markArticleAsRead(article); + } else { + widget.bloc.markArticleAsUnread(article); + } + }, + icon: Icon(article.unread! ? MdiIcons.email : MdiIcons.emailMarkAsUnread), + ), + IconButton( + onPressed: () async { + await launchUrlString( + await _getURL(), + mode: LaunchMode.externalApplication, + ); + }, + icon: const Icon(Icons.open_in_new), + ), + IconButton( + onPressed: () async { + await Share.share(await _getURL()); + }, + icon: const Icon(Icons.share), + ), + ], + ), + body: widget.useWebView + ? Stack( + children: [ + WebView( + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (final controller) async { + _webviewController = controller; + await controller.loadUrl(article.url!); + }, + onPageStarted: (final _) { + setState(() { + _webviewLoading = true; + }); + }, + onPageFinished: (final _) { + _startMarkAsReadTimer(); + setState(() { + _webviewLoading = false; + }); + }, + ), + if (_webviewLoading) ...[ + ColoredBox( + color: Theme.of(context).colorScheme.background, + child: const Center( + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ), + ), + ], + ], + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(10), + child: Html( + data: widget.bodyData, + onLinkTap: ( + final url, + final renderContext, + final attributes, + final element, + ) async { + if (url != null) { + await launchUrlString( + url, + mode: LaunchMode.externalApplication, + ); + } + }, + ), + ), + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/pages/feed.dart b/packages/harbour/lib/src/apps/news/pages/feed.dart new file mode 100644 index 00000000..37328bde --- /dev/null +++ b/packages/harbour/lib/src/apps/news/pages/feed.dart @@ -0,0 +1,28 @@ +part of '../app.dart'; + +class NewsFeedPage extends StatelessWidget { + const NewsFeedPage({ + required this.bloc, + required this.feed, + super.key, + }); + + final NewsBloc bloc; + final NewsFeed feed; + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(feed.title!), + ), + body: NewsArticlesView( + bloc: NewsArticlesBloc( + bloc, + isMainArticlesBloc: false, + id: feed.id, + listType: ListType.feed, + ), + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/pages/folder.dart b/packages/harbour/lib/src/apps/news/pages/folder.dart new file mode 100644 index 00000000..7aa29c68 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/pages/folder.dart @@ -0,0 +1,24 @@ +part of '../app.dart'; + +class NewsFolderPage extends StatelessWidget { + const NewsFolderPage({ + required this.bloc, + required this.folder, + super.key, + }); + + final NewsBloc bloc; + final NewsFolder folder; + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(folder.name!), + ), + body: NewsFolderView( + bloc: bloc, + folder: folder, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/pages/main.dart b/packages/harbour/lib/src/apps/news/pages/main.dart new file mode 100644 index 00000000..8d736088 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/pages/main.dart @@ -0,0 +1,64 @@ +part of '../app.dart'; + +class NewsMainPage extends StatefulWidget { + const NewsMainPage({ + required this.bloc, + super.key, + }); + + final NewsBloc bloc; + + @override + State createState() => _NewsMainPageState(); +} + +class _NewsMainPageState extends State { + late int _index = widget.bloc.options.defaultCategoryOption.value.index; + + @override + void initState() { + super.initState(); + + widget.bloc.errors.listen((final error) { + ExceptionWidget.showSnackbar(context, error); + }); + } + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _index, + onTap: (final index) { + setState(() { + _index = index; + }); + }, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.newspaper), + label: AppLocalizations.of(context).newsArticles, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.folder), + label: AppLocalizations.of(context).newsFolders, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.rss_feed), + label: AppLocalizations.of(context).newsFeeds, + ), + ], + ), + body: _index == 0 + ? NewsArticlesView( + bloc: widget.bloc.mainArticlesBloc, + ) + : _index == 1 + ? NewsFoldersView( + bloc: widget.bloc, + ) + : NewsFeedsView( + bloc: widget.bloc, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/sort/articles.dart b/packages/harbour/lib/src/apps/news/sort/articles.dart new file mode 100644 index 00000000..1c6572d4 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/sort/articles.dart @@ -0,0 +1,13 @@ +part of '../app.dart'; + +final articlesSortBox = SortBox( + { + ArticlesSortProperty.publishDate: (final article) => article.pubDate!, + ArticlesSortProperty.alphabetical: (final article) => article.title!.toLowerCase(), + ArticlesSortProperty.byFeed: (final article) => article.feedId!, + }, + { + ArticlesSortProperty.alphabetical: Box(ArticlesSortProperty.publishDate, SortBoxOrder.descending), + ArticlesSortProperty.byFeed: Box(ArticlesSortProperty.alphabetical, SortBoxOrder.ascending), + }, +); diff --git a/packages/harbour/lib/src/apps/news/sort/feeds.dart b/packages/harbour/lib/src/apps/news/sort/feeds.dart new file mode 100644 index 00000000..b9c1d2d9 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/sort/feeds.dart @@ -0,0 +1,12 @@ +part of '../app.dart'; + +final feedsSortBox = SortBox( + { + FeedsSortProperty.alphabetical: (final feed) => feed.title!.toLowerCase(), + FeedsSortProperty.unreadCount: (final feed) => feed.unreadCount!, + }, + { + FeedsSortProperty.alphabetical: Box(FeedsSortProperty.unreadCount, SortBoxOrder.descending), + FeedsSortProperty.unreadCount: Box(FeedsSortProperty.alphabetical, SortBoxOrder.ascending), + }, +); diff --git a/packages/harbour/lib/src/apps/news/sort/folders.dart b/packages/harbour/lib/src/apps/news/sort/folders.dart new file mode 100644 index 00000000..7eec2e42 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/sort/folders.dart @@ -0,0 +1,29 @@ +part of '../app.dart'; + +final foldersSortBox = SortBox( + { + FoldersSortProperty.alphabetical: (final folderFeedsWrapper) => folderFeedsWrapper.folder.name!.toLowerCase(), + FoldersSortProperty.unreadCount: (final folderFeedsWrapper) => feedsUnreadCountSum(folderFeedsWrapper.feeds), + }, + { + FoldersSortProperty.alphabetical: Box(FoldersSortProperty.unreadCount, SortBoxOrder.descending), + FoldersSortProperty.unreadCount: Box(FoldersSortProperty.alphabetical, SortBoxOrder.ascending), + }, +); + +class FolderFeedsWrapper { + FolderFeedsWrapper( + this.folder, + this.feeds, + ); + + final NewsFolder folder; + final List feeds; +} + +int feedsUnreadCountSum(final List feeds) => [ + 0, // Fixes error when no feeds are in the folder + ...feeds.map((final f) => f.unreadCount!), + ].reduce( + (final a, final b) => a + b, + ); diff --git a/packages/harbour/lib/src/apps/news/widgets/articles_view.dart b/packages/harbour/lib/src/apps/news/widgets/articles_view.dart new file mode 100644 index 00000000..ed2b7c32 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/articles_view.dart @@ -0,0 +1,302 @@ +part of '../app.dart'; + +class NewsArticlesView extends StatefulWidget { + const NewsArticlesView({ + required this.bloc, + super.key, + }); + + final NewsArticlesBloc bloc; + + @override + State createState() => _NewsArticlesViewState(); +} + +class _NewsArticlesViewState extends State { + @override + void initState() { + super.initState(); + + widget.bloc.errors.listen((final error) { + ExceptionWidget.showSnackbar(context, error); + }); + } + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: widget.bloc.newsBloc, + state: (final bloc) => bloc.feeds, + builder: ( + final context, + final feedsData, + final feedsError, + final feedsLoading, + final _, + ) => + StandardRxResultBuilder>( + bloc: widget.bloc, + state: (final bloc) => bloc.articles, + builder: ( + final context, + final articlesData, + final articlesError, + final articlesLoading, + final _, + ) => + Scaffold( + resizeToAvoidBottomInset: false, + body: RefreshIndicator( + onRefresh: () async { + widget.bloc.refresh(); + }, + child: Column( + children: [ + ExceptionWidget( + articlesError ?? feedsError, + onRetry: () { + if (articlesError != null) { + widget.bloc.refresh(); + } + if (feedsError != null) { + widget.bloc.refreshNewsBloc(); + } + }, + ), + CustomLinearProgressIndicator( + visible: articlesLoading || feedsLoading, + ), + RxBlocBuilder( + bloc: widget.bloc, + state: (final bloc) => bloc.filterType, + builder: ( + final context, + final selectedFilterTypeSnapshot, + final _, + ) => + Container( + margin: const EdgeInsets.symmetric(horizontal: 15), + child: DropdownButton( + isExpanded: true, + value: selectedFilterTypeSnapshot.data, + items: [ + FilterType.all, + FilterType.unread, + if (widget.bloc.listType == null) ...[ + FilterType.starred, + ], + ].map>( + (final a) { + late final String label; + switch (a) { + case FilterType.all: + label = AppLocalizations.of(context).newsFilterAll; + break; + case FilterType.unread: + label = AppLocalizations.of(context).newsFilterUnread; + break; + case FilterType.starred: + label = AppLocalizations.of(context).newsFilterStarred; + break; + default: + throw Exception('FilterType $a should not be shown'); + } + return DropdownMenuItem( + value: a, + child: Text(label), + ); + }, + ).toList(), + onChanged: (final value) { + widget.bloc.setFilterType(value!); + }, + ), + ), + ), + if (articlesData != null && feedsData != null) ...[ + Expanded( + child: SortBoxBuilder( + sortBox: articlesSortBox, + sortPropertyOption: widget.bloc.newsBloc.options.articlesSortPropertyOption, + sortBoxOrderOption: widget.bloc.newsBloc.options.articlesSortBoxOrderOption, + input: articlesData, + builder: (final context, final sorted) => CustomListView( + scrollKey: 'news-articles', + items: sorted, + builder: (final context, final article) => _buildArticle( + context, + widget.bloc, + article, + feedsData.singleWhere((final feed) => feed.id == article.feedId), + ), + ), + ), + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ), + ), + ); + + Widget _buildArticle( + final BuildContext context, + final NewsArticlesBloc bloc, + final NewsArticle article, + final NewsFeed feed, + ) { + final clientID = RxBlocProvider.of(context).activeAccount.value!.client.id; + + return ResultStreamBuilder( + stream: Provider.of(context).wrapString( + clientID, + 'news-articles-body-${article.id}', + () async => _fixArticleBody(article.body!), + preferCache: true, + ), + builder: ( + final context, + final bodyData, + final bodyError, + final bodyLoading, + ) => + ListTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + article.title!, + style: article.unread! + ? null + : Theme.of(context).textTheme.subtitle1!.copyWith(color: Theme.of(context).disabledColor), + ), + ), + if (article.mediaThumbnail != null) ...[ + CachedURLImage( + url: article.mediaThumbnail!, + requestManager: Provider.of(context), + client: RxBlocProvider.of(context).activeAccount.value!.client, + width: 100, + height: 50, + fit: BoxFit.cover, + ), + ], + ], + ), + subtitle: Row( + children: [ + Text( + CustomTimeAgo.format( + DateTime.fromMillisecondsSinceEpoch(article.pubDate! * 1000), + ), + ), + const SizedBox( + width: 16, + ), + Container( + margin: const EdgeInsets.only( + top: 8, + bottom: 8, + right: 8, + ), + child: NewsFeedIcon( + feed: feed, + size: 16, + ), + ), + Flexible( + child: Text( + feed.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: IconButton( + icon: Icon( + article.starred! ? Icons.star : Icons.star_outline, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + if (article.starred!) { + bloc.unstarArticle(article); + } else { + bloc.starArticle(article); + } + }, + ), + onLongPress: () { + if (article.unread!) { + bloc.markArticleAsRead(article); + } else { + bloc.markArticleAsUnread(article); + } + }, + onTap: bodyData != null + ? () async { + final viewType = bloc.newsBloc.options.articleViewTypeOption.value; + if (viewType == ArticleViewType.direct) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NewsArticlePage( + bloc: bloc, + article: article, + useWebView: false, + bodyData: bodyData, + ), + ), + ); + } else if (Provider.of(context, listen: false).canUseWebView && + viewType == ArticleViewType.internalBrowser) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NewsArticlePage( + bloc: bloc, + article: article, + useWebView: true, + ), + ), + ); + } else { + if (article.unread!) { + bloc.markArticleAsRead(article); + } + await launchUrlString( + article.url!, + mode: LaunchMode.externalApplication, + ); + } + } + : null, + ), + ); + } + + String _fixArticleBody(final String b) => _fixTree(html_parser.parse(b).documentElement!).outerHtml; + + html_dom.Element _fixTree(final html_dom.Element element) { + _fixElement(element); + element.children.forEach(_fixTree); + + return element; + } + + html_dom.Element _fixElement(final html_dom.Element element) { + for (final attributeName in ['src', 'href']) { + final attributeValue = element.attributes[attributeName]; + if (attributeValue != null && attributeValue.startsWith('//')) { + element.attributes[attributeName] = 'https:$attributeValue'; + } + } + + return element; + } +} diff --git a/packages/harbour/lib/src/apps/news/widgets/feed_icon.dart b/packages/harbour/lib/src/apps/news/widgets/feed_icon.dart new file mode 100644 index 00000000..dfab396d --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/feed_icon.dart @@ -0,0 +1,36 @@ +part of '../app.dart'; + +class NewsFeedIcon extends StatelessWidget { + const NewsFeedIcon({ + required this.feed, + this.size = 48, + super.key, + }); + + final NewsFeed feed; + final double size; + + @override + Widget build(final BuildContext context) => SizedBox( + width: size, + height: size, + child: ColoredBox( + color: Colors.white, + child: Center( + child: feed.faviconLink != null && feed.faviconLink != '' + ? CachedURLImage( + url: feed.faviconLink!, + requestManager: Provider.of(context), + client: RxBlocProvider.of(context).activeAccount.value!.client, + height: size, + width: size, + ) + : Icon( + Icons.rss_feed, + size: size, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); +} diff --git a/packages/harbour/lib/src/apps/news/widgets/feeds_view.dart b/packages/harbour/lib/src/apps/news/widgets/feeds_view.dart new file mode 100644 index 00000000..29b2e917 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/feeds_view.dart @@ -0,0 +1,232 @@ +part of '../app.dart'; + +class NewsFeedsView extends StatelessWidget { + const NewsFeedsView({ + required this.bloc, + this.folderID, + super.key, + }); + + final NewsBloc bloc; + final int? folderID; + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.folders, + builder: ( + final context, + final foldersData, + final foldersError, + final foldersLoading, + final _, + ) => + StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.feeds, + builder: ( + final context, + final feedsData, + final feedsError, + final feedsLoading, + final _, + ) => + Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (final context) => NewsAddFeedDialog( + bloc: bloc, + folderID: folderID, + ), + ); + if (result != null) { + bloc.addFeed(result[0] as String, result[1] as int?); + } + }, + child: const Icon(Icons.add), + ), + body: RefreshIndicator( + onRefresh: () async { + bloc.refresh( + mainArticlesToo: true, + ); + }, + child: Column( + children: [ + ExceptionWidget( + feedsError ?? foldersError, + onRetry: () { + bloc.refresh( + mainArticlesToo: false, + ); + }, + ), + CustomLinearProgressIndicator( + visible: feedsLoading || foldersLoading, + ), + if (feedsData != null && foldersData != null) ...[ + Expanded( + child: SortBoxBuilder( + sortBox: feedsSortBox, + sortPropertyOption: bloc.options.feedsSortPropertyOption, + sortBoxOrderOption: bloc.options.feedsSortBoxOrderOption, + input: feedsData.where((final f) => folderID == null || f.folderId == folderID).toList(), + builder: (final context, final sorted) => CustomListView( + scrollKey: 'news-feeds', + withFloatingActionButton: true, + items: sorted, + builder: (final context, final feed) => _buildFeed( + context, + feed, + foldersData, + ), + ), + ), + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ), + ), + ); + + Widget _buildFeed( + final BuildContext context, + final NewsFeed feed, + final List folders, + ) => + ListTile( + title: Text( + feed.title!, + style: feed.unreadCount! == 0 + ? Theme.of(context).textTheme.subtitle1!.copyWith(color: Theme.of(context).disabledColor) + : null, + ), + subtitle: feed.unreadCount! > 0 + ? Text(AppLocalizations.of(context).newsUnreadArticles(feed.unreadCount!)) + : Container(), + leading: NewsFeedIcon( + feed: feed, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (feed.updateErrorCount! > 0) ...[ + IconButton( + iconSize: 30, + onPressed: () async { + await showDialog( + context: context, + builder: (final context) => NewsFeedUpdateErrorDialog( + feed: feed, + ), + ); + }, + icon: Text( + feed.updateErrorCount!.toString(), + style: const TextStyle( + color: Colors.red, + ), + ), + ), + ], + PopupMenuButton<_FeedAction>( + itemBuilder: (final context) => [ + PopupMenuItem( + value: _FeedAction.showURL, + child: Text(AppLocalizations.of(context).newsShowFeedURL), + ), + PopupMenuItem( + value: _FeedAction.delete, + child: Text(AppLocalizations.of(context).delete), + ), + PopupMenuItem( + value: _FeedAction.rename, + child: Text(AppLocalizations.of(context).rename), + ), + if (folders.isNotEmpty) ...[ + PopupMenuItem( + value: _FeedAction.move, + child: Text(AppLocalizations.of(context).move), + ), + ], + ], + onSelected: (final action) async { + switch (action) { + case _FeedAction.showURL: + await showDialog( + context: context, + builder: (final context) => NewsFeedShowURLDialog( + feed: feed, + ), + ); + break; + case _FeedAction.delete: + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).newsRemoveFeedConfirm(feed.title!), + )) { + bloc.removeFeed(feed.id!); + } + break; + case _FeedAction.rename: + final result = await showRenameDialog( + context: context, + title: AppLocalizations.of(context).newsRenameFeed, + value: feed.title!, + ); + if (result != null) { + bloc.renameFeed(feed.id!, result); + } + break; + case _FeedAction.move: + final result = await showDialog>( + context: context, + builder: (final context) => NewsMoveFeedDialog( + folders: folders, + feed: feed, + ), + ); + if (result != null) { + bloc.moveFeed(feed.id!, result[0]); + } + break; + } + }, + ), + ], + ), + onLongPress: () { + if (feed.unreadCount! > 0) { + bloc.markFeedAsRead(feed.id!); + } + }, + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NewsFeedPage( + bloc: bloc, + feed: feed, + ), + ), + ); + }, + ); +} + +enum _FeedAction { + showURL, + delete, + rename, + move, +} diff --git a/packages/harbour/lib/src/apps/news/widgets/folder_select.dart b/packages/harbour/lib/src/apps/news/widgets/folder_select.dart new file mode 100644 index 00000000..947a36c4 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/folder_select.dart @@ -0,0 +1,34 @@ +part of '../app.dart'; + +class NewsFolderSelect extends StatelessWidget { + const NewsFolderSelect({ + required this.folders, + required this.onChanged, + this.value, + super.key, + }); + + final List folders; + final void Function(NewsFolder?) onChanged; + final NewsFolder? value; + + @override + Widget build(final BuildContext context) => DropdownButtonFormField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context).newsFolder, + ), + value: value, + items: [ + DropdownMenuItem( + child: Text(AppLocalizations.of(context).newsFolderRoot), + ), + ...folders.map( + (final f) => DropdownMenuItem( + value: f, + child: Text(f.name!), + ), + ), + ], + onChanged: onChanged, + ); +} diff --git a/packages/harbour/lib/src/apps/news/widgets/folder_view.dart b/packages/harbour/lib/src/apps/news/widgets/folder_view.dart new file mode 100644 index 00000000..85f68bbb --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/folder_view.dart @@ -0,0 +1,61 @@ +part of '../app.dart'; + +class NewsFolderView extends StatefulWidget { + const NewsFolderView({ + required this.bloc, + required this.folder, + super.key, + }); + + final NewsBloc bloc; + final NewsFolder folder; + + @override + State createState() => _NewsFolderViewState(); +} + +class _NewsFolderViewState extends State { + late final option = widget.bloc.options.defaultFolderViewTypeOption; + late DefaultFolderViewType _viewType = option.value; + + @override + Widget build(final BuildContext context) => Column( + children: [ + Container( + margin: const EdgeInsets.all(10), + child: DropdownButton( + isExpanded: true, + value: _viewType, + items: option.values.value.keys + .map( + (final key) => DropdownMenuItem( + value: key, + child: Text(option.values.value[key]!(context)), + ), + ) + .toList(), + onChanged: (final value) { + setState(() { + _viewType = value!; + }); + }, + ), + ), + Expanded( + child: _viewType == DefaultFolderViewType.articles + ? NewsArticlesView( + bloc: NewsArticlesBloc( + widget.bloc, + isMainArticlesBloc: false, + id: widget.folder.id, + listType: ListType.folder, + ), + ) + : NewsFeedsView( + bloc: widget.bloc, + folderID: widget.folder.id, + ), + ), + ], + ); +} diff --git a/packages/harbour/lib/src/apps/news/widgets/folders_view.dart b/packages/harbour/lib/src/apps/news/widgets/folders_view.dart new file mode 100644 index 00000000..eecfd886 --- /dev/null +++ b/packages/harbour/lib/src/apps/news/widgets/folders_view.dart @@ -0,0 +1,190 @@ +part of '../app.dart'; + +class NewsFoldersView extends StatelessWidget { + const NewsFoldersView({ + required this.bloc, + super.key, + }); + + final NewsBloc bloc; + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (final context) => const NewsCreateFolderDialog(), + ); + if (result != null) { + bloc.createFolder(result); + } + }, + child: const Icon(Icons.add), + ), + body: StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.folders, + builder: ( + final context, + final foldersData, + final foldersError, + final foldersLoading, + final _, + ) => + StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.feeds, + builder: ( + final context, + final feedsData, + final feedsError, + final feedsLoading, + final _, + ) => + RefreshIndicator( + onRefresh: () async { + bloc.refresh( + mainArticlesToo: true, + ); + }, + child: Column( + children: [ + ExceptionWidget( + feedsError ?? foldersError, + onRetry: () { + bloc.refresh( + mainArticlesToo: false, + ); + }, + ), + CustomLinearProgressIndicator( + visible: feedsLoading || foldersLoading, + ), + if (feedsData != null && foldersData != null) ...[ + Expanded( + child: SortBoxBuilder( + sortBox: foldersSortBox, + sortPropertyOption: bloc.options.foldersSortPropertyOption, + sortBoxOrderOption: bloc.options.foldersSortBoxOrderOption, + input: foldersData + .map( + (final folder) => FolderFeedsWrapper( + folder, + feedsData.where((final feed) => feed.folderId == folder.id).toList(), + ), + ) + .toList(), + builder: (final context, final sorted) => CustomListView( + scrollKey: 'news-folders', + withFloatingActionButton: true, + items: sorted, + builder: _buildFolder, + ), + ), + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ), + ), + ); + + Widget _buildFolder( + final BuildContext context, + final FolderFeedsWrapper folderFeedsWrapper, + ) { + final unreadCount = feedsUnreadCountSum(folderFeedsWrapper.feeds); + return ListTile( + title: Text( + folderFeedsWrapper.folder.name!, + style: unreadCount == 0 + ? Theme.of(context).textTheme.subtitle1!.copyWith(color: Theme.of(context).disabledColor) + : null, + ), + subtitle: unreadCount > 0 + ? Text( + AppLocalizations.of(context).newsUnreadArticles(unreadCount), + ) + : Container(), + leading: SizedBox( + width: 48, + height: 48, + child: Stack( + children: [ + Icon( + Icons.folder, + size: 48, + color: Theme.of(context).colorScheme.primary, + ), + Center( + child: Text(folderFeedsWrapper.feeds.length.toString()), + ), + ], + ), + ), + trailing: PopupMenuButton<_FolderAction>( + itemBuilder: (final context) => [ + PopupMenuItem( + value: _FolderAction.delete, + child: Text(AppLocalizations.of(context).delete), + ), + PopupMenuItem( + value: _FolderAction.rename, + child: Text(AppLocalizations.of(context).rename), + ), + ], + onSelected: (final action) async { + switch (action) { + case _FolderAction.delete: + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).newsDeleteFolderConfirm(folderFeedsWrapper.folder.name!), + )) { + bloc.deleteFolder(folderFeedsWrapper.folder.id!); + } + break; + case _FolderAction.rename: + final result = await showRenameDialog( + context: context, + title: AppLocalizations.of(context).newsRenameFolder, + value: folderFeedsWrapper.folder.name!, + ); + if (result != null) { + bloc.renameFolder(folderFeedsWrapper.folder.id!, result); + } + break; + } + }, + ), + onLongPress: () { + if (unreadCount > 0) { + bloc.markFolderAsRead(folderFeedsWrapper.folder.id!); + } + }, + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NewsFolderPage( + bloc: bloc, + folder: folderFeedsWrapper.folder, + ), + ), + ); + }, + ); + } +} + +enum _FolderAction { + delete, + rename, +} diff --git a/packages/harbour/lib/src/apps/notes/app.dart b/packages/harbour/lib/src/apps/notes/app.dart new file mode 100644 index 00000000..e089dfa4 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/app.dart @@ -0,0 +1,50 @@ +library notes; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:intersperse/intersperse.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:settings/settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sort_box/sort_box.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +part 'dialogs/create_note.dart'; +part 'dialogs/select_category.dart'; +part 'options.dart'; +part 'pages/category.dart'; +part 'pages/main.dart'; +part 'pages/note.dart'; +part 'sort/categories.dart'; +part 'sort/notes.dart'; +part 'utils/category_color.dart'; +part 'utils/exception_handler.dart'; +part 'widgets/categories_view.dart'; +part 'widgets/category_select.dart'; +part 'widgets/notes_view.dart'; + +class NotesApp extends AppImplementation { + NotesApp( + final SharedPreferences sharedPreferences, + final RequestManager requestManager, + ) : super( + 'notes', + (final context) => AppLocalizations.of(context).notesName, + sharedPreferences, + NotesAppSpecificOptions.new, + (final options, final client) => NotesBloc( + options, + requestManager, + client, + ), + (final context, final bloc) => NotesMainPage( + bloc: bloc, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/notes/blocs/notes.dart b/packages/harbour/lib/src/apps/notes/blocs/notes.dart new file mode 100644 index 00000000..81372db8 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/blocs/notes.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'notes.rxb.g.dart'; + +abstract class NotesBlocEvents { + void refresh(); + + void createNote(final NotesNote note); + + void updateNote( + final int id, + final String etag, + final NotesNote note, + ); + + void deleteNote(final NotesNote note); +} + +abstract class NotesBlocStates { + BehaviorSubject>> get notes; + + Stream get noteUpdate; + + Stream get errors; +} + +@RxBloc() +class NotesBloc extends $NotesBloc { + NotesBloc( + this.options, + this.requestManager, + this.client, + ) { + _$refreshEvent.listen((final _) => _loadNotes()); + + _$createNoteEvent.listen((final note) { + _wrapAction(() async => client.notes.createNote(note)); + }); + + _$updateNoteEvent.listen((final event) { + _wrapAction( + () async => _noteUpdateController.add( + (await client.notes.updateNote( + event.id, + event.note, + ifMatch: '"${event.etag}"', + ))!, + ), + ); + }); + + _$deleteNoteEvent.listen((final note) { + _wrapAction(() async => client.notes.deleteNote(note.id!)); + }); + + _loadNotes(); + } + + void _wrapAction(final Future Function() call) { + final stream = requestManager.wrapWithoutCache(call).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) async { + _loadNotes(); + }); + } + + void _loadNotes() { + requestManager + .wrapNextcloud, List, NotesNote, NextcloudNotesClient>( + client.id, + client.notes, + 'notes-notes', + () async => (await client.notes.getNotes())!, + (final response) => response, + previousData: _notesSubject.hasValue ? _notesSubject.value.data : null, + ) + .listen(_notesSubject.add); + } + + final NotesAppSpecificOptions options; + final RequestManager requestManager; + final NextcloudClient client; + + final _notesSubject = BehaviorSubject>>(); + final _noteUpdateController = StreamController(); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + // ignore: discarded_futures + _notesSubject.close(); + // ignore: discarded_futures + _errorsStreamController.close(); + super.dispose(); + } + + @override + BehaviorSubject>> _mapToNotesState() => _notesSubject; + + @override + Stream _mapToNoteUpdateState() => _noteUpdateController.stream.asBroadcastStream(); + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} diff --git a/packages/harbour/lib/src/apps/notes/blocs/notes.rxb.g.dart b/packages/harbour/lib/src/apps/notes/blocs/notes.rxb.g.dart new file mode 100644 index 00000000..d36dc691 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/blocs/notes.rxb.g.dart @@ -0,0 +1,96 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'notes.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class NotesBlocType extends RxBlocTypeBase { + NotesBlocEvents get events; + NotesBlocStates get states; +} + +/// [$NotesBloc] extended by the [NotesBloc] +/// {@nodoc} +abstract class $NotesBloc extends RxBlocBase implements NotesBlocEvents, NotesBlocStates, NotesBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [createNote] + final _$createNoteEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [updateNote] + final _$updateNoteEvent = PublishSubject<_UpdateNoteEventArgs>(); + + /// Тhe [Subject] where events sink to by calling [deleteNote] + final _$deleteNoteEvent = PublishSubject(); + + /// The state of [notes] implemented in [_mapToNotesState] + late final BehaviorSubject>> _notesState = _mapToNotesState(); + + /// The state of [noteUpdate] implemented in [_mapToNoteUpdateState] + late final Stream _noteUpdateState = _mapToNoteUpdateState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void refresh() => _$refreshEvent.add(null); + + @override + void createNote(NotesNote note) => _$createNoteEvent.add(note); + + @override + void updateNote(int id, String etag, NotesNote note) => _$updateNoteEvent.add(_UpdateNoteEventArgs(id, etag, note)); + + @override + void deleteNote(NotesNote note) => _$deleteNoteEvent.add(note); + + @override + BehaviorSubject>> get notes => _notesState; + + @override + Stream get noteUpdate => _noteUpdateState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject>> _mapToNotesState(); + + Stream _mapToNoteUpdateState(); + + Stream _mapToErrorsState(); + + @override + NotesBlocEvents get events => this; + + @override + NotesBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$createNoteEvent.close(); + _$updateNoteEvent.close(); + _$deleteNoteEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NotesBlocEvents.updateNote] event +class _UpdateNoteEventArgs { + const _UpdateNoteEventArgs(this.id, this.etag, this.note); + + final int id; + + final String etag; + + final NotesNote note; +} diff --git a/packages/harbour/lib/src/apps/notes/dialogs/create_note.dart b/packages/harbour/lib/src/apps/notes/dialogs/create_note.dart new file mode 100644 index 00000000..61ab2375 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/dialogs/create_note.dart @@ -0,0 +1,92 @@ +part of '../app.dart'; + +class NotesCreateNoteDialog extends StatefulWidget { + const NotesCreateNoteDialog({ + required this.bloc, + this.category, + super.key, + }); + + final NotesBloc bloc; + final String? category; + + @override + State createState() => _NotesCreateNoteDialogState(); +} + +class _NotesCreateNoteDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + String? selectedCategory; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop([controller.text, widget.category ?? selectedCategory]); + } + } + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: widget.bloc, + state: (final bloc) => bloc.notes, + builder: ( + final context, + final notesData, + final notesError, + final notesLoading, + final _, + ) => + CustomDialog( + title: Text(AppLocalizations.of(context).notesCreateNote), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextFormField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + hintText: AppLocalizations.of(context).notesNoteTitle, + ), + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + if (widget.category == null) ...[ + Center( + child: ExceptionWidget( + notesError, + onRetry: () { + widget.bloc.refresh(); + }, + ), + ), + Center( + child: CustomLinearProgressIndicator( + visible: notesLoading, + ), + ), + if (notesData != null) ...[ + NotesCategorySelect( + categories: notesData.map((final note) => note.category!).toSet().toList(), + onChanged: (final category) { + selectedCategory = category; + }, + onSubmitted: submit, + ), + ], + ], + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).notesCreateNote), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/packages/harbour/lib/src/apps/notes/dialogs/select_category.dart b/packages/harbour/lib/src/apps/notes/dialogs/select_category.dart new file mode 100644 index 00000000..da795c68 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/dialogs/select_category.dart @@ -0,0 +1,80 @@ +part of '../app.dart'; + +class NotesSelectCategoryDialog extends StatefulWidget { + const NotesSelectCategoryDialog({ + required this.bloc, + required this.note, + super.key, + }); + + final NotesBloc bloc; + final NotesNote note; + + @override + State createState() => _NotesSelectCategoryDialogState(); +} + +class _NotesSelectCategoryDialogState extends State { + final formKey = GlobalKey(); + + String? selectedCategory; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(selectedCategory ?? widget.note.category); + } + } + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: widget.bloc, + state: (final bloc) => bloc.notes, + builder: ( + final context, + final notesData, + final notesError, + final notesLoading, + final _, + ) => + CustomDialog( + title: Text(AppLocalizations.of(context).notesChangeCategory), + children: [ + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Center( + child: ExceptionWidget( + notesError, + onRetry: () { + widget.bloc.refresh(); + }, + ), + ), + Center( + child: CustomLinearProgressIndicator( + visible: notesLoading, + ), + ), + if (notesData != null) ...[ + NotesCategorySelect( + categories: notesData.map((final note) => note.category!).toSet().toList(), + initialValue: widget.note.category, + onChanged: (final category) { + selectedCategory = category; + }, + onSubmitted: submit, + ), + ], + ElevatedButton( + onPressed: submit, + child: Text(AppLocalizations.of(context).notesSetCategory), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/packages/harbour/lib/src/apps/notes/options.dart b/packages/harbour/lib/src/apps/notes/options.dart new file mode 100644 index 00000000..ee46fca6 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/options.dart @@ -0,0 +1,122 @@ +part of 'app.dart'; + +class NotesAppSpecificOptions extends NextcloudAppSpecificOptions { + NotesAppSpecificOptions(super.storage) { + super.categories = [ + generalCategory, + notesCategory, + categoriesCategory, + ]; + super.options = [ + defaultCategoryOption, + defaultNoteViewTypeOption, + notesSortPropertyOption, + notesSortBoxOrderOption, + categoriesSortPropertyOption, + categoriesSortBoxOrderOption, + ]; + } + + final generalCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).optionsCategoryGeneral, + ); + + final notesCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).notesNotes, + ); + + final categoriesCategory = OptionsCategory( + name: (final context) => AppLocalizations.of(context).notesCategories, + ); + + late final defaultCategoryOption = SelectOption( + storage: super.storage, + category: generalCategory, + key: 'default-category', + label: (final context) => AppLocalizations.of(context).notesOptionsDefaultCategory, + defaultValue: BehaviorSubject.seeded(DefaultCategory.notes), + values: BehaviorSubject.seeded({ + DefaultCategory.notes: (final context) => AppLocalizations.of(context).notesNotes, + DefaultCategory.categories: (final context) => AppLocalizations.of(context).notesCategories, + }), + ); + + late final defaultNoteViewTypeOption = SelectOption( + storage: super.storage, + category: generalCategory, + key: 'default-note-view-type', + label: (final context) => AppLocalizations.of(context).notesOptionsDefaultNoteViewType, + defaultValue: BehaviorSubject.seeded(DefaultNoteViewType.preview), + values: BehaviorSubject.seeded({ + DefaultNoteViewType.preview: (final context) => + AppLocalizations.of(context).notesOptionsDefaultNoteViewTypePreview, + DefaultNoteViewType.edit: (final context) => AppLocalizations.of(context).notesOptionsDefaultNoteViewTypeEdit, + }), + ); + + late final notesSortPropertyOption = SelectOption( + storage: super.storage, + category: notesCategory, + key: 'notes-sort-property', + label: (final context) => AppLocalizations.of(context).notesOptionsNotesSortProperty, + defaultValue: BehaviorSubject.seeded(NotesSortProperty.lastModified), + values: BehaviorSubject.seeded({ + NotesSortProperty.lastModified: (final context) => + AppLocalizations.of(context).notesOptionsNotesSortPropertyLastModified, + NotesSortProperty.alphabetical: (final context) => + AppLocalizations.of(context).notesOptionsNotesSortPropertyAlphabetical, + }), + ); + + late final notesSortBoxOrderOption = SelectOption( + storage: super.storage, + category: notesCategory, + key: 'notes-sort-box-order', + label: (final context) => AppLocalizations.of(context).notesOptionsNotesSortOrder, + defaultValue: BehaviorSubject.seeded(SortBoxOrder.descending), + values: BehaviorSubject.seeded(sortBoxOrderOptionValues), + ); + + late final categoriesSortPropertyOption = SelectOption( + storage: super.storage, + category: categoriesCategory, + key: 'categories-sort-property', + label: (final context) => AppLocalizations.of(context).notesOptionsCategoriesSortProperty, + defaultValue: BehaviorSubject.seeded(CategoriesSortProperty.alphabetical), + values: BehaviorSubject.seeded({ + CategoriesSortProperty.alphabetical: (final context) => + AppLocalizations.of(context).notesOptionsCategoriesSortPropertyAlphabetical, + CategoriesSortProperty.notesCount: (final context) => + AppLocalizations.of(context).notesOptionsCategoriesSortPropertyNotesCount, + }), + ); + + late final categoriesSortBoxOrderOption = SelectOption( + storage: super.storage, + category: categoriesCategory, + key: 'categories-sort-box-order', + label: (final context) => AppLocalizations.of(context).notesOptionsCategoriesSortOrder, + defaultValue: BehaviorSubject.seeded(SortBoxOrder.ascending), + values: BehaviorSubject.seeded(sortBoxOrderOptionValues), + ); +} + +enum DefaultNoteViewType { + preview, + edit, +} + +enum NotesSortProperty { + lastModified, + alphabetical, +} + +enum CategoriesSortProperty { + alphabetical, + notesCount, +} + +enum DefaultCategory { + notes, + categories, +} diff --git a/packages/harbour/lib/src/apps/notes/pages/category.dart b/packages/harbour/lib/src/apps/notes/pages/category.dart new file mode 100644 index 00000000..b0a305b9 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/pages/category.dart @@ -0,0 +1,24 @@ +part of '../app.dart'; + +class NotesCategoryPage extends StatelessWidget { + const NotesCategoryPage({ + required this.bloc, + required this.category, + super.key, + }); + + final NotesBloc bloc; + final NoteCategory category; + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(category.name != '' ? category.name : AppLocalizations.of(context).notesUncategorized), + ), + body: NotesView( + bloc: bloc, + category: category.name, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/notes/pages/main.dart b/packages/harbour/lib/src/apps/notes/pages/main.dart new file mode 100644 index 00000000..dc62228a --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/pages/main.dart @@ -0,0 +1,56 @@ +part of '../app.dart'; + +class NotesMainPage extends StatefulWidget { + const NotesMainPage({ + required this.bloc, + super.key, + }); + + final NotesBloc bloc; + + @override + State createState() => _NotesMainPageState(); +} + +class _NotesMainPageState extends State { + late int _index = widget.bloc.options.defaultCategoryOption.value.index; + + @override + void initState() { + super.initState(); + + widget.bloc.errors.listen((final error) { + handleNotesException(context, error); + }); + } + + @override + Widget build(final BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _index, + onTap: (final index) { + setState(() { + _index = index; + }); + }, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.note), + label: AppLocalizations.of(context).notesNotes, + ), + BottomNavigationBarItem( + icon: const Icon(MdiIcons.tag), + label: AppLocalizations.of(context).notesCategories, + ), + ], + ), + body: _index == 0 + ? NotesView( + bloc: widget.bloc, + ) + : NotesCategoriesView( + bloc: widget.bloc, + ), + ); +} diff --git a/packages/harbour/lib/src/apps/notes/pages/note.dart b/packages/harbour/lib/src/apps/notes/pages/note.dart new file mode 100644 index 00000000..d3c0a9f2 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/pages/note.dart @@ -0,0 +1,201 @@ +part of '../app.dart'; + +class NotesNotePage extends StatefulWidget { + const NotesNotePage({ + required this.bloc, + required this.note, + super.key, + }); + + final NotesBloc bloc; + final NotesNote note; + + @override + State createState() => _NotesNotePageState(); +} + +class _NotesNotePageState extends State { + late final _contentController = TextEditingController(text: widget.note.content); + late final _titleController = TextEditingController(text: widget.note.title); + final _contentFocusNode = FocusNode(); + final _titleFocusNode = FocusNode(); + + late NotesNote _note = widget.note; + bool _showEditor = false; + bool _synced = true; + + void _focusEditor() { + _contentFocusNode.requestFocus(); + _contentController.selection = TextSelection.collapsed(offset: _contentController.text.length); + } + + void _update([final String? selectedCategory]) { + final updatedTitle = _note.title != _titleController.text ? _titleController.text : null; + final updatedCategory = selectedCategory != null && _note.category != selectedCategory ? selectedCategory : null; + final updatedContent = _note.content != _contentController.text ? _contentController.text : null; + + if (updatedTitle != null || updatedCategory != null || updatedContent != null) { + widget.bloc.updateNote( + _note.id!, + _note.etag!, + NotesNote( + title: updatedTitle, + category: updatedCategory, + content: updatedContent, + ), + ); + } + } + + @override + void initState() { + super.initState(); + + void updateSynced() { + _synced = _note.content == _contentController.text; + } + + _contentController.addListener(() => setState(updateSynced)); + + widget.bloc.noteUpdate.listen((final n) { + if (mounted && n.id == _note.id) { + setState(() { + _note = n; + updateSynced(); + }); + } + }); + + _titleFocusNode.addListener(() { + if (!_titleFocusNode.hasFocus) { + _update(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((final _) { + if (widget.bloc.options.defaultNoteViewTypeOption.value == DefaultNoteViewType.edit || + widget.note.content!.isEmpty) { + setState(() { + _showEditor = true; + }); + _contentFocusNode.requestFocus(); + _contentController.selection = TextSelection.collapsed(offset: _contentController.text.length); + } + }); + } + + @override + Widget build(final BuildContext context) { + final titleInputBorder = UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.onPrimary, + ), + ); + return WillPopScope( + onWillPop: () async { + _update(); + return true; + }, + child: Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: TextField( + controller: _titleController, + focusNode: _titleFocusNode, + style: TextStyle( + fontSize: 22, + color: Theme.of(context).colorScheme.onPrimary, + ), + cursorColor: Theme.of(context).colorScheme.onPrimary, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: titleInputBorder, + focusedBorder: titleInputBorder, + ), + ), + actions: [ + IconButton( + icon: Icon( + _synced ? Icons.check : Icons.sync, + ), + onPressed: _update, + ), + IconButton( + icon: Icon( + _showEditor ? Icons.visibility : Icons.edit, + ), + onPressed: () { + setState(() { + _showEditor = !_showEditor; + }); + if (_showEditor) { + _focusEditor(); + } else { + // Prevent the cursor going back to the title field + _contentFocusNode.unfocus(); + _titleFocusNode.unfocus(); + } + }, + ), + IconButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (final context) => NotesSelectCategoryDialog( + bloc: widget.bloc, + note: _note, + ), + ); + if (result != null) { + _update(result); + } + }, + icon: Icon( + MdiIcons.tag, + color: _note.category!.isNotEmpty ? NotesCategoryColor.compute(_note.category!) : null, + ), + ), + ], + ), + body: GestureDetector( + onTap: () { + setState(() { + _showEditor = true; + }); + _focusEditor(); + }, + child: Container( + padding: EdgeInsets.symmetric( + vertical: 10, + horizontal: _showEditor ? 20 : 10, + ), + color: Colors.transparent, + constraints: const BoxConstraints.expand(), + child: _showEditor + ? TextField( + controller: _contentController, + focusNode: _contentFocusNode, + keyboardType: TextInputType.multiline, + maxLines: null, + decoration: const InputDecoration( + border: InputBorder.none, + ), + ) + : MarkdownBody( + data: _contentController.text, + onTapLink: (final text, final href, final title) { + if (href != null) { + launchUrlString( + href, + mode: LaunchMode.externalApplication, + ); + } + }, + ), + ), + ), + ), + ); + } +} diff --git a/packages/harbour/lib/src/apps/notes/sort/categories.dart b/packages/harbour/lib/src/apps/notes/sort/categories.dart new file mode 100644 index 00000000..ad5ce6db --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/sort/categories.dart @@ -0,0 +1,21 @@ +part of '../app.dart'; + +final categoriesSortBox = SortBox( + { + CategoriesSortProperty.alphabetical: (final category) => category.name.toLowerCase(), + CategoriesSortProperty.notesCount: (final category) => category.count, + }, + { + CategoriesSortProperty.notesCount: Box(CategoriesSortProperty.alphabetical, SortBoxOrder.ascending), + }, +); + +class NoteCategory { + NoteCategory( + this.name, + this.count, + ); + + final String name; + final int count; +} diff --git a/packages/harbour/lib/src/apps/notes/sort/notes.dart b/packages/harbour/lib/src/apps/notes/sort/notes.dart new file mode 100644 index 00000000..75cf55a2 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/sort/notes.dart @@ -0,0 +1,11 @@ +part of '../app.dart'; + +final notesSortBox = SortBox( + { + NotesSortProperty.alphabetical: (final note) => note.title!.toLowerCase(), + NotesSortProperty.lastModified: (final note) => note.modified!, + }, + { + NotesSortProperty.alphabetical: Box(NotesSortProperty.lastModified, SortBoxOrder.descending), + }, +); diff --git a/packages/harbour/lib/src/apps/notes/utils/category_color.dart b/packages/harbour/lib/src/apps/notes/utils/category_color.dart new file mode 100644 index 00000000..59e4cff6 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/utils/category_color.dart @@ -0,0 +1,15 @@ +part of '../app.dart'; + +class NotesCategoryColor { + static final Map _colors = {}; + + static Color compute(final String category) { + if (_colors.containsKey(category)) { + return _colors[category]!; + } + + final color = HexColor(sha1.convert(utf8.encode(category)).toString().substring(0, 6)); + _colors[category] = color; + return color; + } +} diff --git a/packages/harbour/lib/src/apps/notes/utils/exception_handler.dart b/packages/harbour/lib/src/apps/notes/utils/exception_handler.dart new file mode 100644 index 00000000..b0103844 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/utils/exception_handler.dart @@ -0,0 +1,9 @@ +part of '../app.dart'; + +void handleNotesException(final BuildContext context, final Exception error) { + if (error is ApiException && error.code == 412) { + ExceptionWidget.showSnackbar(context, AppLocalizations.of(context).notesNoteChangedOnServer); + } else { + ExceptionWidget.showSnackbar(context, error); + } +} diff --git a/packages/harbour/lib/src/apps/notes/widgets/categories_view.dart b/packages/harbour/lib/src/apps/notes/widgets/categories_view.dart new file mode 100644 index 00000000..69e25e48 --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/widgets/categories_view.dart @@ -0,0 +1,100 @@ +part of '../app.dart'; + +class NotesCategoriesView extends StatelessWidget { + const NotesCategoriesView({ + required this.bloc, + super.key, + }); + + final NotesBloc bloc; + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.notes, + builder: ( + final context, + final notesData, + final notesError, + final notesLoading, + final _, + ) => + RefreshIndicator( + onRefresh: () async { + bloc.refresh(); + }, + child: Column( + children: [ + ExceptionWidget( + notesError, + onRetry: () { + bloc.refresh(); + }, + ), + CustomLinearProgressIndicator( + visible: notesLoading, + ), + if (notesData != null) ...[ + Expanded( + child: SortBoxBuilder( + sortBox: categoriesSortBox, + sortPropertyOption: bloc.options.categoriesSortPropertyOption, + sortBoxOrderOption: bloc.options.categoriesSortBoxOrderOption, + input: notesData + .map((final note) => note.category!) + .toSet() + .map( + (final category) => NoteCategory( + category, + notesData.where((final note) => note.category == category).length, + ), + ) + .toList(), + builder: (final context, final sorted) => CustomListView( + scrollKey: 'notes-categories', + items: sorted, + builder: _buildCategory, + ), + ), + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ); + + Widget _buildCategory( + final BuildContext context, + final NoteCategory category, + ) => + ListTile( + title: Text(category.name != '' ? category.name : AppLocalizations.of(context).notesUncategorized), + subtitle: Text(AppLocalizations.of(context).notesNotesInCategory(category.count)), + leading: category.name != '' + ? Icon( + MdiIcons.tag, + size: 40, + color: NotesCategoryColor.compute(category.name), + ) + : const SizedBox( + height: 40, + width: 40, + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NotesCategoryPage( + bloc: bloc, + category: category, + ), + ), + ); + }, + ); +} diff --git a/packages/harbour/lib/src/apps/notes/widgets/category_select.dart b/packages/harbour/lib/src/apps/notes/widgets/category_select.dart new file mode 100644 index 00000000..f90e274c --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/widgets/category_select.dart @@ -0,0 +1,82 @@ +part of '../app.dart'; + +class NotesCategorySelect extends StatelessWidget { + NotesCategorySelect({ + required this.categories, + required this.onChanged, + required this.onSubmitted, + this.initialValue, + super.key, + }) { + if (initialValue != null) { + onChanged(initialValue!); + } + } + + final List categories; + final String? initialValue; + final Function(String category) onChanged; + final Function() onSubmitted; + + late final _categories = categories..sort((final a, final b) => a.compareTo(b)); + + @override + Widget build(final BuildContext context) => CustomAutocomplete( + initialValue: initialValue != null + ? TextEditingValue( + text: initialValue!, + ) + : null, + optionsBuilder: (final value) { + final categories = [ + if (!_categories.contains('')) ...{ + '', + }, + ..._categories, + ]; + + if (value.text == '') { + return categories; + } + return categories.where((final category) => category.toLowerCase().contains(value.text.toLowerCase())); + }, + fieldViewBuilder: ( + final context, + final textEditingController, + final focusNode, + final onFieldSubmitted, + ) => + TextFormField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: AppLocalizations.of(context).notesCategory, + ), + onFieldSubmitted: (final value) { + onChanged(value); + onSubmitted(); + onFieldSubmitted(); + }, + onChanged: onChanged, + ), + displayWidgetForOption: (final category) => Row( + children: [ + Icon( + MdiIcons.tag, + color: category != '' ? NotesCategoryColor.compute(category) : null, + ), + const SizedBox( + width: 10, + ), + Text( + category != '' ? category : AppLocalizations.of(context).notesUncategorized, + ), + ], + ), + onSelected: (final value) { + if (categories.contains(value)) { + onChanged(value); + } + }, + ); +} diff --git a/packages/harbour/lib/src/apps/notes/widgets/notes_view.dart b/packages/harbour/lib/src/apps/notes/widgets/notes_view.dart new file mode 100644 index 00000000..fb118a5d --- /dev/null +++ b/packages/harbour/lib/src/apps/notes/widgets/notes_view.dart @@ -0,0 +1,163 @@ +part of '../app.dart'; + +class NotesView extends StatelessWidget { + const NotesView({ + required this.bloc, + this.category, + super.key, + }); + + final NotesBloc bloc; + final String? category; + + @override + Widget build(final BuildContext context) => StandardRxResultBuilder>( + bloc: bloc, + state: (final bloc) => bloc.notes, + builder: ( + final context, + final notesData, + final notesError, + final notesLoading, + final _, + ) => + Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (final context) => NotesCreateNoteDialog( + bloc: bloc, + category: category, + ), + ); + if (result != null) { + bloc.createNote( + NotesNote( + title: result[0] as String, + category: result[1] as String?, + ), + ); + } + }, + child: const Icon(Icons.add), + ), + body: RefreshIndicator( + onRefresh: () async { + bloc.refresh(); + }, + child: Column( + children: [ + ExceptionWidget( + notesError, + onRetry: () { + bloc.refresh(); + }, + ), + CustomLinearProgressIndicator( + visible: notesLoading, + ), + if (notesData != null) ...[ + Expanded( + child: SortBoxBuilder( + sortBox: notesSortBox, + sortPropertyOption: bloc.options.notesSortPropertyOption, + sortBoxOrderOption: bloc.options.notesSortBoxOrderOption, + input: category != null + ? notesData.where((final note) => note.favorite! && note.category == category).toList() + : notesData.where((final note) => note.favorite!).toList(), + builder: (final context, final sortedFavorites) => SortBoxBuilder( + sortBox: notesSortBox, + sortPropertyOption: bloc.options.notesSortPropertyOption, + sortBoxOrderOption: bloc.options.notesSortBoxOrderOption, + input: category != null + ? notesData.where((final note) => !note.favorite! && note.category == category).toList() + : notesData.where((final note) => !note.favorite!).toList(), + builder: (final context, final sortedNonFavorites) => CustomListView( + scrollKey: 'notes-notes', + withFloatingActionButton: true, + items: [...sortedFavorites, ...sortedNonFavorites], + builder: _buildNote, + ), + ), + ), + ), + ], + ] + .intersperse( + const SizedBox( + height: 10, + ), + ) + .toList(), + ), + ), + ), + ); + + Widget _buildNote( + final BuildContext context, + final NotesNote note, + ) => + ListTile( + title: Text(note.title!), + subtitle: Row( + children: [ + Text( + CustomTimeAgo.format( + DateTime.fromMillisecondsSinceEpoch(note.modified! * 1000), + ), + ), + if (note.category! != '') ...[ + const SizedBox( + width: 8, + ), + Icon( + MdiIcons.tag, + size: 14, + color: NotesCategoryColor.compute(note.category!), + ), + const SizedBox( + width: 2, + ), + Text(note.category!), + ], + ], + ), + trailing: IconButton( + icon: Icon( + note.favorite! ? Icons.star : Icons.star_outline, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + bloc.updateNote( + note.id!, + note.etag!, + NotesNote( + favorite: !note.favorite!, + ), + ); + }, + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => NotesNotePage( + bloc: bloc, + note: note, + ), + ), + ); + }, + onLongPress: () async { + final result = await showConfirmationDialog( + context, + AppLocalizations.of(context).notesDeleteNoteConfirm(note.title!), + ); + if (result) { + bloc.deleteNote(note); + } + }, + ); +} diff --git a/packages/harbour/lib/src/blocs/accounts.dart b/packages/harbour/lib/src/blocs/accounts.dart new file mode 100644 index 00000000..ec0c1800 --- /dev/null +++ b/packages/harbour/lib/src/blocs/accounts.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:harbour/src/harbour.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'accounts.rxb.g.dart'; + +abstract class AccountsBlocEvents { + void addAccount(final Account account); + void removeAccount(final Account account); + void updateAccount(final Account account); + void setActiveAccount(final Account? account); +} + +abstract class AccountsBlocStates { + BehaviorSubject> get accounts; + BehaviorSubject get activeAccount; +} + +@RxBloc() +class AccountsBloc extends $AccountsBloc { + AccountsBloc( + this._requestManager, + this._storage, + this._sharedPreferences, + this._globalOptions, + ) { + _accountsSubject.listen((final accounts) async { + _globalOptions.updateAccounts(accounts); + await _storage.setStringList(_keyAccounts, accounts.map((final a) => json.encode(a.toJson())).toList()); + }); + + _$setActiveAccountEvent.listen((final account) async { + if (account != null) { + await _storage.setString(_keyLastUsedAccount, account.id); + _activeAccountSubject.add(account); + } else { + final accounts = _accountsSubject.value; + if (accounts.isNotEmpty) { + setActiveAccount(accounts[0]); + } else { + await _storage.remove(_keyLastUsedAccount); + _activeAccountSubject.add(null); + } + } + }); + + _$addAccountEvent.listen((final account) async { + if (_activeAccountSubject.valueOrNull == null) { + setActiveAccount(account); + } + final accounts = _accountsSubject.value; + _accountsSubject.add(accounts..add(account)); + }); + + _$removeAccountEvent.listen((final account) async { + final accounts = _accountsSubject.value..removeWhere((final a) => a.id == account.id); + _accountsSubject.add(accounts); + + final activeAccount = _activeAccountSubject.valueOrNull; + if (activeAccount != null && activeAccount.id == account.id) { + setActiveAccount(accounts.isNotEmpty ? accounts[0] : null); + } + }); + + _$updateAccountEvent.listen((final account) async { + final accounts = _accountsSubject.value; + final index = accounts.indexWhere((final a) => a.id == account.id); + if (index == -1) { + // TODO: Figure out how we can remove the old account without potentially race conditioning + accounts.add(account); + } else { + accounts.replaceRange( + index, + index + 1, + [account], + ); + } + + _accountsSubject.add(accounts); + setActiveAccount(account); + }); + + if (_storage.containsKey(_keyAccounts)) { + _accountsSubject.add( + _storage + .getStringList(_keyAccounts)! + .map((final a) => Account.fromJson(json.decode(a) as Map)) + .toList(), + ); + } + + final accounts = _accountsSubject.value; + if (_globalOptions.rememberLastUsedAccount.value && _storage.containsKey(_keyLastUsedAccount)) { + final lastUsedAccountID = _storage.getString(_keyLastUsedAccount); + _activeAccountSubject.add(accounts.singleWhere((final account) => account.id == lastUsedAccountID)); + } else { + // ignore: discarded_futures + _globalOptions.lastAccount.stream.first.then((final lastAccount) { + final matches = accounts.where((final account) => account.id == lastAccount).toList(); + if (matches.isNotEmpty) { + _activeAccountSubject.add(matches[0]); + } + }); + } + } + + AccountSpecificOptions? getOptions([Account? account]) { + account ??= _activeAccountSubject.valueOrNull; + if (account != null) { + final accountID = account.id; + if (_accountsOptions[accountID] != null) { + return _accountsOptions[accountID]; + } + + return _accountsOptions[accountID] = + AccountSpecificOptions(Storage('accounts-${account.id}', _sharedPreferences)); + } + + return null; + } + + final Storage _storage; + final SharedPreferences _sharedPreferences; + final GlobalOptions _globalOptions; + final _keyAccounts = 'accounts'; + final _keyLastUsedAccount = 'last-used-account'; + + final RequestManager _requestManager; + final _accountsOptions = {}; + late final _activeAccountSubject = BehaviorSubject.seeded(null); + late final _accountsSubject = BehaviorSubject>.seeded([]); + + final Map _userDetailsBlocs = {}; + final Map _userStatusBlocs = {}; + + UserDetailsBloc getUserDetailsBloc(final Account account) { + if (_userDetailsBlocs[account] != null) { + return _userDetailsBlocs[account]!; + } + + final bloc = UserDetailsBloc(_requestManager, account.client); + _userDetailsBlocs[account] = bloc; + + return bloc; + } + + UserStatusBloc getUserStatusBloc(final Account account) { + if (_userStatusBlocs[account] != null) { + return _userStatusBlocs[account]!; + } + + final bloc = UserStatusBloc(_requestManager, account, _activeAccountSubject); + _userStatusBlocs[account] = bloc; + + return bloc; + } + + @override + void dispose() { + // ignore: discarded_futures + _activeAccountSubject.close(); + // ignore: discarded_futures + _accountsSubject.close(); + for (final bloc in _userDetailsBlocs.values) { + bloc.dispose(); + } + for (final bloc in _userStatusBlocs.values) { + bloc.dispose(); + } + for (final options in _accountsOptions.values) { + options.dispose(); + } + super.dispose(); + } + + @override + BehaviorSubject> _mapToAccountsState() => _accountsSubject; + + @override + BehaviorSubject _mapToActiveAccountState() => _activeAccountSubject; +} diff --git a/packages/harbour/lib/src/blocs/accounts.rxb.g.dart b/packages/harbour/lib/src/blocs/accounts.rxb.g.dart new file mode 100644 index 00000000..ec737a74 --- /dev/null +++ b/packages/harbour/lib/src/blocs/accounts.rxb.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'accounts.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class AccountsBlocType extends RxBlocTypeBase { + AccountsBlocEvents get events; + AccountsBlocStates get states; +} + +/// [$AccountsBloc] extended by the [AccountsBloc] +/// {@nodoc} +abstract class $AccountsBloc extends RxBlocBase implements AccountsBlocEvents, AccountsBlocStates, AccountsBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [addAccount] + final _$addAccountEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [removeAccount] + final _$removeAccountEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [updateAccount] + final _$updateAccountEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [setActiveAccount] + final _$setActiveAccountEvent = PublishSubject(); + + /// The state of [accounts] implemented in [_mapToAccountsState] + late final BehaviorSubject> _accountsState = _mapToAccountsState(); + + /// The state of [activeAccount] implemented in [_mapToActiveAccountState] + late final BehaviorSubject _activeAccountState = _mapToActiveAccountState(); + + @override + void addAccount(Account account) => _$addAccountEvent.add(account); + + @override + void removeAccount(Account account) => _$removeAccountEvent.add(account); + + @override + void updateAccount(Account account) => _$updateAccountEvent.add(account); + + @override + void setActiveAccount(Account? account) => _$setActiveAccountEvent.add(account); + + @override + BehaviorSubject> get accounts => _accountsState; + + @override + BehaviorSubject get activeAccount => _activeAccountState; + + BehaviorSubject> _mapToAccountsState(); + + BehaviorSubject _mapToActiveAccountState(); + + @override + AccountsBlocEvents get events => this; + + @override + AccountsBlocStates get states => this; + + @override + void dispose() { + _$addAccountEvent.close(); + _$removeAccountEvent.close(); + _$updateAccountEvent.close(); + _$setActiveAccountEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/blocs/apps.dart b/packages/harbour/lib/src/blocs/apps.dart new file mode 100644 index 00000000..9a388185 --- /dev/null +++ b/packages/harbour/lib/src/blocs/apps.dart @@ -0,0 +1,144 @@ +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'apps.rxb.g.dart'; + +typedef NextcloudApp = CoreNavigationAppsOcsDataInner; + +abstract class AppsBlocEvents { + void refresh(); + void setActiveApp(final String? appID); +} + +abstract class AppsBlocStates { + BehaviorSubject>> get apps; + + BehaviorSubject>> get appImplementations; + + BehaviorSubject get activeAppID; +} + +@RxBloc() +class AppsBloc extends $AppsBloc { + AppsBloc( + this._requestManager, + this._accountsBloc, + this._account, + this._allAppImplementations, + ) { + _$refreshEvent.listen((final _) => _loadApps); + _$setActiveAppEvent.listen((final appId) async { + final data = (await _appImplementationsSubject.firstWhere((final result) => result.data != null)).data!; + if (data.where((final app) => app.id == appId).isNotEmpty) { + _activeAppSubject.add(appId); + } + }); + + _appsSubject.listen((final result) { + if (result is ResultLoading) { + _appImplementationsSubject.add(Result.loading()); + } else if (result is ResultError) { + _appImplementationsSubject.add(Result.error((result as ResultError).error)); + } else if (result is ResultSuccess) { + _appImplementationsSubject.add( + Result.success(_getMatchingAppImplementations((result as ResultSuccess>).data)), + ); + } else if (result is ResultCached && result.data != null) { + _appImplementationsSubject.add( + Result.success(_getMatchingAppImplementations((result as ResultCached>).data)), + ); + } + + final matchingApps = result.data != null ? _getMatchingApps(result.data!) : []; + final options = _accountsBloc.getOptions(_account)!..updateApps(matchingApps); + + if (result.data != null) { + // ignore: discarded_futures + options.initialApp.stream.first.then((var initialApp) { + if (initialApp == null) { + if (matchingApps.where((final app) => app.id == 'files').isNotEmpty) { + initialApp = 'files'; + } else if (matchingApps.isNotEmpty) { + // This should never happen, because the files app is always installed and can not be removed, but just in + // case this changes at a later point. + initialApp = matchingApps[0].id; + } + } + if (!_activeAppSubject.hasValue) { + setActiveApp(initialApp); + } + }); + } + }); + + _loadApps(); + } + + // This implementation could be easier, but we want to keep the apps in order + List _getMatchingAppImplementations(final List apps) => apps + .map((final a) => _allAppImplementations.where((final b) => b.id == a.id)) + .reduce((final value, final element) => [...value, ...element]) + .toList(); + + List _getMatchingApps(final List apps) => + apps.where((final a) => _allAppImplementations.where((final b) => b.id == a.id).isNotEmpty).toList(); + + void _loadApps() { + _requestManager + .wrapNextcloud, CoreNavigationApps, void, NextcloudCoreClient>( + _account.client.id, + _account.client.core, + 'apps-apps', + () async => (await _account.client.core.getNavigationApps())!, + (final response) => response.ocs!.data, + preloadCache: true, + ) + .listen(_appsSubject.add); + } + + final RequestManager _requestManager; + final AccountsBloc _accountsBloc; + final Account _account; + final List _allAppImplementations; + + final _appsSubject = BehaviorSubject>>(); + final _appImplementationsSubject = BehaviorSubject>>(); + late final _activeAppSubject = BehaviorSubject(); + + final Map _blocs = {}; + + T getAppBloc(final AppImplementation appImplementation) { + if (_blocs[appImplementation] != null) { + return _blocs[appImplementation]! as T; + } + + final bloc = appImplementation.buildBloc(_account.client); + _blocs[appImplementation] = bloc; + + return bloc as T; + } + + @override + void dispose() { + // ignore: discarded_futures + _appsSubject.close(); + // ignore: discarded_futures + _activeAppSubject.close(); + for (final key in _blocs.keys) { + _blocs[key]!.dispose(); + } + super.dispose(); + } + + @override + BehaviorSubject>> _mapToAppsState() => _appsSubject; + + @override + BehaviorSubject>>> + _mapToAppImplementationsState() => _appImplementationsSubject; + + @override + BehaviorSubject _mapToActiveAppIDState() => _activeAppSubject; +} diff --git a/packages/harbour/lib/src/blocs/apps.rxb.g.dart b/packages/harbour/lib/src/blocs/apps.rxb.g.dart new file mode 100644 index 00000000..f269ec91 --- /dev/null +++ b/packages/harbour/lib/src/blocs/apps.rxb.g.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'apps.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class AppsBlocType extends RxBlocTypeBase { + AppsBlocEvents get events; + AppsBlocStates get states; +} + +/// [$AppsBloc] extended by the [AppsBloc] +/// {@nodoc} +abstract class $AppsBloc extends RxBlocBase implements AppsBlocEvents, AppsBlocStates, AppsBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [refresh] + final _$refreshEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [setActiveApp] + final _$setActiveAppEvent = PublishSubject(); + + /// The state of [apps] implemented in [_mapToAppsState] + late final BehaviorSubject>> _appsState = _mapToAppsState(); + + /// The state of [appImplementations] implemented in + /// [_mapToAppImplementationsState] + late final BehaviorSubject>>> + _appImplementationsState = _mapToAppImplementationsState(); + + /// The state of [activeAppID] implemented in [_mapToActiveAppIDState] + late final BehaviorSubject _activeAppIDState = _mapToActiveAppIDState(); + + @override + void refresh() => _$refreshEvent.add(null); + + @override + void setActiveApp(String? appID) => _$setActiveAppEvent.add(appID); + + @override + BehaviorSubject>> get apps => _appsState; + + @override + BehaviorSubject>>> get appImplementations => + _appImplementationsState; + + @override + BehaviorSubject get activeAppID => _activeAppIDState; + + BehaviorSubject>> _mapToAppsState(); + + BehaviorSubject>>> + _mapToAppImplementationsState(); + + BehaviorSubject _mapToActiveAppIDState(); + + @override + AppsBlocEvents get events => this; + + @override + AppsBlocStates get states => this; + + @override + void dispose() { + _$refreshEvent.close(); + _$setActiveAppEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/blocs/capabilities.dart b/packages/harbour/lib/src/blocs/capabilities.dart new file mode 100644 index 00000000..ea6a0a5d --- /dev/null +++ b/packages/harbour/lib/src/blocs/capabilities.dart @@ -0,0 +1,53 @@ +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'capabilities.rxb.g.dart'; + +typedef Capabilities = CoreServerCapabilitiesOcsDataCapabilities; +typedef NextcloudTheme = CoreServerCapabilitiesOcsDataCapabilitiesTheming; + +abstract class CapabilitiesBlocEvents {} + +abstract class CapabilitiesBlocStates { + BehaviorSubject> get capabilities; +} + +@RxBloc() +class CapabilitiesBloc extends $CapabilitiesBloc { + CapabilitiesBloc( + this._requestManager, + this._client, + ) { + _loadCapabilities(); + } + + void _loadCapabilities() { + _requestManager + .wrapNextcloud( + _client.id, + _client.core, + 'capabilities', + () async => (await _client.core.getCapabilities())!, + (final response) => response.ocs!.data!.capabilities!, + preloadCache: true, + ) + .listen(_capabilitiesSubject.add); + } + + final RequestManager _requestManager; + final NextcloudClient _client; + + final _capabilitiesSubject = BehaviorSubject>(); + + @override + void dispose() { + // ignore: discarded_futures + _capabilitiesSubject.close(); + super.dispose(); + } + + @override + BehaviorSubject> _mapToCapabilitiesState() => _capabilitiesSubject; +} diff --git a/packages/harbour/lib/src/blocs/capabilities.rxb.g.dart b/packages/harbour/lib/src/blocs/capabilities.rxb.g.dart new file mode 100644 index 00000000..de7577c0 --- /dev/null +++ b/packages/harbour/lib/src/blocs/capabilities.rxb.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'capabilities.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class CapabilitiesBlocType extends RxBlocTypeBase { + CapabilitiesBlocEvents get events; + CapabilitiesBlocStates get states; +} + +/// [$CapabilitiesBloc] extended by the [CapabilitiesBloc] +/// {@nodoc} +abstract class $CapabilitiesBloc extends RxBlocBase + implements CapabilitiesBlocEvents, CapabilitiesBlocStates, CapabilitiesBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// The state of [capabilities] implemented in [_mapToCapabilitiesState] + late final BehaviorSubject> _capabilitiesState = + _mapToCapabilitiesState(); + + @override + BehaviorSubject> get capabilities => _capabilitiesState; + + BehaviorSubject> _mapToCapabilitiesState(); + + @override + CapabilitiesBlocEvents get events => this; + + @override + CapabilitiesBlocStates get states => this; + + @override + void dispose() { + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/blocs/login.dart b/packages/harbour/lib/src/blocs/login.dart new file mode 100644 index 00000000..29d4e5cb --- /dev/null +++ b/packages/harbour/lib/src/blocs/login.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'login.rxb.g.dart'; + +abstract class LoginBlocEvents { + void setServerURL(final String? url); +} + +abstract class LoginBlocStates { + BehaviorSubject get serverURL; + + BehaviorSubject get serverConnectionState; + + BehaviorSubject get loginFlowInit; + + BehaviorSubject get loginFlowResult; +} + +@RxBloc() +class LoginBloc extends $LoginBloc { + LoginBloc() { + _$setServerURLEvent.listen((final url) async { + _serverURLSubject.add(url); + _loginFlowInitSubject.add(null); + _loginFlowResultSubject.add(null); + _serverConnectionStateSubject.add(url != null ? ServerConnectionState.loading : null); + + if (url != null) { + try { + final client = NextcloudClient( + url, + userAgentSuffix: userAgentSuffix, + appType: appType, + ); + + final status = (await client.core.getStatus())!; + if (status.maintenance!) { + _serverConnectionStateSubject.add(ServerConnectionState.maintenanceMode); + return; + } + + _serverConnectionStateSubject.add(ServerConnectionState.success); + + final init = await client.core.initLoginFlow(); + _loginFlowInitSubject.add(init); + + _cancelPollTimer(); + _pollTimer = Timer.periodic(const Duration(seconds: 2), (final _) async { + try { + final result = await client.core.getLoginFlowResult(CoreLoginFlowQuery(token: init!.poll!.token!)); + _cancelPollTimer(); + _loginFlowResultSubject.add(result); + } catch (e) { + debugPrint(e.toString()); + } + }); + } catch (e) { + debugPrint(e.toString()); + _serverConnectionStateSubject.add(ServerConnectionState.unreachable); + } + } + }); + } + + void _cancelPollTimer() { + if (_pollTimer != null) { + _pollTimer!.cancel(); + _pollTimer = null; + } + } + + final _serverURLSubject = BehaviorSubject.seeded(null); + final _serverConnectionStateSubject = BehaviorSubject.seeded(null); + final _loginFlowInitSubject = BehaviorSubject.seeded(null); + final _loginFlowResultSubject = BehaviorSubject.seeded(null); + Timer? _pollTimer; + + @override + void dispose() { + _cancelPollTimer(); + // ignore: discarded_futures + _serverURLSubject.close(); + // ignore: discarded_futures + _serverConnectionStateSubject.close(); + // ignore: discarded_futures + _loginFlowInitSubject.close(); + // ignore: discarded_futures + _loginFlowResultSubject.close(); + super.dispose(); + } + + @override + BehaviorSubject _mapToServerURLState() => _serverURLSubject; + + @override + BehaviorSubject _mapToServerConnectionStateState() => _serverConnectionStateSubject; + + @override + BehaviorSubject _mapToLoginFlowInitState() => _loginFlowInitSubject; + + @override + BehaviorSubject _mapToLoginFlowResultState() => _loginFlowResultSubject; +} + +enum ServerConnectionState { + loading, + unreachable, + maintenanceMode, + success, +} diff --git a/packages/harbour/lib/src/blocs/login.rxb.g.dart b/packages/harbour/lib/src/blocs/login.rxb.g.dart new file mode 100644 index 00000000..bb76f6a2 --- /dev/null +++ b/packages/harbour/lib/src/blocs/login.rxb.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'login.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class LoginBlocType extends RxBlocTypeBase { + LoginBlocEvents get events; + LoginBlocStates get states; +} + +/// [$LoginBloc] extended by the [LoginBloc] +/// {@nodoc} +abstract class $LoginBloc extends RxBlocBase implements LoginBlocEvents, LoginBlocStates, LoginBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [setServerURL] + final _$setServerURLEvent = PublishSubject(); + + /// The state of [serverURL] implemented in [_mapToServerURLState] + late final BehaviorSubject _serverURLState = _mapToServerURLState(); + + /// The state of [serverConnectionState] implemented in + /// [_mapToServerConnectionStateState] + late final BehaviorSubject _serverConnectionStateState = _mapToServerConnectionStateState(); + + /// The state of [loginFlowInit] implemented in [_mapToLoginFlowInitState] + late final BehaviorSubject _loginFlowInitState = _mapToLoginFlowInitState(); + + /// The state of [loginFlowResult] implemented in [_mapToLoginFlowResultState] + late final BehaviorSubject _loginFlowResultState = _mapToLoginFlowResultState(); + + @override + void setServerURL(String? url) => _$setServerURLEvent.add(url); + + @override + BehaviorSubject get serverURL => _serverURLState; + + @override + BehaviorSubject get serverConnectionState => _serverConnectionStateState; + + @override + BehaviorSubject get loginFlowInit => _loginFlowInitState; + + @override + BehaviorSubject get loginFlowResult => _loginFlowResultState; + + BehaviorSubject _mapToServerURLState(); + + BehaviorSubject _mapToServerConnectionStateState(); + + BehaviorSubject _mapToLoginFlowInitState(); + + BehaviorSubject _mapToLoginFlowResultState(); + + @override + LoginBlocEvents get events => this; + + @override + LoginBlocStates get states => this; + + @override + void dispose() { + _$setServerURLEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/blocs/user_details.dart b/packages/harbour/lib/src/blocs/user_details.dart new file mode 100644 index 00000000..27fb9dc2 --- /dev/null +++ b/packages/harbour/lib/src/blocs/user_details.dart @@ -0,0 +1,50 @@ +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'user_details.rxb.g.dart'; + +abstract class UserDetailsBlocEvents {} + +abstract class UserDetailsBlocStates { + BehaviorSubject> get userDetails; +} + +@RxBloc() +class UserDetailsBloc extends $UserDetailsBloc { + UserDetailsBloc( + this._requestManager, + this._client, + ) { + _loadUserDetails(); + } + + void _loadUserDetails() { + _requestManager + .wrapNextcloud( + _client.id, + _client.provisioningApi, + 'user-details', + () async => (await _client.provisioningApi.getCurrentUser())!, + (final response) => response.ocs!.data!, + preloadCache: true, + ) + .listen(_userDetailsSubject.add); + } + + final RequestManager _requestManager; + final NextcloudClient _client; + + final _userDetailsSubject = BehaviorSubject>(); + + @override + void dispose() { + // ignore: discarded_futures + _userDetailsSubject.close(); + super.dispose(); + } + + @override + BehaviorSubject> _mapToUserDetailsState() => _userDetailsSubject; +} diff --git a/packages/harbour/lib/src/blocs/user_details.rxb.g.dart b/packages/harbour/lib/src/blocs/user_details.rxb.g.dart new file mode 100644 index 00000000..b1e0adc1 --- /dev/null +++ b/packages/harbour/lib/src/blocs/user_details.rxb.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'user_details.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class UserDetailsBlocType extends RxBlocTypeBase { + UserDetailsBlocEvents get events; + UserDetailsBlocStates get states; +} + +/// [$UserDetailsBloc] extended by the [UserDetailsBloc] +/// {@nodoc} +abstract class $UserDetailsBloc extends RxBlocBase + implements UserDetailsBlocEvents, UserDetailsBlocStates, UserDetailsBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// The state of [userDetails] implemented in [_mapToUserDetailsState] + late final BehaviorSubject> _userDetailsState = _mapToUserDetailsState(); + + @override + BehaviorSubject> get userDetails => _userDetailsState; + + BehaviorSubject> _mapToUserDetailsState(); + + @override + UserDetailsBlocEvents get events => this; + + @override + UserDetailsBlocStates get states => this; + + @override + void dispose() { + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/blocs/user_status.dart b/packages/harbour/lib/src/blocs/user_status.dart new file mode 100644 index 00000000..22e209cb --- /dev/null +++ b/packages/harbour/lib/src/blocs/user_status.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'user_status.rxb.g.dart'; + +abstract class UserStatusBlocEvents {} + +abstract class UserStatusBlocStates { + BehaviorSubject> get userStatus; +} + +@RxBloc() +class UserStatusBloc extends $UserStatusBloc { + UserStatusBloc( + this._requestManager, + this._account, + this._activeAccountStream, + ) { + _activeAccountStreamSubscription = _activeAccountStream.listen((final activeAccount) { + _cancelTimer(); + final thisAccountActive = activeAccount == _account; + _timer = instantPeriodicTimer( + const Duration(minutes: 5), + (final _) async { + if (thisAccountActive) { + await _heartbeat(); + } + _loadUserStatus(); + }, + ); + }); + } + + void _loadUserStatus() { + _requestManager + .wrapNextcloud( + _account.client.id, + _account.client.userStatus, + 'user-status', + () async => (await _account.client.userStatus.getStatus())!, + (final response) => response.ocs?.data, + preloadCache: true, + ) + .listen(_userStatusSubject.add); + } + + Future _heartbeat() async { + return; + + // TODO: https://github.com/jld3103/nextcloud-harbour/issues/10 + // ignore: dead_code + try { + await _account.client.userStatus.heartbeat(UserStatusHeartbeat(status: UserStatusTypeEnum.online)); + } catch (e) { + debugPrint(e.toString()); + } + } + + void _cancelTimer() { + if (_timer != null) { + _timer!.cancel(); + _timer = null; + } + } + + final RequestManager _requestManager; + final Account _account; + final BehaviorSubject _activeAccountStream; + late final StreamSubscription _activeAccountStreamSubscription; + Timer? _timer; + + final _userStatusSubject = BehaviorSubject>(); + + @override + void dispose() { + _cancelTimer(); + // ignore: discarded_futures + _activeAccountStreamSubscription.cancel(); + // ignore: discarded_futures + _userStatusSubject.close(); + super.dispose(); + } + + @override + BehaviorSubject> _mapToUserStatusState() => _userStatusSubject; +} + +Timer instantPeriodicTimer( + final Duration duration, + final void Function(Timer timer) callback, +) { + final timer = Timer.periodic(duration, callback); + callback(timer); + return timer; +} diff --git a/packages/harbour/lib/src/blocs/user_status.rxb.g.dart b/packages/harbour/lib/src/blocs/user_status.rxb.g.dart new file mode 100644 index 00000000..2f6a0f5c --- /dev/null +++ b/packages/harbour/lib/src/blocs/user_status.rxb.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'user_status.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class UserStatusBlocType extends RxBlocTypeBase { + UserStatusBlocEvents get events; + UserStatusBlocStates get states; +} + +/// [$UserStatusBloc] extended by the [UserStatusBloc] +/// {@nodoc} +abstract class $UserStatusBloc extends RxBlocBase + implements UserStatusBlocEvents, UserStatusBlocStates, UserStatusBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// The state of [userStatus] implemented in [_mapToUserStatusState] + late final BehaviorSubject> _userStatusState = _mapToUserStatusState(); + + @override + BehaviorSubject> get userStatus => _userStatusState; + + BehaviorSubject> _mapToUserStatusState(); + + @override + UserStatusBlocEvents get events => this; + + @override + UserStatusBlocStates get states => this; + + @override + void dispose() { + _compositeSubscription.dispose(); + super.dispose(); + } +} diff --git a/packages/harbour/lib/src/harbour.dart b/packages/harbour/lib/src/harbour.dart new file mode 100644 index 00000000..0202481b --- /dev/null +++ b/packages/harbour/lib/src/harbour.dart @@ -0,0 +1,95 @@ +library harbour; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:http/http.dart'; +import 'package:http/http.dart' as http; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:quick_actions/quick_actions.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:settings/settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sort_box/sort_box.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:tray_manager/tray_manager.dart' as tray; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:xdg_directories/xdg_directories.dart' as xdg; + +export 'package:harbour/l10n/localizations.dart'; +export 'package:harbour/src/apps/files/app.dart' show FilesApp, FilesAppSpecificOptions; +export 'package:harbour/src/apps/files/blocs/files.dart'; +export 'package:harbour/src/apps/news/app.dart' show NewsApp, NewsAppSpecificOptions; +export 'package:harbour/src/apps/news/blocs/articles.dart'; +export 'package:harbour/src/apps/news/blocs/news.dart'; +export 'package:harbour/src/apps/notes/app.dart' show NotesApp, NotesAppSpecificOptions; +export 'package:harbour/src/apps/notes/blocs/notes.dart'; +export 'package:harbour/src/blocs/accounts.dart'; +export 'package:harbour/src/blocs/apps.dart'; +export 'package:harbour/src/blocs/capabilities.dart'; +export 'package:harbour/src/blocs/login.dart'; +export 'package:harbour/src/blocs/user_details.dart'; +export 'package:harbour/src/blocs/user_status.dart'; +export 'package:harbour/src/harbour.dart'; +export 'package:harbour/src/models/account.dart'; +export 'package:harbour/src/widgets/custom_auto_complete.dart'; + +part 'pages/home/home.dart'; +part 'pages/home/widgets/server_status.dart'; +part 'pages/login/login.dart'; +part 'pages/settings/account_specific_settings.dart'; +part 'pages/settings/nextcloud_app_specific_settings.dart'; +part 'pages/settings/settings.dart'; +part 'pages/settings/widgets/account_settings_tile.dart'; +part 'platforms/abstract.dart'; +part 'platforms/android.dart'; +part 'platforms/linux.dart'; +part 'utils/app_implementation.dart'; +part 'utils/confirmation_dialog.dart'; +part 'utils/custom_timeago.dart'; +part 'utils/env.dart'; +part 'utils/global_options.dart'; +part 'utils/hex_color.dart'; +part 'utils/missing_permission_exception.dart'; +part 'utils/nextcloud_app_specific_options.dart'; +part 'utils/rename_dialog.dart'; +part 'utils/request_manager.dart'; +part 'utils/save_file.dart'; +part 'utils/settings_export_helper.dart'; +part 'utils/sort_box_builder.dart'; +part 'utils/sort_box_order_option_values.dart'; +part 'utils/storage.dart'; +part 'utils/theme.dart'; +part 'utils/validators.dart'; +part 'widgets/account_avatar.dart'; +part 'widgets/account_tile.dart'; +part 'widgets/cached_url_image.dart'; +part 'widgets/custom_dialog.dart'; +part 'widgets/custom_linear_progress_indicator.dart'; +part 'widgets/custom_listview.dart'; +part 'widgets/exception.dart'; +part 'widgets/harbour_logo.dart'; +part 'widgets/nextcloud_logo.dart'; +part 'widgets/result_stream_builder.dart'; +part 'widgets/standard_rx_result_builder.dart'; diff --git a/packages/harbour/lib/src/models/account.dart b/packages/harbour/lib/src/models/account.dart new file mode 100644 index 00000000..7b29d374 --- /dev/null +++ b/packages/harbour/lib/src/models/account.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:harbour/src/harbour.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:settings/settings.dart'; + +part 'account.g.dart'; + +// TODO: https://github.com/jld3103/nextcloud-harbour/issues/9 +const userAgentSuffix = ' // Harbour'; +const appType = AppType.nextcloud; + +@JsonSerializable() +class Account { + Account({ + required this.serverURL, + required this.username, + this.password, + this.appPassword, + }) : assert( + (password != null && appPassword == null) || (password == null && appPassword != null), + 'Either password or appPassword has to be set', + ); + + factory Account.fromJson(final Map json) => _$AccountFromJson(json); + Map toJson() => _$AccountToJson(this); + + final String serverURL; + final String username; + final String? password; + final String? appPassword; + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(final Object other) => + other is Account && + other.serverURL == serverURL && + other.username == username && + other.password == password && + other.appPassword == appPassword; + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => serverURL.hashCode + username.hashCode; + + String get id => client.id; + + NextcloudClient? _client; + + NextcloudClient get client => _client ??= NextcloudClient( + serverURL, + username: username, + password: password ?? appPassword, + userAgentSuffix: userAgentSuffix, + appType: appType, + ); +} + +Map _idCache = {}; + +extension NextcloudClientID on NextcloudClient { + String get id { + final key = '$username@$baseURL'; + if (_idCache[key] != null) { + return _idCache[key]!; + } + return _idCache[key] = sha1.convert(utf8.encode(key)).toString(); + } +} + +class AccountSpecificOptions { + AccountSpecificOptions(this._storage); + + final Storage _storage; + final _appIDsSubject = BehaviorSubject>(); + + late final List