diff --git a/.firebaserc b/.firebaserc index e04fa1eea..3a6f775f0 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,17 @@ { "projects": { "default": "ramaz-go" + }, + "targets": { + "ramaz-go": { + "hosting": { + "test": [ + "ramlife-test" + ], + "main": [ + "ramaz-go" + ] + } + } } -} +} \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..5e1638ed5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,22 @@ +# Brayden Kohler has backend: data, testing, services and Firebase +/firebase/ @BraydenKO +/lib/data.dart @BraydenKO +/lib/services.dart @BraydenKO +/lib/src/data @BraydenKO +/lib/src/services @BraydenKO +/test/ @BraydenKO + +# David Tarrab has frontend and android +/android/ @DavidTarrab +/images/ @DavidTarrab +/lib/app.dart @DavidTarrab +/lib/main.dart @DavidTarrab +/lib/widgets.dart @DavidTarrab +/lib/pages.dart @DavidTarrab +/lib/src/pages/ @DavidTarrab +/lib/src/widgets/ @DavidTarrab + +# Josh has middleware and ios +/ios/ @todesj +/lib/models.dart @todesj +/lib/src/models/ @todesj diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml new file mode 100644 index 000000000..c2653864d --- /dev/null +++ b/.github/workflows/analyze.yml @@ -0,0 +1,33 @@ +name: Analyzer + +on: pull_request + +jobs: + analyze: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./repo + + steps: + - name: Cache Flutter + id: cache-flutter + uses: actions/cache@v2 + with: + path: flutter/ + key: ${{ runner.os }}-flutter + + - name: Install Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: britannio/action-install-flutter@v1.0 + + - uses: actions/checkout@v2 + with: + path: repo # keeps Flutter separate from repo + + # Has to be after checkout since repo won't exist + - name: Add Flutter to path + run: echo "../flutter/bin" >> $GITHUB_PATH + + - name: Analyze + run: flutter analyze --dartdocs diff --git a/.github/workflows/blocking-issues.yml b/.github/workflows/blocking-issues.yml new file mode 100644 index 000000000..c37e5a594 --- /dev/null +++ b/.github/workflows/blocking-issues.yml @@ -0,0 +1,15 @@ +name: Blocking Issues + +on: + issues: + types: [closed] + pull_request_target: + types: [opened, synchronize, edited] + +jobs: + blocking_issues: + runs-on: ubuntu-latest + name: Checks for blocking issues + + steps: + - uses: Levi-Lesches/blocking-issues@v1 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 000000000..c7b3a2354 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,74 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Documentation + +on: + push: + branches: [ master ] + +jobs: + documentation: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./repo + steps: + - name: Cache Flutter + id: cache-flutter + uses: actions/cache@v2 + with: + path: flutter/ + key: ${{ runner.os }}-flutter + + - name: Install Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: britannio/action-install-flutter@v1.0 + + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + path: repo # keep Flutter separate + + - name: Git Setup + run: | + git config --local user.name "github-actions[bot]" + git branch --all + git switch --track origin/documentation + git merge origin/master + rm -rf docs + git stage docs + git commit -m "Removed documentation" -m "Will generate new docs" + + - name: Add Flutter to path + run: echo "../flutter/bin" >> $GITHUB_PATH + + - name: Flutter Setup + run: | + flutter packages get + flutter pub global activate dartdoc + flutter pub global run dartdoc --version + flutter --version + + - name: Analyze code + run: flutter analyze --dartdocs + + - name: Output error + if: failure() + run: echo "::error The code or is missing documentation. Run flutter analyze --dartdocs" + + - name: Generate documentation + run: flutter pub global run dartdoc --ignore 'unresolved-doc-reference,not-implemented,no-documentable-libraries,ambiguous-reexport' --exclude 'dart:async,dart:collection,dart:convert,dart:core,dart:developer,dart:io,dart:isolate,dart:math,dart:typed_data,dart:ui,dart:html,dart:js,dart:ffi,dart:js_util' --quiet --json --output docs --no-validate-links --no-verbose-warnings --no-allow-non-local-warnings + + - name: Commit files + run: | + cd docs + cd .. + git status + git stage --force docs + git commit -a -m "Generated documentation" + + - name: Push commit + run: git push diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml new file mode 100644 index 000000000..5c330f2ef --- /dev/null +++ b/.github/workflows/firebase-hosting-merge.yml @@ -0,0 +1,49 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting + +on: + push: + branches: master + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./repo + + steps: + - name: Get Flutter from the cache + id: cache-flutter + uses: actions/cache@v2 + with: + path: flutter/ + key: ${{ runner.os }}-flutter + + - name: Install Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: britannio/action-install-flutter@v1.0 + + - uses: actions/checkout@v2 + with: + path: repo + + - name: Add Flutter to path + run: echo "../flutter/bin" >> $GITHUB_PATH + + - name: Build web app + run: flutter build web + + - name: Deploy web app + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_RAMAZ_GO }}' + channelId: live + projectId: ramaz-go + entryPoint: ./repo + target: main + env: + FIREBASE_CLI_PREVIEWS: hostingchannels diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml new file mode 100644 index 000000000..d236c0a1e --- /dev/null +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -0,0 +1,47 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy web preview +on: pull_request + +jobs: + build_and_preview: + if: ${{ github.event.pull_request.head.repo.full_name == 'Ramaz-Upper-School/RamLife' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./repo + + steps: + - name: Get Flutter from the cache + id: cache-flutter + uses: actions/cache@v2 + with: + path: flutter/ + key: ${{ runner.os }}-flutter + + - name: Install Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: britannio/action-install-flutter@v1.0 + + - uses: actions/checkout@v2 + with: + path: repo + + - name: Add Flutter to path + run: echo "../flutter/bin" >> $GITHUB_PATH + + - name: Build web app + run: flutter build web + + - name: Deploy to preview channel + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_RAMAZ_GO }}' + projectId: ramaz-go + entryPoint: ./repo + channelId: live + target: test + env: + FIREBASE_CLI_PREVIEWS: hostingchannels diff --git a/.gitignore b/.gitignore index f3539e182..d9e1580e4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,6 @@ *.txt.yaml todo /data/ -docs/ # Firebase folders: node_modules @@ -54,7 +53,6 @@ firebase/rules_test/firestore.rules # Miscellaneous *.class -*.lock *.log *.pyc *.swp @@ -76,6 +74,7 @@ flutter_*.png linked_*.png unlinked.ds unlinked_spec.ds +**/lib/generated_plugin_registrant.dart # Android related **/android/**/GeneratedPluginRegistrant.java diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7832d9c1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Levi Lesches + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 18f64e0dd..7589a1657 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,9 @@ analyzer: - exclude: [lib/generated_plugin_registrant.dart] + exclude: + - lib/generated_plugin_registrant.dart + - lib/firebase_options.dart + - test/* + - firebase/* linter: rules: - always_declare_return_types @@ -11,8 +15,8 @@ linter: # - avoid_annotating_with_dynamic # - avoid_as # dart 1 only - avoid_bool_literals_in_conditional_expressions - - avoid_catches_without_on_clauses - - avoid_catching_errors + # - avoid_catches_without_on_clauses + # - avoid_catching_errors - avoid_double_and_int_checks - avoid_empty_else - avoid_field_initializers_in_const_classes @@ -54,7 +58,7 @@ linter: - empty_constructor_bodies - empty_statements - file_names - - flutter_style_todos + # - flutter_style_todos - hash_and_equals - implementation_imports - invariant_booleans @@ -119,7 +123,7 @@ linter: - slash_for_doc_comments - sort_child_properties_last # - sort_constructors_first - - sort_pub_dependencies + # - sort_pub_dependencies - sort_unnamed_constructors_first - test_types_in_equals - throw_in_finally diff --git a/android/app/build.gradle b/android/app/build.gradle index 3ccae6a38..09aded9c0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) { } apply plugin: 'com.android.application' -apply plugin: 'io.fabric' +apply plugin: 'com.google.firebase.crashlytics' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { @@ -47,7 +47,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.ramaz.student_life" minSdkVersion 21 - targetSdkVersion 27 + targetSdkVersion 30 multiDexEnabled true versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -83,12 +83,6 @@ flutter { } dependencies { - implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' - implementation 'com.google.firebase:firebase-analytics:17.2.0' - implementation 'com.google.firebase:firebase-auth:18.1.0' - implementation 'com.google.firebase:firebase-core:17.0.1' - implementation 'com.google.firebase:firebase-firestore:20.2.0' - implementation 'com.google.firebase:firebase-messaging:19.0.1' testImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 21e9d7cf0..f564d854f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,11 +12,14 @@ additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> + + @@ -40,13 +43,16 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> - + + + + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme"/> + diff --git a/android/app/src/main/java/com/ramaz/student_life/MainActivity.java b/android/app/src/main/java/com/ramaz/student_life/MainActivity.java index 8f617bcbb..e232ca63b 100644 --- a/android/app/src/main/java/com/ramaz/student_life/MainActivity.java +++ b/android/app/src/main/java/com/ramaz/student_life/MainActivity.java @@ -1,13 +1,7 @@ package com.ramaz.student_life; -import android.os.Bundle; -import io.flutter.app.FlutterActivity; -import io.flutter.plugins.GeneratedPluginRegistrant; +import io.flutter.embedding.android.FlutterActivity; public class MainActivity extends FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - GeneratedPluginRegistrant.registerWith(this); - } + } diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 59e7b3310..8e50bf17b 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -12,4 +12,11 @@ + + diff --git a/android/build.gradle b/android/build.gradle index 04abeaf4c..dccc5cd67 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,15 +2,12 @@ buildscript { repositories { google() jcenter() - maven { - url 'https://maven.fabric.io/public' - } } dependencies { + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.2.0' classpath 'com.android.tools.build:gradle:3.5.1' classpath 'com.google.gms:google-services:4.3.0' - classpath 'io.fabric.tools:gradle:1.31.1' } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2ad09b16a..7c13bbb2b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl = https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl = https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/firebase.json b/firebase.json index 7ecf322ec..d1223ddc3 100644 --- a/firebase.json +++ b/firebase.json @@ -1,12 +1,24 @@ { - "hosting": { - "public": "build/web", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ] - }, + "hosting": [ + { + "target": "main", + "public": "build/web", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ] + }, + { + "target": "test", + "public": "build/web", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ] + } + ], "emulators": { "firestore": { "port": 8080 @@ -16,6 +28,9 @@ }, "ui": { "enabled": true + }, + "auth": { + "port": 9099 } } } diff --git a/firebase/cloud_functions/functions/package-lock.json b/firebase/cloud_functions/functions/package-lock.json index 4eae2a601..268241bd4 100644 --- a/firebase/cloud_functions/functions/package-lock.json +++ b/firebase/cloud_functions/functions/package-lock.json @@ -1491,9 +1491,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.at": { "version": "4.6.0", @@ -1622,9 +1622,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "mkdirp": { @@ -1647,10 +1647,13 @@ "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, "node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", - "optional": true + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "optional": true, + "requires": { + "whatwg-url": "^5.0.0" + } }, "node-forge": { "version": "0.7.4", @@ -1744,9 +1747,9 @@ "dev": true }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "path-to-regexp": { @@ -1761,9 +1764,9 @@ "optional": true }, "protobufjs": { - "version": "6.8.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.9.tgz", - "integrity": "sha512-j2JlRdUeL/f4Z6x4aU4gj9I2LECglC+5qR2TrWb193Tla1qfdaNQTZ8I27Pt7K0Ajmvjjpft7O3KWTGciz4gpw==", + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz", + "integrity": "sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==", "optional": true, "requires": { "@protobufjs/aspromise": "^1.1.2", @@ -1776,15 +1779,15 @@ "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", "long": "^4.0.0" }, "dependencies": { "@types/node": { - "version": "10.17.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.17.tgz", - "integrity": "sha512-gpNnRnZP3VWzzj5k3qrpRC6Rk3H/uclhAVo1aIvwzK5p5cOrs9yEyQ8H/HBsBY0u5rrWxXEiVPQ0dEB6pkjE8Q==", + "version": "17.0.38", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.38.tgz", + "integrity": "sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==", "optional": true } } @@ -2105,6 +2108,12 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "optional": true + }, "tslib": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", @@ -2220,6 +2229,12 @@ "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", "optional": true }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "optional": true + }, "websocket-driver": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.3.tgz", @@ -2235,6 +2250,16 @@ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "optional": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which-boxed-primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz", diff --git a/firebase/firestore-py/.gitignore b/firebase/firestore-py/.gitignore new file mode 100644 index 000000000..119736104 --- /dev/null +++ b/firebase/firestore-py/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +admin.json diff --git a/firebase/firestore-py/bin/admins.py b/firebase/firestore-py/bin/admins.py new file mode 100644 index 000000000..27eb1d0b9 --- /dev/null +++ b/firebase/firestore-py/bin/admins.py @@ -0,0 +1,33 @@ +from firebase_admin import delete_app +import csv + +import lib.utils as utils +import lib.services as firebase + +def get_admins(): + with open(utils.dir.admins) as file: return { + row[0]: row[1:] + for row in csv.reader(file) + } + +def set_claims(admins): + for email, scopes in admins.items(): + if not all(scope in firebase.SCOPES for scope in scopes): + raise ValueError(f"Unrecognized scopes for {email}: {scopes}") + utils.logger.verbose(f"Setting claims for {email}") + if not scopes: + utils.logger.warning(f"Removing admin priveleges for {email}") + if utils.args.debug: + utils.logger.debug(f" Claims for {email}", firebase.get_claims(email)) + if utils.args.should_upload: firebase.set_scopes(email, scopes) + if not utils.args.should_upload: + utils.logger.warning("Did not upload admin claims. Use the --upload flag.") + +if __name__ == '__main__': + utils.logger.info("Setting up admins...") + admins = utils.logger.log_value("admins", get_admins) + set_claims(admins) + + delete_app(firebase.app) + utils.logger.info("Finished settig up admins") + diff --git a/firebase/firestore-py/bin/calendar.py b/firebase/firestore-py/bin/calendar.py new file mode 100644 index 000000000..3b6b746fe --- /dev/null +++ b/firebase/firestore-py/bin/calendar.py @@ -0,0 +1,57 @@ +from firebase_admin import delete_app +import csv + +import lib.services as firebase +import lib.utils as utils + +import lib.data as data +def is_date_line(index): (index - 2) % 7 == 0 +def is_letter_line(index): (index - 4) % 7 == 0 +def is_special_line(index): (index - 5) % 7 == 0 + +SUMMER_MONTHS = {7, 8} + +def get_calendar(month): + with open(utils.dir.get_month(month)) as file: + lines = list(csv.reader(file)) + + calendar = [] + for index, line in enumerate(lines): + if is_date_line(index): date_line = line + elif is_letter_line(index): letter_line = line + elif is_special_line(index): + special_line = line + calendar.extend(Day.get_list( + date_line = date_line, + name_line = name_line, + special_line = special_line, + month = month, + )) + return calendar + + +if __name__ == '__main__': + utils.logger.info("Processing calendar...") + for month in range(1, 13): + # Handle summer months + if month in SUMMER_MONTHS: + utils.logger.verbose(f"Setting a blank calendar for {month} in the summer") + if utils.args.should_upload: + firebase.upload_month(month, data.get_empty_calendar(month)) + continue + + # Parse, verify, and upload {month}.csv + month_calendar = utils.logger.log_value( + f"calendar for {month}", lambda: data.get_default_calendar(month) + ) + verified = data.Day.verify_calendar(month, month_calendar) + assert verified, f"Could not properly parse calendar for {month}" + if (utils.args.should_upload): + firebase.upload_month(month, month_calendar) + + # Cleanup + if not utils.args.should_upload: + utils.logger.warning("Did not upload the calendar. Use the --upload flag.") + delete_app(firebase.app) + utils.logger.info("Calendar processed") + diff --git a/firebase/firestore-py/bin/faculty.py b/firebase/firestore-py/bin/faculty.py new file mode 100644 index 000000000..5e117f4e2 --- /dev/null +++ b/firebase/firestore-py/bin/faculty.py @@ -0,0 +1,51 @@ +from lib import utils +from lib.faculty import reader as faculty_reader +from lib.sections import reader as section_reader +from lib.students import reader as student_reader +from lib.faculty import logic as faculty_logic +from lib.data.student import User +from lib import services + + +if __name__ == "__main__": + utils.logger.info("Indexing data...") + + faculty = utils.logger.log_value( + "faculty objects", faculty_reader.get_faculty + ) + + section_teachers = utils.logger.log_value( + "section teachers", section_reader.get_section_faculty_ids + ) + + faculty_sections = utils.logger.log_value( + "faculty sections", lambda: faculty_logic.get_faculty_sections( + faculty = faculty, + section_teachers = section_teachers + ) + ) + + periods = utils.logger.log_value( + "periods", student_reader.read_periods + ) + + faculty_with_schedule = utils.logger.log_value( + "faculty with schedule", lambda: faculty_logic.get_faculty_with_schedule( + faculty_sections = faculty_sections, + section_periods = periods + ) + ) + + User.verify_schedule(faculty_with_schedule) + + utils.logger.info("Finished data indexing.") + + if utils.args.should_upload: + utils.logger.log_value( + "data upload", lambda: services.upload_users(faculty_with_schedule) + ) + + utils.logger.info(f"Processed {len(faculty_with_schedule)} faculty") + + + diff --git a/firebase/firestore-py/bin/feedback.py b/firebase/firestore-py/bin/feedback.py new file mode 100644 index 000000000..40c78a08c --- /dev/null +++ b/firebase/firestore-py/bin/feedback.py @@ -0,0 +1,14 @@ +from firebase_admin import delete_app + +import lib.utils as utils +import lib.services as firebase + +if __name__ == '__main__': + utils.logger.info("Getting feedback...") + feedback = firebase.get_feedback() + feedback.sort() + for message in feedback: + utils.logger.verbose(str(message)) + + delete_app(firebase.app) + utils.logger.info("Got feedback") \ No newline at end of file diff --git a/firebase/firestore-py/bin/sections.py b/firebase/firestore-py/bin/sections.py new file mode 100644 index 000000000..85a8dc1c1 --- /dev/null +++ b/firebase/firestore-py/bin/sections.py @@ -0,0 +1,38 @@ +from lib.utils import logger +from lib.sections import reader as section_reader +from lib.faculty import reader as faculty_reader +from lib.sections import logic as section_logic +from lib import utils +from lib import services + +if __name__ == "__main__": + print("Indexing data...") + + course_names = logger.log_value("course names", section_reader.get_course_names) + + section_teachers = logger.log_value("section teachers", section_reader.get_section_faculty_ids) + + faculty_names = logger.log_value("faculty names", faculty_reader.get_faculty) + + zoom_links = logger.log_value("zoom links", section_reader.get_zoom_links) + + print(f"Found {len(zoom_links.keys())} zoom links") + + sections = logger.log_value("sections list", lambda: section_logic.get_sections( + course_names = course_names, + section_teachers = section_teachers, + faculty_names = faculty_names, + zoom_links = zoom_links, + ) + ) + + print("Finished data indexing.") + + if utils.args.should_upload: + utils.logger.log_value( + "data upload", lambda: services.upload_sections(sections) + ) + + utils.logger.info(f"Processed {len(sections)} sections") + + diff --git a/firebase/firestore-py/bin/signout.py b/firebase/firestore-py/bin/signout.py new file mode 100644 index 000000000..acff234ac --- /dev/null +++ b/firebase/firestore-py/bin/signout.py @@ -0,0 +1,11 @@ +from firebase_admin import delete_app +import lib.services as firebase +import lib.utils as utils + +if __name__ == "__main__": + utils.logger.info("Signing out all users...") + for user in firebase.list_users(): + firebase.auth.revoke_token(user) + + delete_app(firebase.app) + utils.logger.info("All users have been signed out") diff --git a/firebase/firestore-py/bin/students.py b/firebase/firestore-py/bin/students.py new file mode 100644 index 000000000..93b661f60 --- /dev/null +++ b/firebase/firestore-py/bin/students.py @@ -0,0 +1,60 @@ +from lib import data +from lib import utils +from lib.students import reader as student_reader +from lib import services + +from collections import defaultdict +from firebase_admin import delete_app + +if __name__ == '__main__': + utils.logger.info("Indexing students...") + + student_courses = utils.logger.log_value("student courses", student_reader.read_student_courses) + students = utils.logger.log_value("students", student_reader.read_students) + periods = utils.logger.log_value("section periods", student_reader.read_periods) + homeroom_locations = defaultdict(lambda: "Unavailable") + utils.logger.debug("Homeroom locations", homeroom_locations) + semesters = utils.logger.log_value("semesters", student_reader.read_semesters) + + schedules, homerooms, seniors = utils.logger.log_value( + "schedules", lambda: student_reader.get_schedules( + students = students, + periods = periods, + student_courses = student_courses, + semesters = semesters, + ) + ) + + student_reader.set_students_schedules( + schedules = schedules, + homerooms = homerooms, + homeroom_locations = homeroom_locations, + ) + students_with_schedules = list(schedules.keys()) + utils.logger.debug("Student schedules", students_with_schedules) + data.User.verify_schedule(students_with_schedules) + + test_users = [ + data.User.empty( + email = tester ["email"], + first = tester ["first"], + last = tester ["last"], + ) + for tester in utils.constants.testers + ] + utils.logger.verbose(f"Found {len(test_users)} testers") + utils.logger.debug("Testers", test_users) + students_with_schedules.extend(test_users) + + utils.logger.info("Finished processing students") + + if utils.args.should_upload: + utils.logger.log_progress( + "data upload", + lambda: services.upload_users(students_with_schedules) + ) + else: utils.logger.warning("Did not upload student data. Use the --upload flag.") + + utils.logger.info(f"Processed {len(students_with_schedules)} users.") + + diff --git a/firebase/firestore-py/constants.yaml b/firebase/firestore-py/constants.yaml new file mode 100644 index 000000000..053401573 --- /dev/null +++ b/firebase/firestore-py/constants.yaml @@ -0,0 +1,37 @@ +dayNames: + - "Monday" + - "Tuesday" + - "Wednesday" + - "Thursday" + - "Friday" + +corruptStudents: + - "724703" + - "722601" + - "723397" + +testers: + testt@ramaz.org: + first: "Test" + last: "Account" + taubj@ramaz.org: + first: "Jennifer" + last: "Taub" + shafnera@ramaz.org: + first: "Adina" + last: "Shafner" + mentj@ramaz.org: + first: "John" + last: "Ment" + leschesl@ramaz.org: + first: "Levi" + last: "Lesches" + gershmane@ramaz.org: + first: "Ezra" + last: "Gershman" + +ignoredStudents: + - "ID" + +ignoredSections: + - "909000-10" diff --git a/firebase/firestore-py/lib/data/__init__.py b/firebase/firestore-py/lib/data/__init__.py new file mode 100644 index 000000000..2442b7aad --- /dev/null +++ b/firebase/firestore-py/lib/data/__init__.py @@ -0,0 +1,4 @@ +from .calendar import Day, get_default_calendar, get_empty_calendar +from .feedback import Feedback +from .schedule import Semesters, Section, Period +from .student import User, DayDefaultDict diff --git a/firebase/firestore-py/lib/data/calendar.py b/firebase/firestore-py/lib/data/calendar.py new file mode 100644 index 000000000..19d8dc3ad --- /dev/null +++ b/firebase/firestore-py/lib/data/calendar.py @@ -0,0 +1,85 @@ +from datetime import date +import calendar as calendar_model +from datetime import datetime + +from .. import utils + +SPECIAL_NAMES = { + "rosh chodesh": "Rosh Chodesh", + "friday r.c.": "Friday Rosh Chodesh", + "early dismissal": "Early Dismissal", + "modified": "Modified", +} + +current_year = date.today().year +current_month = date.today().month +cal = calendar_model.Calendar(firstweekday=6) + +def get_default_calendar(month): + result = [] + year = get_year(month) + for date in cal.itermonthdates(year, month): + if date.month != month: continue + weekday = calendar_model.day_name[date.weekday()] + if weekday in {"Saturday", "Sunday"}: + result.append(None) + elif weekday == "Friday": + result.append(Day(date=date, name=weekday, special="Friday")) + else: + result.append(Day(date=date, name=weekday, special="Weekday")) + return result + +def get_year(month): + if current_month > 7: + return current_year if month > 7 else current_year + 1 + else: + return current_year - 1 if month > 7 else current_year + +def get_empty_calendar(month): return [None] * 31 + +class Day: + def get_list(date_line, name_line, special_line, month): return [ + Day.raw(date=date, name=name, special=special, month=month) + for date, name, special in zip(date_line, name_line, special_line) + if date + ] + + def verify_calendar(month, calendar): + _, days_in_month = calendar_model.monthrange(current_year, month) + days = set(range(1, days_in_month + 1)) + is_valid = len(calendar) == days_in_month + if not is_valid: utils.logger.warning(f"Calendar for {month} is invalid. Missing entries for {days}.") + return is_valid + + def raw(date, name, special, month): + year = get_year(month) + day = int(date) + date = datetime(year, month, day) + name = name.split() [0] + special = special.lower() + if special.endswith(" Schedule"): + special = special[:special.find(" Schedule")] + if special.startswith("modified"): + special = "Modified" + elif not special or special not in SPECIAL_NAMES: + special = None + else: special = SPECIAL_NAMES[special] + return Day(date = date, name = name, special = special) + + def __init__(self, date, name, special): + self.date = date + self.name = name + self.special = special + assert name is not None or special is None, f"Cannot have a special without a name: {date}" + + def __str__(self): + if self.name is None: return f"{self.date}: No school" + else: return f"{self.date}: {self.name} {self.special}" + + def __repr__(self): return str(self) + + def to_json(self): return { + "name": self.name, + "schedule": self.special + } + diff --git a/firebase/firestore-py/lib/data/feedback.py b/firebase/firestore-py/lib/data/feedback.py new file mode 100644 index 000000000..bb5f74455 --- /dev/null +++ b/firebase/firestore-py/lib/data/feedback.py @@ -0,0 +1,25 @@ +class Feedback: + def __init__(self, message, email, name, is_anonymous, timestamp): + self.message = message + self.email = email + self.name = name + self.is_anonymous = is_anonymous + self.timestamp = timestamp + + def from_json(json): return Feedback( + message = json["message"], + email=json.get("email"), + name=json["name"], + is_anonymous=json.get("anonymous") or not json.get("responseConsent"), + timestamp=json["timestamp"] + ) + + def __repr__(self): + result = "" + if not self.is_anonymous: + result += f"{self.name}, {self.email}: " + result += f"{self.message} -- {self.timestamp}" + return result + + def __lt__(self, other): + return self.timestamp < other.timestamp \ No newline at end of file diff --git a/firebase/firestore-py/lib/data/schedule.py b/firebase/firestore-py/lib/data/schedule.py new file mode 100644 index 000000000..9070b9621 --- /dev/null +++ b/firebase/firestore-py/lib/data/schedule.py @@ -0,0 +1,51 @@ +class Semesters: + def __init__(self, semester1, semester2, section_id): + self.semester1 = semester1 + self.semester2 = semester2 + self.section_id = section_id + assert semester1 is not None and semester2 is not None, f"Could not read semester data for {section_id}" + + def __str__(self): return f"Semesters({self.semester1}, {self.semester2})" + def __repr__(self): return f"Semesters({int(self.semester1)}, {int(self.semester2)})" + +class Section: + def __init__(self, name, id, teacher, zoom_link): + self.name = name + self.id = id + self.teacher = teacher + self.zoom_link = zoom_link + + def __repr__(self): return f"{self.name} ({self.id})" + + def to_json(self): return { + "name": self.name, + "teacher": self.teacher, + "id": self.id, + "virtualLink": self.zoom_link, + } + +class Period: + PERIODS_IN_DAY = { + "Monday": 10, + "Tuesday": 10, + "Wednesday": 10, + "Thursday": 10, + "Friday": 6, + } + + def __init__(self, room, id, day, period): + assert day is not None and period is not None, f"Could not read period data for {id}" + assert (id is None) == (room is None), f"If ID is None, room must be too (and vice versa): {day}, {period}, {id}" + self.room = room + self.id = id + self.day = day + self.period = period + + def __repr__(self): return f"{self.day}_{self.period}({self.id})" + + def to_json(self): return { + "room": self.room, + "id": self.id, + "dayName": self.day, + "name": str(self.period), + } diff --git a/firebase/firestore-py/lib/data/student.py b/firebase/firestore-py/lib/data/student.py new file mode 100644 index 000000000..9e78e9e4c --- /dev/null +++ b/firebase/firestore-py/lib/data/student.py @@ -0,0 +1,76 @@ +from .. import utils +from .schedule import Period + +class DayDefaultDict(dict): + def __missing__(self, letter): + self[letter] = [None] * Period.PERIODS_IN_DAY[letter] + return self[letter] + def populate(self, keys): + for key in keys: + if key not in self: self.__missing__(key) + +class User: + def verify_schedule(users): + missing_schedules = {user for user in users if user.has_no_classes()} + if (missing_schedules): + utils.logger.warning(f"Misisng schedules for {missing_schedules}") + + def schedule_to_json(schedule): return [ + period.to_json() if period is not None else None + for period in schedule + ] + + def __init__(self, first, last, email, id, homeroom=None, homeroom_location=None, schedule=None): + assert id, f"Could not find id for user: {first} {last}, {email}" + # assert first and last and email, f"Could not find contact info for {self}" + self.first = first + self.last = last + self.name = f"{first} {last}" + self.email = email + self.id = id + self.homeroom = homeroom + self.homeroom_location = homeroom_location + self.schedule = schedule + if not schedule: return + assert homeroom and homeroom_location, f"Could not find homeroom for {self}" + for day_name in utils.constants.day_names: + assert day_name in schedule, f"{self} does not have a schedule for {day_name}" + + def empty(email, first, last): + user = User( + first = first, + last = last, + email = email, + id = "TEST", + homeroom = "SENIOR_HOMEROOM", + homeroom_location = "Unavailable", + schedule = DayDefaultDict(), + ) + user.schedule.populate(utils.constants.day_names) + return user + + def __repr__(self): + return f"{self.first} {self.last} ({self.id})" + + def has_no_classes(self): + return all( + all(period is None for period in day) + for day in self.schedule + ) + + def to_json(self): return { + **{ + day_name: User.schedule_to_json(self.schedule [day_name]) + for day_name in utils.constants.day_names + }, + "advisory": None if not self.homeroom else { + "id": self.homeroom, + "room": self.homeroom_location, + }, + "email": self.email, + "dayNames": utils.constants.day_names, + "contactInfo": { + "name": f"{self.first} {self.last}", + "email": self.email + } + } diff --git a/firebase/firestore-py/lib/faculty/logic.py b/firebase/firestore-py/lib/faculty/logic.py new file mode 100644 index 000000000..46a1a52af --- /dev/null +++ b/firebase/firestore-py/lib/faculty/logic.py @@ -0,0 +1,105 @@ +from collections import defaultdict +from typing import DefaultDict +from .. import utils +from .. import data + +''' +A collection of functions o index faculty data. + +No function in this class reads data from the data files, just works logic +on them. This helps keep the program modular, by separating the data sources +from the data indexing +''' + +''' +Maps faculty to the sections they teach. + +This function works by taking several arguments: + +- faculty, from [FacultyReader.get_faculty] +- sectionTeachers, from [SectionReader.get_section_faculty_ids] + +These are kept as parameters instead of calling the functions by itself +in order to keep the data and logic layers separate. +''' + +def get_faculty_sections(faculty,section_teachers): + result = defaultdict(set) + missing_emails = set() + for section_id, faculty_id in section_teachers.items(): + # Teaches a class but doesn't have basic faculty data + if faculty_id not in faculty: + missing_emails.add(faculty_id) + continue + result[faculty[faculty_id]].add(section_id) + + if missing_emails: + utils.logger.warning(f"Missing emails for {missing_emails}") + return result + +''' +Returns complete [User] objects. + +This function returns [User] objects with more properties than before. +See [User.addSchedule] for which properties are added. + +This function works by taking several arguments: + + - faculty_sections from [get_faculty_sections] + - section_periods from [student_reader.get_periods] + +These are kept as parameters instead of calling the functions by itself +in order to keep the data and logic layers separate. +''' + +def get_faculty_with_schedule(faculty_sections, section_periods): + # The schedule for each teacher + schedules = {} + + # Sections IDs which are taught but never meet. + missing_periods = set() + + # Faculty missing a homerooms. + # + # This will be logged at the debug level. + missing_homerooms = set() + + # Loop over teacher sections and get their periods. + for key, value in faculty_sections.items(): + periods = [] + for section_id in value: + if section_id in section_periods: + periods.extend(section_periods[section_id]) + elif section_id.startswith("UADV"): + key.homeroom = section_id + key.homeroom_location = "Unavailable" + else: + missing_periods.add(section_id) + + # Still couldn'y find any homeroom + if key.homeroom is None: + missing_homerooms.add(key) + key.homeroom = "SENIOR_HOMEROOM" + key.homeroom_location = "Unavailable" + + schedules[key] = periods + + # Some logging + if not missing_periods: + utils.logger.debug("Missing homerooms", missing_homerooms) + + # Compiles a list of periods into a full schedule + result = [] + for key, value in schedules.items(): + schedule = data.DayDefaultDict() + + for period in value: + + schedule[period.day][period.period-1] = period + + schedule.populate(utils.constants.day_names) + key.schedule = schedule + result.append(key) + + + return result diff --git a/firebase/firestore-py/lib/faculty/reader.py b/firebase/firestore-py/lib/faculty/reader.py new file mode 100644 index 000000000..0e675229b --- /dev/null +++ b/firebase/firestore-py/lib/faculty/reader.py @@ -0,0 +1,20 @@ +import csv +from .. import utils +from ..data import student + +''' +A collection of functions to read faculty data. +No function in this class actually performs logic on said data, just returns +it. This helps keep the program modular, by separating the data sources from +the data indexing. +''' +def get_faculty(): + with open(utils.dir.faculty) as file: + return {row["USER_ID"]: student.User( + first = row["FIRST_NAME"], + last = row ["LAST_NAME"], + email = row["EMAIL"].lower(), + id = row["USER_ID"]) + for row in csv.DictReader(file) + if row ["USER_ID"] not in utils.constants.corrupted_students} + diff --git a/firebase/firestore-py/lib/readers/students.py b/firebase/firestore-py/lib/readers/students.py new file mode 100644 index 000000000..8b20d446a --- /dev/null +++ b/firebase/firestore-py/lib/readers/students.py @@ -0,0 +1,107 @@ +import csv +from collections import defaultdict + +import lib.data as data +import lib.utils as utils + +def read_students(): + with open(utils.dir.students) as file: return { + row ["ID"]: data.User( + first = row ["First Name"], + last = row ["Last Name"], + email = row ["Email"].lower(), + id = row ["ID"], + ) + for row in csv.DictReader(file) + if row ["ID"] not in utils.constants.corrupted_students + } + +def read_periods(): + homeroom_locations = {} + periods = defaultdict(list) + with open(utils.dir.section_schedule) as file: + for row in csv.DictReader(file): + if row ["SCHOOL_ID"] != "Upper": continue + section_id = row ["SECTION_ID"] + day = row ["WEEKDAY_NAME"] + period_str = row ["BLOCK_NAME"] + room = row ["ROOM"] + + # Handle homerooms + try: period_num = int(period_str) + except ValueError: + if period_str == "HOMEROOM": + homeroom_locations [section_id] = room + continue + + periods [section_id].append(data.Period( + day = day, + room = room, + id = section_id, + period = period_num + )) + return periods + +def read_student_courses(): + courses = defaultdict(list) + with open(utils.dir.schedule) as file: + for row in csv.DictReader(file): + if row ["SCHOOL_ID"] != "Upper": continue + student = row ["STUDENT_ID"] + if student in utils.constants.corrupted_students: continue + courses [student].append(row ["SECTION_ID"]) + return courses + +def read_semesters(): + with open(utils.dir.section) as file: return { + row ["SECTION_ID"]: data.Semesters( + semester1 = row ["TERM1"] == "Y", + semester2 = row ["TERM2"] == "Y", + section_id = row ["SECTION_ID"], + ) + for row in csv.DictReader(file) + if row ["SCHOOL_ID"] == "Upper" + } + +def get_schedules(students, periods, student_courses, semesters): + homerooms = {} + seniors = set() + result = defaultdict(data.DayDefaultDict) + ignored = set() + + for student, courses in student_courses.items(): + student = students [student] + for section_id in courses: + if "UADV" in section_id: + homerooms [student] = section_id + continue + # if section_id in utils.constants.ignored_sections: continue + + try: semester = semesters [section_id] + except KeyError as error: + utils.logger.error(f"Section {section_id} was in schedule.csv but not in sections.csv") + raise error from None + + if (semester is not None and not (semester.semester1 if utils.constants.is_semester1 else semester.semester2)): + continue + elif section_id.startswith("12"): seniors.add(student) + + if section_id not in periods: # in schedule.csv but not section_schedule.csv + ignored.add(section_id) + continue + + for period in periods [section_id]: + result [student] [period.day] [period.period - 1] = period + + for schedule in result.values(): schedule.populate(utils.constants.day_names) + if ignored: + utils.logger.warning(f"Ignored {len(ignored)} classes") + utils.logger.debug("Ignored classes", ignored) + return result, homerooms, seniors + +def set_students_schedules(schedules, homerooms, homeroom_locations): + for student, schedule in schedules.items(): + if student.id in utils.constants.ignored_students: continue + student.homeroom = "SENIOR_HOMEROOM" if student not in homerooms else homerooms [student] + student.homeroom_location = "Unavailable" if student not in homerooms else homeroom_locations [homerooms [student]] + student.schedule = schedule diff --git a/firebase/firestore-py/lib/sections/logic.py b/firebase/firestore-py/lib/sections/logic.py new file mode 100644 index 000000000..42ab64d79 --- /dev/null +++ b/firebase/firestore-py/lib/sections/logic.py @@ -0,0 +1,39 @@ +from ..data.schedule import Section + +''' +A collection of functions to index course data. + +No function in this class reads data from the data files, just works on them. +This helps keep the program modular by seperating the data from +the data indexing +''' + +# Converts a section ID to a course ID. +def get_course_id(section_id): + result = section_id[0:section_id.index("-")] + if result.startswith("0"): + return result[1:] + else: + return result + + # Builds a list of [Section] objects. + # + # This function works by taking several arguments: + # + # - courseNames, from [section_reader.course_names] + # - sectionTeachers, from [section_reader.get_section_faculty_ids] + # - facultyNames, from [faculty_reader.get_faculty] + # + # These are kept as parameters instead of calling the functions by itself + # in order to keep the data and logic layers separate. + +def get_sections(course_names, section_teachers, faculty_names, zoom_links): + return [ + Section( + id = key, + name = course_names[get_course_id(key)], + teacher = faculty_names[value].name, + zoom_link = zoom_links[key] if key in zoom_links else "" + ) + for key, value in section_teachers.items() + ] diff --git a/firebase/firestore-py/lib/sections/reader.py b/firebase/firestore-py/lib/sections/reader.py new file mode 100644 index 000000000..002bcf4db --- /dev/null +++ b/firebase/firestore-py/lib/sections/reader.py @@ -0,0 +1,38 @@ +import csv +from logging import log +from ..utils import dir +import warnings + +''' +A collection of functions to read course data. + +No function in this class actually performs logic on data, just returns it. +his helps keep the program modular, by separating the data sources from +the data indexing. +''' + +def get_course_names(): + with open(dir.courses) as file: + return { + row["Course ID"] : row["Course Name"] + for row in csv.DictReader(file) + if row["School ID"] == "Upper"} + +def get_section_faculty_ids(): + with open(dir.section) as file: + return { + row["SECTION_ID"]: row["FACULTY_ID"] + for row in csv.DictReader(file) + if row["SCHOOL_ID"] == "Upper" and row["FACULTY_ID"]} + +def get_zoom_links(): + try: + with open(dir.zoom_links) as file: + return { + row["ID"]: row["LINK"] + for row in csv.DictReader(file) + if row["LINK"] + } + except FileNotFoundError: + warnings.warn("zoom_links.csv doesn't exist. Cannot grab data. Using an empty dictionary instead") + return {} \ No newline at end of file diff --git a/firebase/firestore-py/lib/services/__init__.py b/firebase/firestore-py/lib/services/__init__.py new file mode 100644 index 000000000..38f1ab595 --- /dev/null +++ b/firebase/firestore-py/lib/services/__init__.py @@ -0,0 +1,4 @@ +from .firebase import app +from .firestore import * +from .scopes import SCOPES +from .auth import get_claims, set_scopes, list_users diff --git a/firebase/firestore-py/lib/services/auth.py b/firebase/firestore-py/lib/services/auth.py new file mode 100644 index 000000000..8dcefa678 --- /dev/null +++ b/firebase/firestore-py/lib/services/auth.py @@ -0,0 +1,24 @@ +from .firebase import app +from firebase_admin import auth +from firebase_admin.exceptions import NotFoundError + +def create_user(email): + auth.create_user(email=email) + +def get_user(email): + try: return auth.get_user_by_email(email) + except NotFoundError: return create_user(email) + +def list_users(): + return auth.list_users().iterate_all() + +def revoke_token(user): + auth.revoke_refresh_tokens(user.uid) + +def get_claims(email): + return get_user(email).custom_claims + +def set_scopes(email, scopes): auth.set_custom_user_claims( + get_user(email).uid, + {"isAdmin": bool(scopes), "scopes": scopes}, +) diff --git a/firebase/firestore-py/lib/services/firebase.py b/firebase/firestore-py/lib/services/firebase.py new file mode 100644 index 000000000..9c72ded1c --- /dev/null +++ b/firebase/firestore-py/lib/services/firebase.py @@ -0,0 +1,4 @@ +from firebase_admin import initialize_app, credentials +from ..utils.dir import certificate + +app = initialize_app(credentials.Certificate(certificate)) diff --git a/firebase/firestore-py/lib/services/firestore.py b/firebase/firestore-py/lib/services/firestore.py new file mode 100644 index 000000000..52e9b36ec --- /dev/null +++ b/firebase/firestore-py/lib/services/firestore.py @@ -0,0 +1,36 @@ +from firebase_admin import firestore +from .firebase import app +from .. import data + +_firestore = firestore.client() + +students = _firestore.collection("students") +calendar = _firestore.collection("calendar") +courses = _firestore.collection("classes") +feedback = _firestore.collection("feedback") + +def upload_users(users): + batch = _firestore.batch() + for user in users: + batch.set(students.document(user.email), user.to_json()) + batch.commit() + +def upload_month(month, data): + calendar.document(str(month)).update({ + "month": month, + "calendar": [(day.to_json() if day is not None else None) for day in data] + }) + +def upload_sections(sections): + batch = _firestore.batch() + for section in sections: + batch.set(courses.document(section.id), section.to_json()) + batch.commit() + +def get_month(month): + return calendar.document(str(month)).get().to_dict() + +def get_feedback(): return [ + data.Feedback.from_json(document.to_dict()) + for document in feedback.get() +] diff --git a/firebase/firestore-py/lib/services/scopes.py b/firebase/firestore-py/lib/services/scopes.py new file mode 100644 index 000000000..24068c9c8 --- /dev/null +++ b/firebase/firestore-py/lib/services/scopes.py @@ -0,0 +1,14 @@ +# The scope name for calendar admins. +_calendar = "calendar"; + +# The scope name for publication admins. +_publications = "publications"; + +# The scope name for sports admins. +_sports = "sports"; + +# A list of all acceptable scopes. +# +# These scopes are the only ones recognized by the app. Since the data is +# pulled from a file, this safeguards against typos. +SCOPES = {_calendar, _publications, _sports}; diff --git a/firebase/firestore-py/lib/students/reader.py b/firebase/firestore-py/lib/students/reader.py new file mode 100644 index 000000000..8b20d446a --- /dev/null +++ b/firebase/firestore-py/lib/students/reader.py @@ -0,0 +1,107 @@ +import csv +from collections import defaultdict + +import lib.data as data +import lib.utils as utils + +def read_students(): + with open(utils.dir.students) as file: return { + row ["ID"]: data.User( + first = row ["First Name"], + last = row ["Last Name"], + email = row ["Email"].lower(), + id = row ["ID"], + ) + for row in csv.DictReader(file) + if row ["ID"] not in utils.constants.corrupted_students + } + +def read_periods(): + homeroom_locations = {} + periods = defaultdict(list) + with open(utils.dir.section_schedule) as file: + for row in csv.DictReader(file): + if row ["SCHOOL_ID"] != "Upper": continue + section_id = row ["SECTION_ID"] + day = row ["WEEKDAY_NAME"] + period_str = row ["BLOCK_NAME"] + room = row ["ROOM"] + + # Handle homerooms + try: period_num = int(period_str) + except ValueError: + if period_str == "HOMEROOM": + homeroom_locations [section_id] = room + continue + + periods [section_id].append(data.Period( + day = day, + room = room, + id = section_id, + period = period_num + )) + return periods + +def read_student_courses(): + courses = defaultdict(list) + with open(utils.dir.schedule) as file: + for row in csv.DictReader(file): + if row ["SCHOOL_ID"] != "Upper": continue + student = row ["STUDENT_ID"] + if student in utils.constants.corrupted_students: continue + courses [student].append(row ["SECTION_ID"]) + return courses + +def read_semesters(): + with open(utils.dir.section) as file: return { + row ["SECTION_ID"]: data.Semesters( + semester1 = row ["TERM1"] == "Y", + semester2 = row ["TERM2"] == "Y", + section_id = row ["SECTION_ID"], + ) + for row in csv.DictReader(file) + if row ["SCHOOL_ID"] == "Upper" + } + +def get_schedules(students, periods, student_courses, semesters): + homerooms = {} + seniors = set() + result = defaultdict(data.DayDefaultDict) + ignored = set() + + for student, courses in student_courses.items(): + student = students [student] + for section_id in courses: + if "UADV" in section_id: + homerooms [student] = section_id + continue + # if section_id in utils.constants.ignored_sections: continue + + try: semester = semesters [section_id] + except KeyError as error: + utils.logger.error(f"Section {section_id} was in schedule.csv but not in sections.csv") + raise error from None + + if (semester is not None and not (semester.semester1 if utils.constants.is_semester1 else semester.semester2)): + continue + elif section_id.startswith("12"): seniors.add(student) + + if section_id not in periods: # in schedule.csv but not section_schedule.csv + ignored.add(section_id) + continue + + for period in periods [section_id]: + result [student] [period.day] [period.period - 1] = period + + for schedule in result.values(): schedule.populate(utils.constants.day_names) + if ignored: + utils.logger.warning(f"Ignored {len(ignored)} classes") + utils.logger.debug("Ignored classes", ignored) + return result, homerooms, seniors + +def set_students_schedules(schedules, homerooms, homeroom_locations): + for student, schedule in schedules.items(): + if student.id in utils.constants.ignored_students: continue + student.homeroom = "SENIOR_HOMEROOM" if student not in homerooms else homerooms [student] + student.homeroom_location = "Unavailable" if student not in homerooms else homeroom_locations [homerooms [student]] + student.schedule = schedule diff --git a/firebase/firestore-py/lib/utils/__init__.py b/firebase/firestore-py/lib/utils/__init__.py new file mode 100644 index 000000000..93ed3b802 --- /dev/null +++ b/firebase/firestore-py/lib/utils/__init__.py @@ -0,0 +1,5 @@ +from .logger import logger, log_value +from .args import args +from . import dir +from . import constants +constants.init() diff --git a/firebase/firestore-py/lib/utils/args.py b/firebase/firestore-py/lib/utils/args.py new file mode 100644 index 000000000..6b798fb1d --- /dev/null +++ b/firebase/firestore-py/lib/utils/args.py @@ -0,0 +1,10 @@ +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("--color", action=argparse.BooleanOptionalAction, default=True, dest="use_color", help="Whether to use color in console output. Disable color if outputting to a file") +parser.add_argument("-u", "--upload", action="store_true", dest="should_upload", help="Whether to actually upload the results. Omitting implies a dry run.") +parser.add_argument("-v", "--verbose", action="store_true", help="Whether to output verbose messages (messages indicating individual steps).") +parser.add_argument("-d", "--debug", action="store_true", help="Whether to output debug messages (values of all function calls).") +args = parser.parse_args() +if args.debug: + with open("debug.log", "w") as file: file.write("") diff --git a/firebase/firestore-py/lib/utils/constants.py b/firebase/firestore-py/lib/utils/constants.py new file mode 100644 index 000000000..c70e1a4f2 --- /dev/null +++ b/firebase/firestore-py/lib/utils/constants.py @@ -0,0 +1,25 @@ +import yaml +from datetime import date + +from . import dir + +day_names = None +corrupted_students = None +is_semester1 = date.today().month > 7 +testers = None +ignored_students = None +ignored_sections = None + +def init(): + global day_names, corrupted_students, testers, ignored_students, ignored_sections + with open(dir.constants) as file: + contents = yaml.safe_load(file) + + day_names = contents["dayNames"] + corrupted_students = contents["corruptStudents"] + testers = [ + {"email": tester, "first": contents["testers"][tester]["first"], "last": contents["testers"][tester]["last"]} + for tester in contents["testers"] + ] + ignored_students = contents["ignoredStudents"] + ignored_sections = contents["ignoredSections"] diff --git a/firebase/firestore-py/lib/utils/dir.py b/firebase/firestore-py/lib/utils/dir.py new file mode 100644 index 000000000..8d855e432 --- /dev/null +++ b/firebase/firestore-py/lib/utils/dir.py @@ -0,0 +1,57 @@ +from pathlib import Path + +project_dir = Path.cwd() +data_dir = project_dir.parent.parent / "data" + +# The path to the admin certificate file. +certificate = project_dir / "admin.json" + +# The courses database. + +# Contains the names of every course, but requires a course ID, not +# a section ID. +courses = data_dir / "courses.csv" + +# The faculty database. +# +# Contains the names, emails, and IDs of every faculty member. +faculty = data_dir / "faculty.csv" + +# The schedule database. +# +# Contains a list pairing each student ID to multiple section IDs. +schedule = data_dir / "schedule.csv" + +# The sections database. +# +# Contains the teachers of every section, along with other useful data. +section = data_dir / "section.csv" + +# The periods database. +# +# Contains each period every section meets. +section_schedule = data_dir / "section_schedule.csv" + +# The students database. +# +# Contains the names, emails, and IDs of every student. +students = data_dir / "students.csv" + +# The virtual class links. +zoom_links = data_dir / "zoom_links.csv" + +# The list of admins. +# +# Each row should be the name of the admin, followed by a list of scopes. +admins = data_dir / "admins.csv" + +# Options for this tool +constants = project_dir / "constants.yaml" + +# Constants such as dayNames, corruptStudents, and testers +constants = project_dir / "constants.yaml" + +# Returns the path for the calendar at a given month. +# +# The month should follow 1-based indexing. +def get_month(month): return data_dir / "calendar" / f"{month}.csv" diff --git a/firebase/firestore-py/lib/utils/logger.py b/firebase/firestore-py/lib/utils/logger.py new file mode 100644 index 000000000..2b7bd1c0e --- /dev/null +++ b/firebase/firestore-py/lib/utils/logger.py @@ -0,0 +1,91 @@ +import logging +from .args import args +from colorama import init +from colorama import Fore, Back, Style + +init(autoreset=True) +logging.VERBOSE = 15 +reset = Style.RESET_ALL +def get_ansi(code): return f"\u001b[38;5;{code}m" + +class ColorFormatter(logging.Formatter): + def __init__(self, use_color = True): + self.use_color = use_color + + FORMATS = { + logging.DEBUG: Fore.WHITE, + logging.VERBOSE: Style.BRIGHT + Fore.BLACK, + logging.INFO: Fore.BLUE, + logging.WARNING: Fore.YELLOW, + logging.ERROR: Fore.RED, + } + + def format(self, record): + color = self.FORMATS[record.levelno] + if self.use_color: + formatter = logging.Formatter(f"{color}[{record.levelname[0]}]{reset} %(message)s") + else: + formatter = logging.Formatter(f"[{record.levelname[0]}] %(message)s") + return formatter.format(record) + +def verbose(self, message, *args, **kwargs): + if self.isEnabledFor(logging.VERBOSE): + self._log(logging.VERBOSE, message, args, **kwargs) + +def debug(self, label, value, *args, **kwargs): + if self.isEnabledFor(logging.DEBUG): + self._log(logging.DEBUG, f"{label}: {value}", args, **kwargs) + +def error(self, message, *args, **kwards): + if self.isEnabledFor(logging.ERROR): + self._log(logging.ERROR, message, args, **kwards) + raise AssertionError(message) + +logging.addLevelName(logging.VERBOSE, "VERBOSE") # between INFO and DEBUG +logging.Logger.verbose = verbose +logging.Logger.debug = debug +logging.Logger.error = error + +logger = logging.getLogger("ramlife") +console_handler = logging.StreamHandler() +console_handler.setFormatter(ColorFormatter()) +if args.verbose: + logger.setLevel(logging.VERBOSE) + console_handler.setLevel(logging.VERBOSE) +elif args.debug: + logger.setLevel(logging.DEBUG) + console_handler.setLevel(logging.VERBOSE) +else: + logger.setLevel(logging.INFO) + console_handler.setLevel(logging.INFO) +logger.addHandler(console_handler) + +if args.debug: + file_handler = logging.FileHandler("debug.log", encoding='utf8') + file_handler.setFormatter(ColorFormatter(False)) + file_handler.setLevel(logging.DEBUG) + logger.addHandler(file_handler) + +def log_value(label, function): + """ + Emits logs before and after returning a value. + + Emits a [verbose] log before calling [func], then a [debug] log with the + result, and finally returns the result. + + The [label] should be all lower case, since it will appear in the middle + of the logged messages. + """ + + logger.verbose(f"Getting {label}") + value = function() + logger.debug(f"Value of {label}", value) + return value + +def log_progress(label, function): + logger.info(f"Starting {label}") + function() + logger.info(f"Finished {label}") + +logger.log_progress = log_progress +logger.log_value = log_value \ No newline at end of file diff --git a/firebase/firestore-py/requirements.txt b/firebase/firestore-py/requirements.txt new file mode 100644 index 000000000..64b19d160 --- /dev/null +++ b/firebase/firestore-py/requirements.txt @@ -0,0 +1,3 @@ +colorama==0.4.4 +firebase_admin==5.0.2 +PyYAML==5.4.1 diff --git a/firebase/firestore/build.yaml b/firebase/firestore/build.yaml index 9b77e7c65..79f7df082 100644 --- a/firebase/firestore/build.yaml +++ b/firebase/firestore/build.yaml @@ -1,6 +1,7 @@ targets: $default: sources: + - $package$ - "lib/**" - "node/**" # main.dart MUST be in node folder. # - "test/**" diff --git a/firebase/firestore/constants.yaml b/firebase/firestore/constants.yaml new file mode 100644 index 000000000..480d52d26 --- /dev/null +++ b/firebase/firestore/constants.yaml @@ -0,0 +1,25 @@ +dayNames: + - "Monday" + - "Tuesday" + - "Wednesday" + - "Thursday" + - "Friday" + +corruptStudents: + - "724703" + - "722601" + - "723397" + +testers: + testt@ramaz.org: + first: "Test" + last: "Account" + taubj@ramaz.org: + first: "Jennifer" + last: "Taub" + shafnera@ramaz.org: + first: "Adina" + last: "Shafner" + mentj@ramaz.org: + first: "John" + last: "Ment" diff --git a/firebase/firestore/lib/constants.dart b/firebase/firestore/lib/constants.dart index a441e8d6b..f7ebdca61 100644 --- a/firebase/firestore/lib/constants.dart +++ b/firebase/firestore/lib/constants.dart @@ -1,7 +1,19 @@ -final Set dayNames = { - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday" -}; \ No newline at end of file +import "package:yaml/yaml.dart"; + +import "package:firestore/helpers.dart"; +import "package:node_io/node_io.dart"; + +final File file = File(DataFiles.constants); + +final YamlMap yamlContents = loadYaml(file.readAsStringSync()); + +final Set dayNames = Set.from(yamlContents ["dayNames"]); + +final Set corruptStudents = Set.from(yamlContents ["corruptStudents"]); + +final bool isSemester1 = DateTime.now().month > 7; + +final List> testers = [ + for (final MapEntry entry in yamlContents ["testers"].entries) + {"email": entry.key}..addAll(Map.from(entry.value)) +]; diff --git a/firebase/firestore/lib/src/data/calendar.dart b/firebase/firestore/lib/src/data/calendar.dart index ec611e8ed..0a93089df 100644 --- a/firebase/firestore/lib/src/data/calendar.dart +++ b/firebase/firestore/lib/src/data/calendar.dart @@ -175,6 +175,6 @@ class Day extends Serializable { @override Map get json => { "name": name, - "special": special, + "schedule": special, }; } \ No newline at end of file diff --git a/firebase/firestore/lib/src/data/schedule.dart b/firebase/firestore/lib/src/data/schedule.dart index 0077f0107..0be57a383 100644 --- a/firebase/firestore/lib/src/data/schedule.dart +++ b/firebase/firestore/lib/src/data/schedule.dart @@ -40,14 +40,17 @@ class Section extends Serializable { /// The section ID for this class final String id; - /// The teacher for this section. + /// The full name of the teacher for this section. final String teacher; + final String zoomLink; + /// Creates a section. const Section({ @required this.name, @required this.id, @required this.teacher, + this.zoomLink, }) : assert( name != null && id != null && teacher != null, @@ -62,6 +65,7 @@ class Section extends Serializable { "name": name, "teacher": teacher, "id": id, + "virtualLink": zoomLink, }; } @@ -116,5 +120,7 @@ class Period extends Serializable { Map get json => { "room": room, "id": id, + "dayName": day, + "name": period.toString(), }; } diff --git a/firebase/firestore/lib/src/data/student.dart b/firebase/firestore/lib/src/data/student.dart index 2d8c39070..09da0bf07 100644 --- a/firebase/firestore/lib/src/data/student.dart +++ b/firebase/firestore/lib/src/data/student.dart @@ -31,6 +31,12 @@ class User extends Serializable { period?.json ]; + static DefaultMap> get emptySchedule => DefaultMap( + (String letter) => List.filled(Period.periodsInDay[letter], null) + )..setDefaultForAll(dayNames); + + static final dayNamesList = List.from(dayNames); + /// This user's email. final String email; @@ -92,6 +98,16 @@ class User extends Serializable { } } + User.empty({ + @required this.email, + @required this.first, + @required this.last, + }) : + homeroom = "SENIOR_HOMEROOM", + homeroomLocation = "Unavailable", + id = null, + schedule = emptySchedule; + /// If this user has no classes. bool get hasNoClasses => schedule.values.every( (List daySchedule) => daySchedule.every( @@ -135,10 +151,18 @@ class User extends Serializable { @override Map get json => { for (final String dayName in dayNames) - dayName: scheduleToJson(schedule [dayName]), - "homeroom": homeroom, - "homeroom meeting room": homeroomLocation, + dayName: scheduleToJson(schedule [dayName]), + + "advisory": homeroom == null ? null : { + "id": homeroom, + "room": homeroomLocation, + }, "email": email, + "dayNames": dayNamesList, + "contactInfo": { + "name": "$first $last", + "email": email, + } }; @override diff --git a/firebase/firestore/lib/src/faculty/logic.dart b/firebase/firestore/lib/src/faculty/logic.dart index 753e024b5..6c3009127 100644 --- a/firebase/firestore/lib/src/faculty/logic.dart +++ b/firebase/firestore/lib/src/faculty/logic.dart @@ -18,7 +18,7 @@ class FacultyLogic { /// This function works by taking several arguments: /// /// - faculty, from [FacultyReader.getFaculty] - /// - sectionTeachers, from [SectionReader.getSectionTeachers] with `id: true` + /// - sectionTeachers, from [SectionReader.getSectionFacultyIds] /// /// These are kept as parameters instead of calling the functions by itself /// in order to keep the data and logic layers separate. @@ -119,7 +119,7 @@ class FacultyLogic { final User newFaculty = replaceHomerooms.containsKey(faculty) ? replaceHomerooms[faculty] : faculty.addHomeroom( // will be overriden - homeroom: "TEST_HOMEROOM", + homeroom: "SENIOR_HOMEROOM", homeroomLocation: "Unavailable", ); schedules [newFaculty] = schedule; diff --git a/firebase/firestore/lib/src/faculty/reader.dart b/firebase/firestore/lib/src/faculty/reader.dart index 81ebce282..de3b5e423 100644 --- a/firebase/firestore/lib/src/faculty/reader.dart +++ b/firebase/firestore/lib/src/faculty/reader.dart @@ -12,7 +12,7 @@ class FacultyReader { await for(final Map row in csvReader(DataFiles.faculty)) row ["ID"]: User( id: row ["ID"], - email: row ["E-mail"], + email: row ["E-mail"].toLowerCase(), first: row ["First Name"], last: row ["Last Name"], ) diff --git a/firebase/firestore/lib/src/helpers/dir.dart b/firebase/firestore/lib/src/helpers/dir.dart index 1ebac3c8c..2e4c5e2c7 100644 --- a/firebase/firestore/lib/src/helpers/dir.dart +++ b/firebase/firestore/lib/src/helpers/dir.dart @@ -44,11 +44,15 @@ class DataFiles { /// Contains the names, emails, and IDs of every student. static final String students = "${dataDir.path}\\students.csv"; + static final String zoomLinks = "${dataDir.path}\\zoom_links.csv"; + /// The list of admins. /// /// Each row should be the name of the admin, followed by a list of scopes. static final String admins = "${dataDir.path}\\admins.csv"; + static final String constants = "${projectDir.path}\\constants.yaml"; + /// Returns the path for the calendar at a given month. /// /// The month should follow 1-based indexing. diff --git a/firebase/firestore/lib/src/sections/logic.dart b/firebase/firestore/lib/src/sections/logic.dart index 453c67d5a..5599267f7 100644 --- a/firebase/firestore/lib/src/sections/logic.dart +++ b/firebase/firestore/lib/src/sections/logic.dart @@ -1,5 +1,6 @@ import "package:firestore/data.dart"; +import "package:firestore/faculty.dart"; import "reader.dart"; // for doc comments /// A collection of functions to index course data. @@ -19,19 +20,23 @@ class SectionLogic { /// This function works by taking several arguments: /// /// - courseNames, from [SectionReader.courseNames] - /// - sectionTeachers, from [SectionReader.getSectionTeachers] (`id: false`) + /// - sectionTeachers, from [SectionReader.getSectionFacultyIds] + /// - facultyNames, from [FacultyReader.getFaculty] /// /// These are kept as parameters instead of calling the functions by itself /// in order to keep the data and logic layers separate. static List
getSections({ @required Map courseNames, @required Map sectionTeachers, + @required Map facultyNames, + @required Map zoomLinks, }) => [ for (final MapEntry entry in sectionTeachers.entries) Section( id: entry.key, name: courseNames [getCourseId(entry.key)], - teacher: entry.value, + teacher: facultyNames [entry.value].name, + zoomLink: zoomLinks [entry.key], ) ]; } diff --git a/firebase/firestore/lib/src/sections/reader.dart b/firebase/firestore/lib/src/sections/reader.dart index e4e50f5b4..dd603727d 100644 --- a/firebase/firestore/lib/src/sections/reader.dart +++ b/firebase/firestore/lib/src/sections/reader.dart @@ -13,21 +13,17 @@ class SectionReader { row ["ID"]: row ["FULL_NAME"] }; - /// Maps section IDs to their respective teachers. - /// - /// If [id] is true, the values are the faculty IDs. Otherwise, the values are - /// the teachers' full names. - static Future> getSectionTeachers({ - bool id = false - }) async { - final Map result = {}; - await for(final Map row in csvReader(DataFiles.section)) { - final String teacher = row [id ? "FACULTY_ID" : "FACULTY_FULL_NAME"]; - if (row ["SCHOOL_ID"] != "Upper" || teacher.isEmpty) { - continue; - } - result [row ["SECTION_ID"]] = teacher; - } - return result; - } + static Future> getSectionFacultyIds() async => { + await for (final Map row in csvReader(DataFiles.section)) + if (row ["SCHOOL_ID"] == "Upper" && row ["FACULTY_ID"].isNotEmpty) + row ["SECTION_ID"]: row ["FACULTY_ID"], + }; +} + +class ZoomLinkReader { + static Future> getZoomLinks() async => { + await for (final Map row in csvReader(DataFiles.zoomLinks)) + if (row ["LINK"].isNotEmpty) + row ["ID"]: row ["LINK"], + }; } diff --git a/firebase/firestore/lib/src/services/auth.dart b/firebase/firestore/lib/src/services/auth.dart index ef5c725d7..e2e964b7c 100644 --- a/firebase/firestore/lib/src/services/auth.dart +++ b/firebase/firestore/lib/src/services/auth.dart @@ -32,11 +32,32 @@ class Auth { /// Available scopes are held as constants in [Scopes]. static Future setScopes(String email, List scopes) async => auth.setCustomUserClaims( - (await auth.getUserByEmail(email)).uid, + (await getUser(email)).uid, {"isAdmin": scopes.isNotEmpty, "scopes": scopes} ); - static Future> getClaims(String email) async => dartify( - (await auth.getUserByEmail(email)).customClaims - ); + /// Creates a user. + static Future createUser(String email) => auth + .createUser(fb.CreateUserRequest(email: email)); + + /// Gets the user with the given email, or creates one + static Future getUser(String email) async { + try { + return await auth.getUserByEmail(email); + } catch (error) { // ignore: avoid_catches_without_on_clauses + // package:firebase_admin_interop is not so good with error types + if (error.code == "auth/user-not-found") { + await createUser(email); + return getUser(email); + } else { + rethrow; + } + } + } + + /// Gets the custom claims for a user. + static Future> getClaims(String email) async { + final fb.UserRecord user = await getUser(email); + return dartify(user.customClaims); + } } diff --git a/firebase/firestore/lib/src/services/firestore.dart b/firebase/firestore/lib/src/services/firestore.dart index 131b23910..3e312d0c3 100644 --- a/firebase/firestore/lib/src/services/firestore.dart +++ b/firebase/firestore/lib/src/services/firestore.dart @@ -21,11 +21,6 @@ class Firestore { /// The name of the feedback collection. static const String feedbackKey = "feedback"; - /// The name of the reminders collection. - static const String remindersKey = "reminders"; - - static const String adminsKey = "admin"; - /// The students collection. static final fb.CollectionReference studentsCollection = firestore.collection(studentsKey); @@ -42,14 +37,7 @@ class Firestore { static final fb.CollectionReference feedbackCollection = firestore.collection(feedbackKey); - /// The reminders collection. - static final fb.CollectionReference remindersCollection = - firestore.collection(remindersKey); - - static final fb.CollectionReference adminsCollection = - firestore.collection(adminsKey); - - /// Deletes reminders froma given user that fit a predicate function. + /// Removes and returns reminders from a given user. /// /// If [transaction] is null, one will be created and passed to this function. static Future>> deleteRemindersFromUser( @@ -64,7 +52,8 @@ class Firestore { ); } - final fb.DocumentReference document = remindersCollection.document(email); + final fb.DocumentReference document = studentsCollection + .document(email).collection("reminders").document(email); final fb.DocumentSnapshot snapshot = await transaction.get(document); final Map data = snapshot.data.toMap(); @@ -104,7 +93,7 @@ class Firestore { for (final User user in users) { batch.setData( studentsCollection.document(user.email), - fb.DocumentData.fromMap(user.json) + fb.DocumentData.fromMap(user.json), ); } return batch.commit(); @@ -121,7 +110,7 @@ class Firestore { "month": month, "calendar": [ for (final Day day in calendar) - day.json + day?.json ], } ) @@ -162,11 +151,4 @@ class Firestore { ]; return calendar; } - - static Future uploadAdmin(String email) async => adminsCollection - .document(email) - .setData( - fb.DocumentData.fromMap({"email": email}), - fb.SetOptions(merge: true), - ); } diff --git a/firebase/firestore/lib/src/students/logic.dart b/firebase/firestore/lib/src/students/logic.dart index bb538d90b..5d9e2d13a 100644 --- a/firebase/firestore/lib/src/students/logic.dart +++ b/firebase/firestore/lib/src/students/logic.dart @@ -63,7 +63,19 @@ class StudentLogic { continue; } - if (semesters != null && !semesters [sectionId].semester2) { + final Semesters semestersForCourse = semesters [sectionId]; + if (semestersForCourse == null) { + Logger.error( + "Section $sectionId was in schedule.csv but not in sections.csv" + ); + } + if ( + semesters != null && + !(isSemester1 + ? semestersForCourse.semester1 + : semestersForCourse.semester2 + ) + ) { continue; } else if (sectionId.startsWith("12")) { seniors.add(student); diff --git a/firebase/firestore/lib/src/students/reader.dart b/firebase/firestore/lib/src/students/reader.dart index 42b721678..e00ec33d1 100644 --- a/firebase/firestore/lib/src/students/reader.dart +++ b/firebase/firestore/lib/src/students/reader.dart @@ -1,3 +1,4 @@ +import "package:firestore/constants.dart"; import "package:firestore/data.dart"; import "package:firestore/helpers.dart"; @@ -13,12 +14,13 @@ class StudentReader { /// Maps student IDs to their respective [User] objects. static Future> getStudents() async => { await for (final Map entry in csvReader(DataFiles.students)) - entry ["ID"]: User( - first: entry ["First Name"], - last: entry ["Last Name"], - email: entry ["Student E-mail"].toLowerCase(), - id: entry ["ID"], - ) + if (!corruptStudents.contains(entry ["ID"])) + entry ["ID"]: User( + first: entry ["First Name"], + last: entry ["Last Name"], + email: entry ["Student E-mail"].toLowerCase(), + id: entry ["ID"], + ) }; /// Maps homeroom section IDs to their respective rooms. @@ -63,6 +65,9 @@ class StudentReader { static Future>> getStudentClasses() async { final Map> result = DefaultMap((_) => []); await for (final Map entry in csvReader(DataFiles.schedule)) { + if (corruptStudents.contains(entry ["STUDENT_ID"])) { + continue; + } result [entry ["STUDENT_ID"]].add(entry ["SECTION_ID"]); } return result; diff --git a/firebase/firestore/node/admins.dart b/firebase/firestore/node/admins.dart index fb233701e..29dae00ab 100644 --- a/firebase/firestore/node/admins.dart +++ b/firebase/firestore/node/admins.dart @@ -1,3 +1,5 @@ +// @dart=2.9 + import "package:firestore/helpers.dart"; import "package:firestore/services.dart"; @@ -10,10 +12,13 @@ Future setClaims(Map> admins) async { for (final MapEntry> entry in admins.entries) { final String email = entry.key; final List scopes = entry.value; - assert( - scopes.every(Scopes.scopes.contains), - "Cannot parse scopes for: $email. Got: $scopes" - ); + if (!scopes.every(Scopes.scopes.contains)) { + throw ArgumentError.value( + scopes.toString(), + "admin scopes", + "Unrecognized scopes for $email" + ); + } Logger.verbose("Setting claims for $email"); if (entry.value.isEmpty) { Logger.warning("Removing admin privileges for $email"); @@ -22,7 +27,6 @@ Future setClaims(Map> admins) async { "Previous claims for $email", () => Auth.getClaims(email) ); await Auth.setScopes(email, scopes); - await Firestore.uploadAdmin(email); } } @@ -34,10 +38,7 @@ Future main() async { await Logger.logValue("admins", getAdmins); if (Args.upload) { - await Logger.logProgress( - "setting admin claims", - () async => setClaims(admins) - ); + await setClaims(admins); } else { Logger.warning("Did not upload admin claims. Use the --upload flag."); } diff --git a/firebase/firestore/node/calendar.dart b/firebase/firestore/node/calendar.dart index 5bf5061c7..cf6493850 100644 --- a/firebase/firestore/node/calendar.dart +++ b/firebase/firestore/node/calendar.dart @@ -1,3 +1,5 @@ +// @dart=2.9 + import "package:firestore/data.dart"; import "package:firestore/helpers.dart"; import "package:firestore/services.dart"; diff --git a/firebase/firestore/node/faculty.dart b/firebase/firestore/node/faculty.dart index 61bd2a7e3..540ed7d55 100644 --- a/firebase/firestore/node/faculty.dart +++ b/firebase/firestore/node/faculty.dart @@ -1,3 +1,5 @@ +// @dart=2.9 + import "package:firestore/data.dart"; import "package:firestore/helpers.dart"; import "package:firestore/faculty.dart"; @@ -13,7 +15,7 @@ Future main() async { ); final Map sectionTeachers = await Logger.logValue( - "section teachers", () => SectionReader.getSectionTeachers(id: true) + "section teachers", SectionReader.getSectionFacultyIds, ); final Map> facultySections = await Logger.logValue( diff --git a/firebase/firestore/node/feedback.dart b/firebase/firestore/node/feedback.dart index f0d8590ce..db18930ce 100644 --- a/firebase/firestore/node/feedback.dart +++ b/firebase/firestore/node/feedback.dart @@ -1,3 +1,5 @@ +// @dart=2.9 + import "package:firestore/data.dart"; import "package:firestore/helpers.dart"; import "package:firestore/services.dart"; diff --git a/firebase/firestore/node/sections.dart b/firebase/firestore/node/sections.dart index 274807983..7c46ba13e 100644 --- a/firebase/firestore/node/sections.dart +++ b/firebase/firestore/node/sections.dart @@ -1,4 +1,7 @@ +// @dart=2.9 + import "package:firestore/data.dart"; +import "package:firestore/faculty.dart"; import "package:firestore/helpers.dart"; import "package:firestore/sections.dart"; import "package:firestore/services.dart"; @@ -11,13 +14,24 @@ Future main() async { ); final Map sectionTeachers = await Logger.logValue( - "section teachers", SectionReader.getSectionTeachers + "section teachers", SectionReader.getSectionFacultyIds + ); + + final Map facultyNames = await Logger.logValue( + "faculty names", FacultyReader.getFaculty, + ); + + final Map zoomLinks = await Logger.logValue( + "zoom links", ZoomLinkReader.getZoomLinks, ); + Logger.info("Found ${zoomLinks.keys.length} zoom links"); final List
sections = await Logger.logValue( "sections list", () => SectionLogic.getSections( courseNames: courseNames, sectionTeachers: sectionTeachers, + facultyNames: facultyNames, + zoomLinks: zoomLinks, ) ); diff --git a/firebase/firestore/node/students.dart b/firebase/firestore/node/students.dart index ad1198c1c..bffe1fbbf 100644 --- a/firebase/firestore/node/students.dart +++ b/firebase/firestore/node/students.dart @@ -1,3 +1,6 @@ +// @dart=2.9 + +import "package:firestore/constants.dart"; import "package:firestore/data.dart"; import "package:firestore/helpers.dart"; import "package:firestore/services.dart"; @@ -41,6 +44,8 @@ Future main() async { final Map homerooms = StudentLogic.homerooms; Logger.debug("Homerooms", homerooms); + Logger.debug("Seniors", StudentLogic.seniors); + final List studentsWithSchedules = await Logger.logValue( "student schedules", () => StudentLogic.getStudentsWithSchedules( schedules: schedules, @@ -51,6 +56,25 @@ Future main() async { User.verifySchedules(studentsWithSchedules); + if (Args.inArgs({"-t", "--testers"})) { + studentsWithSchedules.clear(); + } + + final List testUsers = [ + for (final Map tester in testers) + User.empty( + email: tester ["email"], + first: tester ["first"], + last: tester ["last"], + ) + ]; + Logger.info( + "Found ${testUsers.length} testers. " + "Use the --testers flag to only process testers" + ); + Logger.debug("Testers", testUsers); + studentsWithSchedules.addAll(testUsers); + Logger.info("Finished data indexing."); if (Args.upload) { @@ -61,5 +85,5 @@ Future main() async { Logger.warning("Did not upload student data. Use the --upload flag."); } await app.delete(); - Logger.info("Processed ${students.length} users."); + Logger.info("Processed ${studentsWithSchedules.length} users."); } diff --git a/firebase/firestore/node/zoom_links.dart b/firebase/firestore/node/zoom_links.dart deleted file mode 100644 index c9af066d9..000000000 --- a/firebase/firestore/node/zoom_links.dart +++ /dev/null @@ -1,55 +0,0 @@ -import "package:firestore/data.dart"; -import "package:firestore/helpers.dart"; -import "package:firestore/services.dart"; -import "package:firestore/students.dart"; - -extension RegexMatcher on RegExp { - bool fullyMatches(String str) { - final String match = stringMatch(str); - return match == str; - } -} - -bool isZoomReminder(Map reminder) => - reminder ["time"] ["type"] == "subject" && - reminder ["time"] ["repeats"] == true && - RegExp(r"\d{3}-\d{3}-\d{4}").fullyMatches(reminder ["message"] as String); - -Future printZoomReminders() async { - for (final User user in (await StudentReader.getStudents()).values) { - final document = Firestore.remindersCollection.document(user.email); - final Map data = (await document.get()).data.toMap(); - final List> reminders = [ - for (final dynamic reminder in data ["reminders"] ?? []) - Map.from(reminder) - ]; - if ( - reminders == null || - reminders.isEmpty || - !reminders.any(isZoomReminder) - ) { - continue; - } - - Logger.debug(user.email, reminders); - - - // await Firestore.deleteRemindersFromUser( - // user.email, - // (Map reminder) => - // reminder ["time"] ["type"] == "subject" && - // reminder ["time"] ["repeats"] == true && - // RegExp(r"\d{3}-\d{3}-\d{4}").fullyMatches(reminder ["message"] as String), - // ); - } -} - -Future main(List args) async { - Args.initLogger("Reading zoom reminders"); - if (Args.upload) { - await Logger.logProgress("zoom reminders search", printZoomReminders); - } else { - Logger.info("Did not read reminders. Use the --upload flag."); - } - await app.delete(); -} diff --git a/firebase/firestore/pubspec.yaml b/firebase/firestore/pubspec.yaml index dbbcb50a1..73560ab86 100644 --- a/firebase/firestore/pubspec.yaml +++ b/firebase/firestore/pubspec.yaml @@ -4,17 +4,33 @@ description: A sample command-line application. # homepage: https://www.example.com environment: - sdk: '>=2.7.0 <3.0.0' + sdk: '>=2.9.0 <3.0.0' dependencies: - meta: any - # prior to 2.1.0, the values for custom claims had to be Strings - # (I changed that with my own PR -- be the change you want to see in the world) firebase_admin_interop: ^2.1.0 - node_io: ^1.1.1 - build_runner: any - build_node_compilers: any - node_interop: any + meta: ^1.3.0 + node_interop: <=1.2.1 + node_io: ^1.2.0 + yaml: ^2.1.2 + +dependency_overrides: + build_node_compilers: + git: + url: https://github.com/pulyaevskiy/node-interop.git + path: build_node_compilers + #node_io: + # git: + # url: https://github.com/pulyaevskiy/node-interop.git + # path: node_io + #node_interop: + # git: + # url: https://github.com/pulyaevskiy/node-interop.git + # path: node_interop + # version: <=1.2.1 + + +dev_dependencies: + build_node_compilers: ^0.3.2 + build_runner: ^1.0.0 -# dev_dependencies: # test: ^1.6.0 diff --git a/firebase/rules_test/package-lock.json b/firebase/rules_test/package-lock.json index 7b75c0b32..d12e3cf24 100644 --- a/firebase/rules_test/package-lock.json +++ b/firebase/rules_test/package-lock.json @@ -113,6 +113,550 @@ "@grpc/proto-loader": "^0.5.0", "grpc": "1.24.2", "tslib": "1.11.1" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "dev": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "grpc": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/grpc/-/grpc-1.24.2.tgz", + "integrity": "sha512-EG3WH6AWMVvAiV15d+lr+K77HJ/KV/3FvMpjKjulXHbTwgDZkhkcWbwhxFAoTdxTkQvy0WFcO3Nog50QBbHZWw==", + "dev": true, + "requires": { + "@types/bytebuffer": "^5.0.40", + "lodash.camelcase": "^4.3.0", + "lodash.clone": "^4.5.0", + "nan": "^2.13.2", + "node-pre-gyp": "^0.14.0", + "protobufjs": "^5.0.3" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.3", + "bundled": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "debug": { + "version": "3.2.6", + "bundled": true, + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "fs-minipass": { + "version": "1.2.7", + "bundled": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.4", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "bundled": true, + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "bundled": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "needle": { + "version": "2.4.0", + "bundled": true, + "dev": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.14.0", + "bundled": true, + "dev": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.6", + "bundled": true, + "dev": true + }, + "npm-packlist": { + "version": "1.4.6", + "bundled": true, + "dev": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.7.1", + "bundled": true, + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true + }, + "semver": { + "version": "5.7.1", + "bundled": true, + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.1.1", + "bundled": true + } + } + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "dev": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "protobufjs": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-5.0.3.tgz", + "integrity": "sha512-55Kcx1MhPZX0zTbVosMQEO5R6/rikNXd9b6RQK4KSPcrSIIwoXTtebIczUrXlwaSrbz4x8XUVThGPob1n8I4QA==", + "dev": true, + "requires": { + "ascli": "~1", + "bytebuffer": "~5", + "glob": "^7.0.5", + "yargs": "^3.10.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "dev": true, + "requires": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } } }, "@firebase/firestore-types": { @@ -274,6 +818,550 @@ "firebase": "7.11.0", "grpc": "1.24.2", "request": "2.88.2" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "dev": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "grpc": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/grpc/-/grpc-1.24.2.tgz", + "integrity": "sha512-EG3WH6AWMVvAiV15d+lr+K77HJ/KV/3FvMpjKjulXHbTwgDZkhkcWbwhxFAoTdxTkQvy0WFcO3Nog50QBbHZWw==", + "dev": true, + "requires": { + "@types/bytebuffer": "^5.0.40", + "lodash.camelcase": "^4.3.0", + "lodash.clone": "^4.5.0", + "nan": "^2.13.2", + "node-pre-gyp": "^0.14.0", + "protobufjs": "^5.0.3" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.3", + "bundled": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "debug": { + "version": "3.2.6", + "bundled": true, + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "fs-minipass": { + "version": "1.2.7", + "bundled": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.4", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "bundled": true, + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "bundled": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "needle": { + "version": "2.4.0", + "bundled": true, + "dev": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.14.0", + "bundled": true, + "dev": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.6", + "bundled": true, + "dev": true + }, + "npm-packlist": { + "version": "1.4.6", + "bundled": true, + "dev": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.7.1", + "bundled": true, + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true + }, + "semver": { + "version": "5.7.1", + "bundled": true, + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.1.1", + "bundled": true + } + } + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "dev": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "protobufjs": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-5.0.3.tgz", + "integrity": "sha512-55Kcx1MhPZX0zTbVosMQEO5R6/rikNXd9b6RQK4KSPcrSIIwoXTtebIczUrXlwaSrbz4x8XUVThGPob1n8I4QA==", + "dev": true, + "requires": { + "ascli": "~1", + "bytebuffer": "~5", + "glob": "^7.0.5", + "yargs": "^3.10.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "dev": true, + "requires": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } } }, "@firebase/util": { @@ -301,6 +1389,37 @@ "protobufjs": "^6.8.6" } }, + "@mapbox/node-pre-gyp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", + "integrity": "sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==", + "requires": { + "detect-libc": "^1.0.3", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.1", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "rimraf": "^3.0.2", + "semver": "^7.3.4", + "tar": "^6.1.0" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -366,9 +1485,9 @@ "dev": true }, "@types/bytebuffer": { - "version": "5.0.40", - "resolved": "https://registry.npmjs.org/@types/bytebuffer/-/bytebuffer-5.0.40.tgz", - "integrity": "sha512-h48dyzZrPMz25K6Q4+NCwWaxwXany2FhQg/ErOcdZS1ZpsaDnDMZg8JYLMTGz7uvXKrcKGJUZJlZObyfgdaN9g==", + "version": "5.0.42", + "resolved": "https://registry.npmjs.org/@types/bytebuffer/-/bytebuffer-5.0.42.tgz", + "integrity": "sha512-lEgKojWUAc/MG2t649oZS5AfYFP2xRNPoDuwDBlBMjHXd8MaGPgFgtCXUK7inZdBOygmVf10qxc1Us8GXC96aw==", "requires": { "@types/long": "*", "@types/node": "*" @@ -408,10 +1527,38 @@ "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==", "dev": true }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ajv": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz", - "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -450,6 +1597,20 @@ "picomatch": "^2.0.4" } }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -504,8 +1665,7 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "bcrypt-pbkdf": { "version": "1.0.2", @@ -526,7 +1686,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -611,6 +1770,11 @@ "readdirp": "~3.2.0" } }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, "cliui": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", @@ -658,8 +1822,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "configuration-processor": { "version": "1.1.1", @@ -667,6 +1830,11 @@ "integrity": "sha1-8hiQhbJZ2M2eXnwqy67Fx/FiBaI=", "dev": true }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, "core-js": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz", @@ -676,8 +1844,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "dashdash": { "version": "1.14.1", @@ -726,6 +1893,16 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -924,11 +2101,18 @@ "mime-types": "^2.1.12" } }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.1.2", @@ -943,6 +2127,21 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -962,7 +2161,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -973,9 +2171,9 @@ } }, "glob-parent": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", - "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -988,315 +2186,18 @@ "dev": true }, "grpc": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/grpc/-/grpc-1.24.2.tgz", - "integrity": "sha512-EG3WH6AWMVvAiV15d+lr+K77HJ/KV/3FvMpjKjulXHbTwgDZkhkcWbwhxFAoTdxTkQvy0WFcO3Nog50QBbHZWw==", + "version": "1.24.9", + "resolved": "https://registry.npmjs.org/grpc/-/grpc-1.24.9.tgz", + "integrity": "sha512-BOq1AJocZJcG/6qyX3LX2KvKy91RIix10GFLhqWg+1L6b73uWIN2w0cq+lSi0q9mXfkjeFaBz83+oau7oJqG3Q==", "requires": { + "@mapbox/node-pre-gyp": "^1.0.4", "@types/bytebuffer": "^5.0.40", "lodash.camelcase": "^4.3.0", "lodash.clone": "^4.5.0", "nan": "^2.13.2", - "node-pre-gyp": "^0.14.0", "protobufjs": "^5.0.3" }, "dependencies": { - "abbrev": { - "version": "1.1.1", - "bundled": true - }, - "ansi-regex": { - "version": "2.1.1", - "bundled": true - }, - "aproba": { - "version": "1.2.0", - "bundled": true - }, - "are-we-there-yet": { - "version": "1.1.5", - "bundled": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.6" - } - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "chownr": { - "version": "1.1.3", - "bundled": true - }, - "code-point-at": { - "version": "1.1.0", - "bundled": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true - }, - "core-util-is": { - "version": "1.0.2", - "bundled": true - }, - "debug": { - "version": "3.2.6", - "bundled": true, - "requires": { - "ms": "^2.1.1" - } - }, - "deep-extend": { - "version": "0.6.0", - "bundled": true - }, - "delegates": { - "version": "1.0.0", - "bundled": true - }, - "detect-libc": { - "version": "1.0.3", - "bundled": true - }, - "fs-minipass": { - "version": "1.2.7", - "bundled": true, - "requires": { - "minipass": "^2.6.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true - }, - "gauge": { - "version": "2.7.4", - "bundled": true, - "requires": { - "aproba": "^1.0.3", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.0", - "object-assign": "^4.1.0", - "signal-exit": "^3.0.0", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wide-align": "^1.1.0" - } - }, - "glob": { - "version": "7.1.4", - "bundled": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true - }, - "iconv-lite": { - "version": "0.4.24", - "bundled": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ignore-walk": { - "version": "3.0.3", - "bundled": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "bundled": true - }, - "ini": { - "version": "1.3.5", - "bundled": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "bundled": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "bundled": true - }, - "minimatch": { - "version": "3.0.4", - "bundled": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.0", - "bundled": true - }, - "minipass": { - "version": "2.9.0", - "bundled": true, - "requires": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - }, - "minizlib": { - "version": "1.3.3", - "bundled": true, - "requires": { - "minipass": "^2.9.0" - } - }, - "mkdirp": { - "version": "0.5.1", - "bundled": true, - "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "bundled": true - } - } - }, - "ms": { - "version": "2.1.2", - "bundled": true - }, - "needle": { - "version": "2.4.0", - "bundled": true, - "requires": { - "debug": "^3.2.6", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - } - }, - "node-pre-gyp": { - "version": "0.14.0", - "bundled": true, - "requires": { - "detect-libc": "^1.0.2", - "mkdirp": "^1.0.0", - "needle": "^2.2.1", - "nopt": "^4.0.1", - "npm-packlist": "^1.1.6", - "npmlog": "^4.0.2", - "rc": "^1.2.7", - "rimraf": "^2.6.1", - "semver": "^5.3.0", - "tar": "^4.4.2" - }, - "dependencies": { - "mkdirp": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.3.tgz", - "integrity": "sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==" - } - } - }, - "nopt": { - "version": "4.0.1", - "bundled": true, - "requires": { - "abbrev": "1", - "osenv": "^0.1.4" - } - }, - "npm-bundled": { - "version": "1.0.6", - "bundled": true - }, - "npm-packlist": { - "version": "1.4.6", - "bundled": true, - "requires": { - "ignore-walk": "^3.0.1", - "npm-bundled": "^1.0.1" - } - }, - "npmlog": { - "version": "4.1.2", - "bundled": true, - "requires": { - "are-we-there-yet": "~1.1.2", - "console-control-strings": "~1.1.0", - "gauge": "~2.7.3", - "set-blocking": "~2.0.0" - } - }, - "number-is-nan": { - "version": "1.0.1", - "bundled": true - }, - "object-assign": { - "version": "4.1.1", - "bundled": true - }, - "once": { - "version": "1.4.0", - "bundled": true, - "requires": { - "wrappy": "1" - } - }, - "os-homedir": { - "version": "1.0.2", - "bundled": true - }, - "os-tmpdir": { - "version": "1.0.2", - "bundled": true - }, - "osenv": { - "version": "0.1.5", - "bundled": true, - "requires": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true - }, - "process-nextick-args": { - "version": "2.0.1", - "bundled": true - }, "protobufjs": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-5.0.3.tgz", @@ -1307,133 +2208,6 @@ "glob": "^7.0.5", "yargs": "^3.10.0" } - }, - "rc": { - "version": "1.2.8", - "bundled": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.5", - "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - } - } - }, - "readable-stream": { - "version": "2.3.6", - "bundled": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "rimraf": { - "version": "2.7.1", - "bundled": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.1.2", - "bundled": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true - }, - "sax": { - "version": "1.2.4", - "bundled": true - }, - "semver": { - "version": "5.7.1", - "bundled": true - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true - }, - "signal-exit": { - "version": "3.0.2", - "bundled": true - }, - "string-width": { - "version": "1.0.2", - "bundled": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "string_decoder": { - "version": "1.1.1", - "bundled": true, - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "bundled": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "bundled": true - }, - "tar": { - "version": "4.4.13", - "bundled": true, - "requires": { - "chownr": "^1.1.1", - "fs-minipass": "^1.2.5", - "minipass": "^2.8.6", - "minizlib": "^1.2.1", - "mkdirp": "^1.0.0", - "safe-buffer": "^5.1.2", - "yallist": "^3.0.3" - }, - "dependencies": { - "mkdirp": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.3.tgz", - "integrity": "sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==" - } - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true - }, - "wide-align": { - "version": "1.1.3", - "bundled": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true - }, - "yallist": { - "version": "3.1.1", - "bundled": true } } }, @@ -1474,6 +2248,11 @@ "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1497,6 +2276,30 @@ "sshpk": "^1.7.0" } }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1516,7 +2319,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -1525,7 +2327,12 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, "invert-kv": { @@ -1619,6 +2426,11 @@ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1706,9 +2518,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, "lodash.camelcase": { @@ -1736,6 +2548,29 @@ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", "dev": true }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, "mime-db": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", @@ -1755,11 +2590,38 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "minipass": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", + "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, "mocha": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.1.0.tgz", @@ -1853,9 +2715,9 @@ } }, "y18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", "dev": true }, "yargs": { @@ -1885,9 +2747,9 @@ "dev": true }, "nan": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", - "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" }, "node-environment-flags": { "version": "1.0.6", @@ -1909,12 +2771,31 @@ "is-stream": "^1.0.1" } }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "requires": { + "abbrev": "1" + } + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -1926,6 +2807,11 @@ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "dev": true }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", @@ -1964,7 +2850,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -2015,8 +2900,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "performance-now": { "version": "2.1.0", @@ -2036,6 +2920,11 @@ "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "dev": true }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "promise-polyfill": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz", @@ -2089,6 +2978,27 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "readdirp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", @@ -2151,6 +3061,14 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, "safe-buffer": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", @@ -2172,8 +3090,12 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, "sprintf-js": { "version": "1.0.3", @@ -2228,6 +3150,21 @@ "function-bind": "^1.1.1" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -2251,6 +3188,19 @@ "has-flag": "^3.0.0" } }, + "tar": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2300,6 +3250,11 @@ "punycode": "^2.1.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -2359,7 +3314,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, "requires": { "string-width": "^1.0.2 || 2" } @@ -2381,8 +3335,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "xmlhttprequest": { "version": "1.8.0", @@ -2391,9 +3344,14 @@ "dev": true }, "y18n": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", - "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yargs": { "version": "3.32.0", @@ -2499,9 +3457,9 @@ } }, "y18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", "dev": true }, "yargs": { diff --git a/firebase/rules_test/package.json b/firebase/rules_test/package.json index ccc0919a4..c9e2dcc8d 100644 --- a/firebase/rules_test/package.json +++ b/firebase/rules_test/package.json @@ -13,6 +13,6 @@ "prettier": "^1.19.1" }, "dependencies": { - "grpc": "^1.24.2" + "grpc": "^1.24.9" } } diff --git a/images/contributors/brayden-kohler - Copy.jpg b/images/contributors/brayden-kohler - Copy.jpg new file mode 100644 index 000000000..d100077ed Binary files /dev/null and b/images/contributors/brayden-kohler - Copy.jpg differ diff --git a/images/contributors/brayden-kohler.jpg b/images/contributors/brayden-kohler.jpg new file mode 100644 index 000000000..af24269bd Binary files /dev/null and b/images/contributors/brayden-kohler.jpg differ diff --git a/images/contributors/david-tarrab - Copy.jpg b/images/contributors/david-tarrab - Copy.jpg new file mode 100644 index 000000000..93df69325 Binary files /dev/null and b/images/contributors/david-tarrab - Copy.jpg differ diff --git a/images/contributors/david-tarrab.jpg b/images/contributors/david-tarrab.jpg new file mode 100644 index 000000000..17f9ff29f Binary files /dev/null and b/images/contributors/david-tarrab.jpg differ diff --git a/images/contributors/eli-vovsha - Copy.jpg b/images/contributors/eli-vovsha - Copy.jpg new file mode 100644 index 000000000..b6c816dce Binary files /dev/null and b/images/contributors/eli-vovsha - Copy.jpg differ diff --git a/images/contributors/eli-vovsha.jpg b/images/contributors/eli-vovsha.jpg new file mode 100644 index 000000000..d3961030d Binary files /dev/null and b/images/contributors/eli-vovsha.jpg differ diff --git a/images/contributors/josh-todes - Copy.jpg b/images/contributors/josh-todes - Copy.jpg new file mode 100644 index 000000000..9d735c50d Binary files /dev/null and b/images/contributors/josh-todes - Copy.jpg differ diff --git a/images/contributors/josh-todes.jpg b/images/contributors/josh-todes.jpg new file mode 100644 index 000000000..e1dc3b4dc Binary files /dev/null and b/images/contributors/josh-todes.jpg differ diff --git a/images/contributors/levi-lesches - Copy.jpg b/images/contributors/levi-lesches - Copy.jpg new file mode 100644 index 000000000..28c22211a Binary files /dev/null and b/images/contributors/levi-lesches - Copy.jpg differ diff --git a/images/contributors/levi-lesches.jpg b/images/contributors/levi-lesches.jpg new file mode 100644 index 000000000..82becd95e Binary files /dev/null and b/images/contributors/levi-lesches.jpg differ diff --git a/images/icons/baseball.png b/images/icons/baseball.png deleted file mode 100644 index fb7b62641..000000000 Binary files a/images/icons/baseball.png and /dev/null differ diff --git a/images/icons/basketball.png b/images/icons/basketball.png deleted file mode 100644 index 6a1926bb2..000000000 Binary files a/images/icons/basketball.png and /dev/null differ diff --git a/images/icons/hockey.png b/images/icons/hockey.png deleted file mode 100644 index c62a6e7c9..000000000 Binary files a/images/icons/hockey.png and /dev/null differ diff --git a/images/icons/soccer.png b/images/icons/soccer.png deleted file mode 100644 index 20eade68a..000000000 Binary files a/images/icons/soccer.png and /dev/null differ diff --git a/images/icons/tennis.png b/images/icons/tennis.png deleted file mode 100644 index 6f989b38e..000000000 Binary files a/images/icons/tennis.png and /dev/null differ diff --git a/images/icons/volleyball.png b/images/icons/volleyball.png deleted file mode 100644 index e35ce4cb0..000000000 Binary files a/images/icons/volleyball.png and /dev/null differ diff --git a/images/logos/google_sign_in.png b/images/logos/google_sign_in.png new file mode 100644 index 000000000..3f52a66ae Binary files /dev/null and b/images/logos/google_sign_in.png differ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9367d483e..e3f60149b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -2,25 +2,25 @@ - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 8.0 + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11 diff --git a/ios/Flutter/Flutter.podspec b/ios/Flutter/Flutter.podspec deleted file mode 100644 index 5ca30416b..000000000 --- a/ios/Flutter/Flutter.podspec +++ /dev/null @@ -1,18 +0,0 @@ -# -# NOTE: This podspec is NOT to be published. It is only used as a local source! -# - -Pod::Spec.new do |s| - s.name = 'Flutter' - s.version = '1.0.0' - s.summary = 'High-performance, high-fidelity mobile apps.' - s.description = <<-DESC -Flutter provides an easy and productive way to build and deploy high-performance mobile apps for Android and iOS. - DESC - s.homepage = 'https://flutter.io' - s.license = { :type => 'MIT' } - s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } - s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } - s.ios.deployment_target = '8.0' - s.vendored_frameworks = 'Flutter.framework' -end diff --git a/ios/Podfile b/ios/Podfile index a4af0a922..17e02dd3e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '9.0' +platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -28,6 +28,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '8.11.0' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4f2d9d3b7..a9cd78444 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,409 +1,134 @@ PODS: - - abseil/algorithm (0.20200225.0): - - abseil/algorithm/algorithm (= 0.20200225.0) - - abseil/algorithm/container (= 0.20200225.0) - - abseil/algorithm/algorithm (0.20200225.0): - - abseil/base/config - - abseil/algorithm/container (0.20200225.0): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/base (0.20200225.0): - - abseil/base/atomic_hook (= 0.20200225.0) - - abseil/base/base (= 0.20200225.0) - - abseil/base/base_internal (= 0.20200225.0) - - abseil/base/bits (= 0.20200225.0) - - abseil/base/config (= 0.20200225.0) - - abseil/base/core_headers (= 0.20200225.0) - - abseil/base/dynamic_annotations (= 0.20200225.0) - - abseil/base/endian (= 0.20200225.0) - - abseil/base/errno_saver (= 0.20200225.0) - - abseil/base/exponential_biased (= 0.20200225.0) - - abseil/base/log_severity (= 0.20200225.0) - - abseil/base/malloc_internal (= 0.20200225.0) - - abseil/base/periodic_sampler (= 0.20200225.0) - - abseil/base/pretty_function (= 0.20200225.0) - - abseil/base/raw_logging_internal (= 0.20200225.0) - - abseil/base/spinlock_wait (= 0.20200225.0) - - abseil/base/throw_delegate (= 0.20200225.0) - - abseil/base/atomic_hook (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/base (0.20200225.0): - - abseil/base/atomic_hook - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/log_severity - - abseil/base/raw_logging_internal - - abseil/base/spinlock_wait - - abseil/meta/type_traits - - abseil/base/base_internal (0.20200225.0): - - abseil/base/config - - abseil/meta/type_traits - - abseil/base/bits (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/config (0.20200225.0) - - abseil/base/core_headers (0.20200225.0): - - abseil/base/config - - abseil/base/dynamic_annotations (0.20200225.0) - - abseil/base/endian (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/errno_saver (0.20200225.0): - - abseil/base/config - - abseil/base/exponential_biased (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/log_severity (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/malloc_internal (0.20200225.0): - - abseil/base/base - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/raw_logging_internal - - abseil/base/periodic_sampler (0.20200225.0): - - abseil/base/core_headers - - abseil/base/exponential_biased - - abseil/base/pretty_function (0.20200225.0) - - abseil/base/raw_logging_internal (0.20200225.0): - - abseil/base/atomic_hook - - abseil/base/config - - abseil/base/core_headers - - abseil/base/log_severity - - abseil/base/spinlock_wait (0.20200225.0): - - abseil/base/base_internal - - abseil/base/core_headers - - abseil/base/errno_saver - - abseil/base/throw_delegate (0.20200225.0): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/container/compressed_tuple (0.20200225.0): - - abseil/utility/utility - - abseil/container/inlined_vector (0.20200225.0): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/base/throw_delegate - - abseil/container/inlined_vector_internal - - abseil/memory/memory - - abseil/container/inlined_vector_internal (0.20200225.0): - - abseil/base/core_headers - - abseil/container/compressed_tuple - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/types/span - - abseil/memory (0.20200225.0): - - abseil/memory/memory (= 0.20200225.0) - - abseil/memory/memory (0.20200225.0): - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/meta (0.20200225.0): - - abseil/meta/type_traits (= 0.20200225.0) - - abseil/meta/type_traits (0.20200225.0): - - abseil/base/config - - abseil/numeric/int128 (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/strings/internal (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/raw_logging_internal - - abseil/meta/type_traits - - abseil/strings/str_format (0.20200225.0): - - abseil/strings/str_format_internal - - abseil/strings/str_format_internal (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/numeric/int128 - - abseil/strings/strings - - abseil/types/span - - abseil/strings/strings (0.20200225.0): - - abseil/base/base - - abseil/base/bits - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/raw_logging_internal - - abseil/base/throw_delegate - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/numeric/int128 - - abseil/strings/internal - - abseil/time (0.20200225.0): - - abseil/time/internal (= 0.20200225.0) - - abseil/time/time (= 0.20200225.0) - - abseil/time/internal (0.20200225.0): - - abseil/time/internal/cctz (= 0.20200225.0) - - abseil/time/internal/cctz (0.20200225.0): - - abseil/time/internal/cctz/civil_time (= 0.20200225.0) - - abseil/time/internal/cctz/time_zone (= 0.20200225.0) - - abseil/time/internal/cctz/civil_time (0.20200225.0): - - abseil/base/config - - abseil/time/internal/cctz/time_zone (0.20200225.0): - - abseil/base/config - - abseil/time/internal/cctz/civil_time - - abseil/time/time (0.20200225.0): - - abseil/base/base - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/numeric/int128 - - abseil/strings/strings - - abseil/time/internal/cctz/civil_time - - abseil/time/internal/cctz/time_zone - - abseil/types (0.20200225.0): - - abseil/types/any (= 0.20200225.0) - - abseil/types/bad_any_cast (= 0.20200225.0) - - abseil/types/bad_any_cast_impl (= 0.20200225.0) - - abseil/types/bad_optional_access (= 0.20200225.0) - - abseil/types/bad_variant_access (= 0.20200225.0) - - abseil/types/compare (= 0.20200225.0) - - abseil/types/optional (= 0.20200225.0) - - abseil/types/span (= 0.20200225.0) - - abseil/types/variant (= 0.20200225.0) - - abseil/types/any (0.20200225.0): - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/types/bad_any_cast - - abseil/utility/utility - - abseil/types/bad_any_cast (0.20200225.0): - - abseil/base/config - - abseil/types/bad_any_cast_impl - - abseil/types/bad_any_cast_impl (0.20200225.0): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/types/bad_optional_access (0.20200225.0): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/types/bad_variant_access (0.20200225.0): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/types/compare (0.20200225.0): - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/types/optional (0.20200225.0): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/types/bad_optional_access - - abseil/utility/utility - - abseil/types/span (0.20200225.0): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/base/throw_delegate - - abseil/meta/type_traits - - abseil/types/variant (0.20200225.0): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/types/bad_variant_access - - abseil/utility/utility - - abseil/utility/utility (0.20200225.0): - - abseil/base/base_internal - - abseil/base/config - - abseil/meta/type_traits - - AppAuth (1.4.0): - - AppAuth/Core (= 1.4.0) - - AppAuth/ExternalUserAgent (= 1.4.0) - - AppAuth/Core (1.4.0) - - AppAuth/ExternalUserAgent (1.4.0) - - BoringSSL-GRPC (0.0.7): - - BoringSSL-GRPC/Implementation (= 0.0.7) - - BoringSSL-GRPC/Interface (= 0.0.7) - - BoringSSL-GRPC/Implementation (0.0.7): - - BoringSSL-GRPC/Interface (= 0.0.7) - - BoringSSL-GRPC/Interface (0.0.7) - - cloud_firestore (0.14.0-2): - - Firebase/CoreOnly (~> 6.26.0) - - Firebase/Firestore (~> 6.26.0) + - AppAuth (1.6.0): + - AppAuth/Core (= 1.6.0) + - AppAuth/ExternalUserAgent (= 1.6.0) + - AppAuth/Core (1.6.0) + - AppAuth/ExternalUserAgent (1.6.0): + - AppAuth/Core + - cloud_firestore (3.1.7): + - Firebase/Firestore (= 8.11.0) - firebase_core - Flutter - - Crashlytics (3.14.0): - - Fabric (~> 1.10.2) - - Fabric (1.10.2) - - Firebase/Auth (6.26.0): + - Firebase/Auth (8.11.0): - Firebase/CoreOnly - - FirebaseAuth (~> 6.5.3) - - Firebase/Core (6.26.0): + - FirebaseAuth (~> 8.11.0) + - Firebase/CoreOnly (8.11.0): + - FirebaseCore (= 8.11.0) + - Firebase/Crashlytics (8.11.0): - Firebase/CoreOnly - - FirebaseAnalytics (= 6.6.0) - - Firebase/CoreOnly (6.26.0): - - FirebaseCore (= 6.7.2) - - Firebase/Firestore (6.26.0): + - FirebaseCrashlytics (~> 8.11.0) + - Firebase/Firestore (8.11.0): - Firebase/CoreOnly - - FirebaseFirestore (~> 1.15.0) - - Firebase/Messaging (6.26.0): + - FirebaseFirestore (~> 8.11.0) + - Firebase/Messaging (8.11.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 4.4.1) - - firebase_auth (0.18.0-1): - - Firebase/Auth (~> 6.26.0) - - Firebase/CoreOnly (~> 6.26.0) + - FirebaseMessaging (~> 8.11.0) + - firebase_auth (3.3.6): + - Firebase/Auth (= 8.11.0) - firebase_core - Flutter - - firebase_core (0.5.0): - - Firebase/CoreOnly (~> 6.26.0) + - firebase_core (1.12.0): + - Firebase/CoreOnly (= 8.11.0) - Flutter - - firebase_crashlytics (0.0.1): - - Crashlytics - - Fabric - - Firebase/Core + - firebase_crashlytics (2.5.1): + - Firebase/Crashlytics (= 8.11.0) + - firebase_core - Flutter - - firebase_messaging (0.0.1): - - Firebase/Core - - Firebase/Messaging + - firebase_messaging (11.2.6): + - Firebase/Messaging (= 8.11.0) + - firebase_core - Flutter - - FirebaseAnalytics (6.6.0): - - FirebaseCore (~> 6.7) - - FirebaseInstallations (~> 1.3) - - GoogleAppMeasurement (= 6.6.0) - - GoogleUtilities/AppDelegateSwizzler (~> 6.0) - - GoogleUtilities/MethodSwizzler (~> 6.0) - - GoogleUtilities/Network (~> 6.0) - - "GoogleUtilities/NSData+zlib (~> 6.0)" - - nanopb (~> 1.30905.0) - - FirebaseAnalyticsInterop (1.5.0) - - FirebaseAuth (6.5.3): - - FirebaseAuthInterop (~> 1.0) - - FirebaseCore (~> 6.6) - - GoogleUtilities/AppDelegateSwizzler (~> 6.5) - - GoogleUtilities/Environment (~> 6.5) - - GTMSessionFetcher/Core (~> 1.1) - - FirebaseAuthInterop (1.1.0) - - FirebaseCore (6.7.2): - - FirebaseCoreDiagnostics (~> 1.3) - - FirebaseCoreDiagnosticsInterop (~> 1.2) - - GoogleUtilities/Environment (~> 6.5) - - GoogleUtilities/Logger (~> 6.5) - - FirebaseCoreDiagnostics (1.5.0): - - GoogleDataTransport (~> 7.0) - - GoogleUtilities/Environment (~> 6.7) - - GoogleUtilities/Logger (~> 6.7) - - nanopb (~> 1.30905.0) - - FirebaseCoreDiagnosticsInterop (1.2.0) - - FirebaseFirestore (1.15.0): - - abseil/algorithm (= 0.20200225.0) - - abseil/base (= 0.20200225.0) - - abseil/memory (= 0.20200225.0) - - abseil/meta (= 0.20200225.0) - - abseil/strings/strings (= 0.20200225.0) - - abseil/time (= 0.20200225.0) - - abseil/types (= 0.20200225.0) - - FirebaseAuthInterop (~> 1.0) - - FirebaseCore (~> 6.2) - - "gRPC-C++ (~> 1.28.0)" - - leveldb-library (~> 1.22) - - nanopb (~> 1.30905.0) - - FirebaseInstallations (1.3.0): - - FirebaseCore (~> 6.6) - - GoogleUtilities/Environment (~> 6.6) - - GoogleUtilities/UserDefaults (~> 6.6) - - PromisesObjC (~> 1.2) - - FirebaseInstanceID (4.3.4): - - FirebaseCore (~> 6.6) - - FirebaseInstallations (~> 1.0) - - GoogleUtilities/Environment (~> 6.5) - - GoogleUtilities/UserDefaults (~> 6.5) - - FirebaseMessaging (4.4.1): - - FirebaseAnalyticsInterop (~> 1.5) - - FirebaseCore (~> 6.6) - - FirebaseInstanceID (~> 4.3) - - GoogleUtilities/AppDelegateSwizzler (~> 6.5) - - GoogleUtilities/Environment (~> 6.5) - - GoogleUtilities/Reachability (~> 6.5) - - GoogleUtilities/UserDefaults (~> 6.5) - - Protobuf (>= 3.9.2, ~> 3.9) + - FirebaseAuth (8.11.0): + - FirebaseCore (~> 8.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.7) + - GoogleUtilities/Environment (~> 7.7) + - GTMSessionFetcher/Core (~> 1.5) + - FirebaseCore (8.11.0): + - FirebaseCoreDiagnostics (~> 8.0) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/Logger (~> 7.7) + - FirebaseCoreDiagnostics (8.15.0): + - GoogleDataTransport (~> 9.1) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/Logger (~> 7.7) + - nanopb (~> 2.30908.0) + - FirebaseCrashlytics (8.11.0): + - FirebaseCore (~> 8.0) + - FirebaseInstallations (~> 8.0) + - GoogleDataTransport (~> 9.1) + - GoogleUtilities/Environment (~> 7.7) + - nanopb (~> 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - FirebaseFirestore (8.11.0): + - FirebaseFirestore/AutodetectLeveldb (= 8.11.0) + - FirebaseFirestore/AutodetectLeveldb (8.11.0): + - FirebaseFirestore/Base + - FirebaseFirestore/WithLeveldb + - FirebaseFirestore/Base (8.11.0) + - FirebaseFirestore/WithLeveldb (8.11.0): + - FirebaseFirestore/Base + - FirebaseInstallations (8.15.0): + - FirebaseCore (~> 8.0) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/UserDefaults (~> 7.7) + - PromisesObjC (< 3.0, >= 1.2) + - FirebaseMessaging (8.11.0): + - FirebaseCore (~> 8.0) + - FirebaseInstallations (~> 8.0) + - GoogleDataTransport (~> 9.1) + - GoogleUtilities/AppDelegateSwizzler (~> 7.7) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/Reachability (~> 7.7) + - GoogleUtilities/UserDefaults (~> 7.7) + - nanopb (~> 2.30908.0) - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter + - flutter_native_timezone (0.0.1): + - Flutter - google_sign_in (0.0.1): - Flutter - GoogleSignIn (~> 5.0) - - GoogleAppMeasurement (6.6.0): - - GoogleUtilities/AppDelegateSwizzler (~> 6.0) - - GoogleUtilities/MethodSwizzler (~> 6.0) - - GoogleUtilities/Network (~> 6.0) - - "GoogleUtilities/NSData+zlib (~> 6.0)" - - nanopb (~> 1.30905.0) - - GoogleDataTransport (7.2.0): - - nanopb (~> 1.30905.0) + - GoogleDataTransport (9.2.0): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) - GoogleSignIn (5.0.2): - AppAuth (~> 1.2) - GTMAppAuth (~> 1.0) - GTMSessionFetcher/Core (~> 1.1) - - GoogleUtilities/AppDelegateSwizzler (6.7.2): + - GoogleUtilities/AppDelegateSwizzler (7.8.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (6.7.2): - - PromisesObjC (~> 1.2) - - GoogleUtilities/Logger (6.7.2): + - GoogleUtilities/Environment (7.8.0): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.8.0): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (6.7.2): - - GoogleUtilities/Logger - - GoogleUtilities/Network (6.7.2): + - GoogleUtilities/Network (7.8.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (6.7.2)" - - GoogleUtilities/Reachability (6.7.2): + - "GoogleUtilities/NSData+zlib (7.8.0)" + - GoogleUtilities/Reachability (7.8.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (6.7.2): + - GoogleUtilities/UserDefaults (7.8.0): - GoogleUtilities/Logger - - "gRPC-C++ (1.28.2)": - - "gRPC-C++/Implementation (= 1.28.2)" - - "gRPC-C++/Interface (= 1.28.2)" - - "gRPC-C++/Implementation (1.28.2)": - - abseil/container/inlined_vector (= 0.20200225.0) - - abseil/memory/memory (= 0.20200225.0) - - abseil/strings/str_format (= 0.20200225.0) - - abseil/strings/strings (= 0.20200225.0) - - abseil/types/optional (= 0.20200225.0) - - "gRPC-C++/Interface (= 1.28.2)" - - gRPC-Core (= 1.28.2) - - "gRPC-C++/Interface (1.28.2)" - - gRPC-Core (1.28.2): - - gRPC-Core/Implementation (= 1.28.2) - - gRPC-Core/Interface (= 1.28.2) - - gRPC-Core/Implementation (1.28.2): - - abseil/container/inlined_vector (= 0.20200225.0) - - abseil/memory/memory (= 0.20200225.0) - - abseil/strings/str_format (= 0.20200225.0) - - abseil/strings/strings (= 0.20200225.0) - - abseil/types/optional (= 0.20200225.0) - - BoringSSL-GRPC (= 0.0.7) - - gRPC-Core/Interface (= 1.28.2) - - gRPC-Core/Interface (1.28.2) - - GTMAppAuth (1.0.0): - - AppAuth/Core (~> 1.0) - - GTMSessionFetcher (~> 1.1) - - GTMSessionFetcher (1.4.0): - - GTMSessionFetcher/Full (= 1.4.0) - - GTMSessionFetcher/Core (1.4.0) - - GTMSessionFetcher/Full (1.4.0): - - GTMSessionFetcher/Core (= 1.4.0) - - leveldb-library (1.22) - - nanopb (1.30905.0): - - nanopb/decode (= 1.30905.0) - - nanopb/encode (= 1.30905.0) - - nanopb/decode (1.30905.0) - - nanopb/encode (1.30905.0) - - path_provider (0.0.1): + - GTMAppAuth (1.3.1): + - AppAuth/Core (~> 1.6) + - GTMSessionFetcher/Core (< 3.0, >= 1.5) + - GTMSessionFetcher/Core (1.7.2) + - nanopb (2.30908.0): + - nanopb/decode (= 2.30908.0) + - nanopb/encode (= 2.30908.0) + - nanopb/decode (2.30908.0) + - nanopb/encode (2.30908.0) + - path_provider_ios (0.0.1): - Flutter - - PromisesObjC (1.2.10) - - Protobuf (3.13.0) - - shared_preferences (0.0.1): + - PromisesObjC (2.1.1) + - shared_preferences_ios (0.0.1): - Flutter - - url_launcher (0.0.1): + - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: @@ -412,44 +137,32 @@ DEPENDENCIES: - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - FirebaseFirestore (from `https://github.com/invertase/firestore-ios-sdk-frameworks.git`, tag `8.11.0`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_native_timezone (from `.symlinks/plugins/flutter_native_timezone/ios`) - google_sign_in (from `.symlinks/plugins/google_sign_in/ios`) - - path_provider (from `.symlinks/plugins/path_provider/ios`) - - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) - - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: - - abseil - AppAuth - - BoringSSL-GRPC - - Crashlytics - - Fabric - Firebase - - FirebaseAnalytics - - FirebaseAnalyticsInterop - FirebaseAuth - - FirebaseAuthInterop - FirebaseCore - FirebaseCoreDiagnostics - - FirebaseCoreDiagnosticsInterop - - FirebaseFirestore + - FirebaseCrashlytics - FirebaseInstallations - - FirebaseInstanceID - FirebaseMessaging - - GoogleAppMeasurement - GoogleDataTransport - GoogleSignIn - GoogleUtilities - - "gRPC-C++" - - gRPC-Core - GTMAppAuth - GTMSessionFetcher - - leveldb-library - nanopb - PromisesObjC - - Protobuf EXTERNAL SOURCES: cloud_firestore: @@ -462,61 +175,59 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_crashlytics/ios" firebase_messaging: :path: ".symlinks/plugins/firebase_messaging/ios" + FirebaseFirestore: + :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git + :tag: 8.11.0 Flutter: :path: Flutter flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_native_timezone: + :path: ".symlinks/plugins/flutter_native_timezone/ios" google_sign_in: :path: ".symlinks/plugins/google_sign_in/ios" - path_provider: - :path: ".symlinks/plugins/path_provider/ios" - shared_preferences: - :path: ".symlinks/plugins/shared_preferences/ios" - url_launcher: - :path: ".symlinks/plugins/url_launcher/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +CHECKOUT OPTIONS: + FirebaseFirestore: + :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git + :tag: 8.11.0 SPEC CHECKSUMS: - abseil: 6c8eb7892aefa08d929b39f9bb108e5367e3228f - AppAuth: 31bcec809a638d7bd2f86ea8a52bd45f6e81e7c7 - BoringSSL-GRPC: 8edf627ee524575e2f8d19d56f068b448eea3879 - cloud_firestore: b054ccc068b5907645c32b669d38e82db2a11fc7 - Crashlytics: 540b7e5f5da5a042647227a5e3ac51d85eed06df - Fabric: 706c8b8098fff96c33c0db69cbf81f9c551d0d74 - Firebase: 7cf5f9c67f03cb3b606d1d6535286e1080e57eb6 - firebase_auth: c42c06a212439824b5da53437da905931e8e6b9a - firebase_core: 3134fe79d257d430f163b558caf52a10a87efe8a - firebase_crashlytics: cc468e94e5a637364aeeeec5c8a96d92ea75ec7b - firebase_messaging: 21344b3b3a7d9d325d63a70e3750c0c798fe1e03 - FirebaseAnalytics: 96634d356482d4f3af8fe459a0ebf19a99c71b75 - FirebaseAnalyticsInterop: 3f86269c38ae41f47afeb43ebf32a001f58fcdae - FirebaseAuth: 7047aec89c0b17ecd924a550c853f0c27ac6015e - FirebaseAuthInterop: a0f37ae05833af156e72028f648d313f7e7592e9 - FirebaseCore: f42e5e5f382cdcf6b617ed737bf6c871a6947b17 - FirebaseCoreDiagnostics: 7535fe695737f8c5b350584292a70b7f8ff0357b - FirebaseCoreDiagnosticsInterop: 296e2c5f5314500a850ad0b83e9e7c10b011a850 - FirebaseFirestore: 8c158bdde010fa397386333a74570eaef033e62d - FirebaseInstallations: 6f5f680e65dc374397a483c32d1799ba822a395b - FirebaseInstanceID: cef67c4967c7cecb56ea65d8acbb4834825c587b - FirebaseMessaging: 29543feb343b09546ab3aa04d008ee8595b43c44 - Flutter: 0e3d915762c693b495b44d77113d4970485de6ec - flutter_local_notifications: 9e4738ce2471c5af910d961a6b7eadcf57c50186 - google_sign_in: 6bd214b9c154f881422f5fe27b66aaa7bbd580cc - GoogleAppMeasurement: 67458367830514fb20fd9e233496f1eef9d90185 - GoogleDataTransport: 672fb0ce96fe7f7f31d43672fca62ad2c9c86f7b + AppAuth: 8fca6b5563a5baef2c04bee27538025e4ceb2add + cloud_firestore: 86f87680a19c805cb0c80b75ecb17ca2d18085f9 + Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c + firebase_auth: ccdb84a0d52a2d7897898ae8121689da6e13d71f + firebase_core: 443bccfd6aa6b42f07be365b500773dc69db2d87 + firebase_crashlytics: 0df152ee2d96f5a4aeddf2288fb277bbd0833b18 + firebase_messaging: 8dc3a6f069774a0bf4dac04dd79fdaffcb62e145 + FirebaseAuth: d96d73aba85d192d7a7aa0b86dd6d7f8ec170b4b + FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0 + FirebaseCoreDiagnostics: 92e07a649aeb66352b319d43bdd2ee3942af84cb + FirebaseCrashlytics: 62268addefae79601057818156e8bc69d71fee41 + FirebaseFirestore: 8a0c6a06219bbaad86c72bcb66688c28995dde84 + FirebaseInstallations: 40bd9054049b2eae9a2c38ef1c3dd213df3605cd + FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + flutter_native_timezone: 5f05b2de06c9776b4cc70e1839f03de178394d22 + google_sign_in: c5cecea71f3be43282263550556e311c4cc03582 + GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f GoogleSignIn: 7137d297ddc022a7e0aa4619c86d72c909fa7213 - GoogleUtilities: 7f2f5a07f888cdb145101d6042bc4422f57e70b3 - "gRPC-C++": 13d8ccef97d5c3c441b7e3c529ef28ebee86fad2 - gRPC-Core: 4afa11bfbedf7cdecd04de535a9e046893404ed5 - GTMAppAuth: 4deac854479704f348309e7b66189e604cf5e01e - GTMSessionFetcher: 6f5c8abbab8a9bce4bb3f057e317728ec6182b10 - leveldb-library: 55d93ee664b4007aac644a782d11da33fba316f7 - nanopb: c43f40fadfe79e8b8db116583945847910cbabc9 - path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c - PromisesObjC: b14b1c6b68e306650688599de8a45e49fae81151 - Protobuf: 3dac39b34a08151c6d949560efe3f86134a3f748 - shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d - url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef + GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 + GTMAppAuth: 0ff230db599948a9ad7470ca667337803b3fc4dd + GTMSessionFetcher: 5595ec75acf5be50814f81e9189490412bad82ba + nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 + path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 + PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de -PODFILE CHECKSUM: b1f7a399522c118a74b177b13c01eca692aa7e6d +PODFILE CHECKSUM: b6de34a429ede15219ca6e3281979fa57ea4a552 -COCOAPODS: 1.9.3 +COCOAPODS: 1.11.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0cc48f51c..149ea3b3d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,13 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ + 03099FE127091E0B007D04AD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 03099FE027091E0B007D04AD /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3834E854234435600065EDE5 /* auto_increment_version.sh in Resources */ = {isa = PBXBuildFile; fileRef = 3834E853234435600065EDE5 /* auto_increment_version.sh */; }; - 386F396523404B21005F0FA5 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 386F396423404B21005F0FA5 /* GoogleService-Info.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 75FC9B5B709BDC5FDF3CB91D /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B705C0822E21CF3F7C623A6D /* libPods-Runner.a */; }; 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; @@ -34,11 +34,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 03099FE027091E0B007D04AD /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 2E5A27593813E785D5B7CCF3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3834E853234435600065EDE5 /* auto_increment_version.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = auto_increment_version.sh; sourceTree = ""; }; - 386F396423404B21005F0FA5 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3B8D73913ECD49AB72E4B626 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -111,7 +111,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( - 386F396423404B21005F0FA5 /* GoogleService-Info.plist */, + 03099FE027091E0B007D04AD /* GoogleService-Info.plist */, 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, 3834E853234435600065EDE5 /* auto_increment_version.sh */, @@ -156,10 +156,8 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 19C30BF24B9B3764322558E0 /* [CP] Embed Pods Frameworks */, B95F8B29E8B1475B6CE0DCBC /* [CP] Copy Pods Resources */, - 3834E8522344353A0065EDE5 /* Auto increment Xcode version number */, - 3864E4252347CC4B00516597 /* Crashlytics (needs to be last) */, + 03E104E1253E7F040083B7F0 /* ShellScript */, ); buildRules = ( ); @@ -176,7 +174,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 0910; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -216,67 +214,29 @@ 3834E854234435600065EDE5 /* auto_increment_version.sh in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - 386F396523404B21005F0FA5 /* GoogleService-Info.plist in Resources */, + 03099FE127091E0B007D04AD /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 19C30BF24B9B3764322558E0 /* [CP] Embed Pods Frameworks */ = { + 03E104E1253E7F040083B7F0 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3834E8522344353A0065EDE5 /* Auto increment Xcode version number */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Auto increment Xcode version number"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh $SRCROOT/auto_increment_version.sh"; - }; - 3864E4252347CC4B00516597 /* Crashlytics (needs to be last) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "", ); inputPaths = ( - "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); - name = "Crashlytics (needs to be last)"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/Fabric/run\n"; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/FirebaseCrashlytics/run\n"; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; @@ -290,7 +250,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; 84A3871B02ECAE1C37386DD1 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -333,12 +293,15 @@ buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", + "${PODS_ROOT}/FirebaseFirestore/FirebaseFirestore/Resources/gRPCCertificates-Cpp.bundle", + "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", ); name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/gRPCCertificates-Cpp.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -419,7 +382,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"${PODS_CONFIGURATION_BUILD_DIR}/BoringSSL-GRPC\"/**", @@ -460,12 +423,12 @@ }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2E5A27593813E785D5B7CCF3 /* Pods-Runner.profile.xcconfig */; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = SE9C56DQWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -474,6 +437,7 @@ "$(PROJECT_DIR)/Pods", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -483,9 +447,14 @@ "$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Pods", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.ramaz.ramaz.student-life"; + MARKETING_VERSION = 1.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.ramaz.coding.ramlife; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -535,7 +504,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -582,7 +551,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"${PODS_CONFIGURATION_BUILD_DIR}/BoringSSL-GRPC\"/**", @@ -623,12 +592,12 @@ }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 85DD3FE87B920992509E31A0 /* Pods-Runner.debug.xcconfig */; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = SE9C56DQWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -637,6 +606,7 @@ "$(PROJECT_DIR)/Pods", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -646,21 +616,26 @@ "$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Pods", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.ramaz.ramaz.student-life"; + MARKETING_VERSION = 1.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.ramaz.coding.ramlife; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3B8D73913ECD49AB72E4B626 /* Pods-Runner.release.xcconfig */; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = SE9C56DQWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -669,6 +644,7 @@ "$(PROJECT_DIR)/Pods", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -678,9 +654,14 @@ "$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Pods", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.ramaz.ramaz.student-life"; + MARKETING_VERSION = 1.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.ramaz.coding.ramlife; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16e..919434a62 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 786d6aad5..c87d15a33 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - - - - + + - - CLIENT_ID - 267381428578-7a8kkf45mdkepv1vr4j39gmlclcgob1u.apps.googleusercontent.com + 267381428578-7dem1o3hm0qsb92lpcst43cmj95t4mv0.apps.googleusercontent.com REVERSED_CLIENT_ID - com.googleusercontent.apps.267381428578-7a8kkf45mdkepv1vr4j39gmlclcgob1u + com.googleusercontent.apps.267381428578-7dem1o3hm0qsb92lpcst43cmj95t4mv0 ANDROID_CLIENT_ID - 267381428578-fjpj5ojak0grnccec1pg29n3quhn437b.apps.googleusercontent.com + 267381428578-02uu4a03um3dfo10oq6akse1f0dlkc84.apps.googleusercontent.com API_KEY AIzaSyB6rEaYZeHcxeW5ZU2i_YQCYmwgwx8f07s GCM_SENDER_ID @@ -15,7 +15,7 @@ PLIST_VERSION 1 BUNDLE_ID - com.ramaz.ramaz.student-life + com.ramaz.coding.ramlife PROJECT_ID ramaz-go STORAGE_BUCKET @@ -31,7 +31,7 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:267381428578:ios:91dc64bf9850b2632c5c3b + 1:267381428578:ios:196e12b81f2bcae22c5c3b DATABASE_URL https://ramaz-go.firebaseio.com diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 22cfc5b66..4eef953b1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion en CFBundleExecutable @@ -11,11 +13,11 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Ram Life + RamLife CFBundlePackageType APPL CFBundleShortVersionString - 1.0.1 + $(MARKETING_VERSION) CFBundleSignature ???? CFBundleURLTypes @@ -25,12 +27,12 @@ Editor CFBundleURLSchemes - com.googleusercontent.apps.267381428578-7a8kkf45mdkepv1vr4j39gmlclcgob1u + com.googleusercontent.apps.267381428578-7dem1o3hm0qsb92lpcst43cmj95t4mv0 CFBundleVersion - 29 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 000000000..20bd9dcc2 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,129 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/constants.dart"; +import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; +import "package:ramaz/services.dart"; +import "package:ramaz/widgets.dart"; + +/// The main app widget. +class RamLife extends StatelessWidget { + /// Determines whether the user is an admin with the given scope. + static bool hasAdminScope(AdminScope scope) => Auth.isSignedIn + && Models.instance.user.isAdmin + && Models.instance.user.adminScopes!.contains(scope); + + /// The routes for this app. + static final Map routes = { + Routes.login: (_) => RouteInitializer( + onError: null, + isAllowed: () => true, + child: const Login(), + ), + Routes.home: (_) => const RouteInitializer( + child: HomePage(pageIndex: 0), + ), + Routes.schedule: (_) => const RouteInitializer( + child: HomePage(pageIndex: 1), + ), + Routes.reminders: (_) => const RouteInitializer( + child: HomePage(pageIndex: 2), + ), + Routes.feedback: (_) => const RouteInitializer( + child: FeedbackPage(), + ), + Routes.calendar: (_) => RouteInitializer( + isAllowed: () => hasAdminScope(AdminScope.calendar), + child: const AdminCalendarPage(), + ), + Routes.schedules: (_) => RouteInitializer( + isAllowed: () => hasAdminScope(AdminScope.calendar), + child: const AdminSchedulesPage(), + ), + Routes.sports: (_) => const RouteInitializer( + child: HomePage(pageIndex: 3), + ), + Routes.credits: (_) => const RouteInitializer( + child: CreditsPage(), + ), + }; + + /// Provides a const constructor. + const RamLife(); + + @override + Widget build (BuildContext context) => ThemeChanger( + defaultBrightness: Brightness.light, + light: ThemeData ( + colorScheme: const ColorScheme.light( + primary: RamazColors.blue, + // primaryVariant: RamazColors.blueDark, + secondary: RamazColors.gold, + // secondaryVariant: RamazColors.goldDark, + brightness: Brightness.light + ), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: RamazColors.blueLight, + selectionHandleColor: RamazColors.blueLight, + ), + buttonTheme: const ButtonThemeData ( + buttonColor: RamazColors.gold, + textTheme: ButtonTextTheme.normal, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(RamazColors.gold) + ) + ) + ), + dark: ThemeData( + colorScheme: const ColorScheme.dark( + primary: RamazColors.blue, + // primaryVariant: RamazColors.blueDark, + secondary: RamazColors.gold, + // secondaryVariant: RamazColors.goldDark, + brightness: Brightness.dark + ), + iconTheme: const IconThemeData (color: RamazColors.goldDark), + primaryIconTheme: const IconThemeData (color: RamazColors.goldDark), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: RamazColors.goldDark, + foregroundColor: RamazColors.blue + ), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: RamazColors.blueLight, + selectionHandleColor: RamazColors.blueLight, + ), + cardTheme: CardTheme ( + color: Colors.grey[820] + ), + toggleableActiveColor: RamazColors.blueLight, + buttonTheme: const ButtonThemeData ( + buttonColor: RamazColors.blueDark, + textTheme: ButtonTextTheme.accent, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(RamazColors.blueDark) + ) + ), + ), + builder: (BuildContext context, ThemeData theme) => MaterialApp ( + initialRoute: Routes.home, + title: "Ram Life", + color: RamazColors.blue, + theme: theme, + onGenerateRoute: (RouteSettings settings) => PageRouteBuilder( + settings: settings, + transitionDuration: Duration.zero, + pageBuilder: (BuildContext context, __, ___) { + final String routeName = + (settings.name == null || !routes.containsKey(settings.name)) + ? Routes.home : settings.name!; + return routes [routeName]! (context); + }, + ) + ) + ); +} diff --git a/lib/constants.dart b/lib/constants.dart index b8f1400ae..c780fb41c 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -30,42 +30,10 @@ class RamazColors { static const Color goldLight = Color (0xfffff552); } -/// Route names for each page in the app. -/// -/// These would be enums, but Flutter requires Strings. -class Routes { - /// The route name for the home page. - static const String home = "home"; - - /// The route name for the schedule page. - static const String schedule = "schedule"; - - /// The route name for the reminders page. - static const String reminders = "reminders"; - - /// The route name for the login page. - static const String login = "login"; - - /// The route name for the feedback page. - static const String feedback = "feedback"; - - /// The route name for the calendar page. - static const String calendar = "calendar"; - - /// The route name for the specials manager page. - static const String specials = "specials"; - - /// The route name for the admin home page. - static const String admin = "admin"; - - /// The route name for the sports games page. - static const String sports = "sports"; -} - /// A collection of URLs used throughout the app class Urls { /// The URL for schoology. - static const String schoology = "https://app.schoology.com"; + static const String schoology = "https://connect.ramaz.org"; /// The URL for Outlook mail. static const String email = "http://mymail.ramaz.org"; @@ -77,10 +45,10 @@ class Urls { static const String googleDrive = "http://drive.google.com"; /// The URL for Outlook mail. - static const String seniorSystems = "https://my.ramaz.org/SeniorApps/facelets/home/home.xhtml"; + static const String seniorSystems = "https://ramaz.seniormbp.com/SeniorApps/facelets/registration/loginCenter.xhtml"; /// The URL for Ramaz livestreaming. - static const String sportsLivestream = "https://ramaz.org/streaming"; + static const String sportsLivestream = "https://www.ramaz.org/streaming"; } /// Some date constants. diff --git a/lib/data.dart b/lib/data.dart index 959c1ccca..d081f0113 100644 --- a/lib/data.dart +++ b/lib/data.dart @@ -8,10 +8,22 @@ /// other app should be implemented in this library. library data; -export "src/data/admin.dart"; +// The clubs feature +export "src/data/clubs/club.dart"; +export "src/data/clubs/message.dart"; + +export "src/data/contributor.dart"; export "src/data/feedback.dart"; export "src/data/reminder.dart"; -export "src/data/schedule.dart"; + +// The schedule feature +export "src/data/schedule/activity.dart"; +export "src/data/schedule/advisory.dart"; +export "src/data/schedule/day.dart"; +export "src/data/schedule/period.dart"; +export "src/data/schedule/schedule.dart"; +export "src/data/schedule/subject.dart"; +export "src/data/schedule/time.dart"; + export "src/data/sports.dart"; -export "src/data/student.dart"; -export "src/data/times.dart"; +export "src/data/user.dart"; diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 000000000..6acafb320 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,71 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: lines_longer_than_80_chars +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + // ignore: missing_enum_constant_in_switch + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + } + + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyBxHI4pDKY-4CsmmALF4GVdwtZOxYAZkHk', + appId: '1:267381428578:web:d7e3fc9294a5423e2c5c3b', + messagingSenderId: '267381428578', + projectId: 'ramaz-go', + authDomain: 'ramaz-go.firebaseapp.com', + databaseURL: 'https://ramaz-go.firebaseio.com', + storageBucket: 'ramaz-go.appspot.com', + measurementId: 'G-STL2M6CLDT', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyAtgjKhWYA9iwNNdPJB8fdYAFGg8rKUhBk', + appId: '1:267381428578:android:8be588f5967f914f', + messagingSenderId: '267381428578', + projectId: 'ramaz-go', + databaseURL: 'https://ramaz-go.firebaseio.com', + storageBucket: 'ramaz-go.appspot.com', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyB6rEaYZeHcxeW5ZU2i_YQCYmwgwx8f07s', + appId: '1:267381428578:ios:196e12b81f2bcae22c5c3b', + messagingSenderId: '267381428578', + projectId: 'ramaz-go', + databaseURL: 'https://ramaz-go.firebaseio.com', + storageBucket: 'ramaz-go.appspot.com', + androidClientId: '267381428578-02uu4a03um3dfo10oq6akse1f0dlkc84.apps.googleusercontent.com', + iosClientId: '267381428578-7dem1o3hm0qsb92lpcst43cmj95t4mv0.apps.googleusercontent.com', + iosBundleId: 'com.ramaz.coding.ramlife', + ); +} diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart deleted file mode 100644 index 052a2cd9c..000000000 --- a/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,26 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore: unused_import -import 'dart:ui'; - -import 'package:cloud_firestore_web/cloud_firestore_web.dart'; -import 'package:firebase_auth_web/firebase_auth_web.dart'; -import 'package:firebase_core_web/firebase_core_web.dart'; -import 'package:google_sign_in_web/google_sign_in_web.dart'; -import 'package:shared_preferences_web/shared_preferences_web.dart'; -import 'package:url_launcher_web/url_launcher_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(PluginRegistry registry) { - FirebaseFirestoreWeb.registerWith(registry.registrarFor(FirebaseFirestoreWeb)); - FirebaseAuthWeb.registerWith(registry.registrarFor(FirebaseAuthWeb)); - FirebaseCoreWeb.registerWith(registry.registrarFor(FirebaseCoreWeb)); - GoogleSignInPlugin.registerWith(registry.registrarFor(GoogleSignInPlugin)); - SharedPreferencesPlugin.registerWith(registry.registrarFor(SharedPreferencesPlugin)); - UrlLauncherPlugin.registerWith(registry.registrarFor(UrlLauncherPlugin)); - registry.registerMessageHandler(); -} diff --git a/lib/main.dart b/lib/main.dart index 18ab08e09..3fb720d05 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,150 +1,10 @@ -import "dart:async" show runZoned; - import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -import "package:ramaz/constants.dart"; // for route keys -import "package:ramaz/models.dart"; -import "package:ramaz/pages.dart"; import "package:ramaz/services.dart"; -import "package:ramaz/widgets.dart" show ThemeChanger; - -Future main({bool restart = false}) async { - // This shows a splash screen but secretly - // determines the desired `platformBrightness` - Brightness brightness; - runZoned( - () => runApp ( - SplashScreen( - setBrightness: - (Brightness platform) => brightness = platform - ) - ), - onError: Crashlytics.recordError, - ); - await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - - // This initializes services -- it is always safe. - await Services.init(); - bool isReady; - try { - isReady = await Services.instance.isReady; - // Checks whther services are ready -- REALLY shouldn't error, but might - if (isReady) { - // This initializes data models -- it may error. - await Models.init(); - } - // We want to at least try again on ANY error. - // ignore: avoid_catches_without_on_clauses - } catch (_) { - debugPrint("Error on main."); - if (!restart) { - debugPrint("Trying again..."); - await Services.instance.reset(); - return main(restart: true); - } else { - rethrow; - } - } - - // Determine the appropriate brightness. - final bool savedBrightness = Services.instance.prefs.brightness; - if (savedBrightness != null) { - brightness = savedBrightness - ? Brightness.light - : Brightness.dark; - } - - // Now we are ready to run the app (with error catching) - FlutterError.onError = Crashlytics.recordFlutterError; - runZoned( - () => runApp ( - RamazApp ( - isReady: isReady, - brightness: brightness, - ) - ), - onError: Crashlytics.recordError, - ); -} - -/// The main app widget. -class RamazApp extends StatelessWidget { - final bool isReady; - - /// The brightness to default to. - final Brightness brightness; - /// Creates the main app widget. - const RamazApp ({ - @required this.brightness, - @required this.isReady, - }); +import "app.dart"; - @override - Widget build (BuildContext context) => ThemeChanger( - defaultBrightness: brightness, - light: ThemeData ( - brightness: Brightness.light, - primarySwatch: Colors.blue, - primaryColor: RamazColors.blue, - primaryColorBrightness: Brightness.dark, - primaryColorLight: RamazColors.blueLight, - primaryColorDark: RamazColors.blueDark, - accentColor: RamazColors.gold, - accentColorBrightness: Brightness.light, - cursorColor: RamazColors.blueLight, - textSelectionHandleColor: RamazColors.blueLight, - buttonColor: RamazColors.gold, - buttonTheme: const ButtonThemeData ( - buttonColor: RamazColors.gold, - textTheme: ButtonTextTheme.normal, - ), - ), - dark: ThemeData( - brightness: Brightness.dark, - scaffoldBackgroundColor: Colors.grey[850], - primarySwatch: Colors.blue, - primaryColorBrightness: Brightness.dark, - primaryColorLight: RamazColors.blueLight, - primaryColorDark: RamazColors.blueDark, - accentColor: RamazColors.goldDark, - accentColorBrightness: Brightness.light, - iconTheme: const IconThemeData (color: RamazColors.goldDark), - primaryIconTheme: const IconThemeData (color: RamazColors.goldDark), - accentIconTheme: const IconThemeData (color: RamazColors.goldDark), - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: RamazColors.goldDark, - foregroundColor: RamazColors.blue - ), - cursorColor: RamazColors.blueLight, - textSelectionHandleColor: RamazColors.blueLight, - cardTheme: CardTheme ( - color: Colors.grey[820] - ), - toggleableActiveColor: RamazColors.blueLight, - buttonColor: RamazColors.blueDark, - buttonTheme: const ButtonThemeData ( - buttonColor: RamazColors.blueDark, - textTheme: ButtonTextTheme.accent, - ), - ), - builder: (BuildContext context, ThemeData theme) => MaterialApp ( - home: isReady ? HomePage() : Login(), - title: "Ram Life", - color: RamazColors.blue, - theme: theme, - routes: { - Routes.login: (_) => Login(), - Routes.home: (_) => HomePage(), - Routes.schedule: (_) => SchedulePage(), - Routes.reminders: (_) => RemindersPage(), - Routes.feedback: (_) => FeedbackPage(), - Routes.calendar: (_) => CalendarPage(), - Routes.specials: (_) => SpecialPage(), - Routes.admin: (_) => AdminHomePage(), - Routes.sports: (_) => SportsPage(), - } - ) - ); +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await FirebaseCore.init(); + runApp(const RamLife()); } diff --git a/lib/models.dart b/lib/models.dart index dbc71ff52..e9164374d 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -25,53 +25,99 @@ library models; import "package:ramaz/services.dart"; -import "src/models/data/admin.dart"; + +import "src/models/data/model.dart"; import "src/models/data/reminders.dart"; import "src/models/data/schedule.dart"; import "src/models/data/sports.dart"; +import "src/models/data/user.dart"; -// data models -export "src/models/data/admin.dart"; +// ------------ data models ------------ export "src/models/data/reminders.dart"; export "src/models/data/schedule.dart"; export "src/models/data/sports.dart"; +export "src/models/data/user.dart"; + +// ------------ view models ------------ +export "src/models/view/admin_schedules.dart"; -// view models +// These models are for creating data objects export "src/models/view/builders/day_builder.dart"; export "src/models/view/builders/reminder_builder.dart"; -export "src/models/view/builders/special_builder.dart"; +export "src/models/view/builders/schedule_builder.dart"; export "src/models/view/builders/sports_builder.dart"; + +export "src/models/view/calendar_editor.dart"; export "src/models/view/feedback.dart"; export "src/models/view/home.dart"; export "src/models/view/schedule.dart"; +export "src/models/view/schedule_search.dart"; export "src/models/view/sports.dart"; -class Models { - static Reminders reminders; +/// Bundles all the data models together. +/// +/// Each data model is responsible for different types of data. For example, +/// [ScheduleModel] keeps track of the schedule (as well as associated data such +/// as the current period) and [UserModel] reads the user data. +/// +/// Each data model inherits from [Model], so it has [init] and [dispose] +/// functions. This model serves to bundles those together, so that calling +/// [init] or [dispose] on this model will call the respective functions +/// on all the data models. +class Models extends Model { + /// The singleton instance of this class. + static Models instance = Models(); + + Reminders? _reminders; + ScheduleModel? _schedule; + Sports? _sports; + UserModel? _user; + + /// The reminders data model. + Reminders get reminders => _reminders ??= Reminders(); + + /// The schedule data model. + ScheduleModel get schedule => _schedule ??= ScheduleModel(); - static Schedule schedule; + /// The sports data model. + Sports get sports => _sports ??= Sports(); - static Sports sports; + /// The user data model. + UserModel get user => _user ??= UserModel(); - static AdminModel admin; + /// Whether the data models have been initialized. + bool isReady = false; - static Future init() async { - reminders = Reminders(); + @override + Future init() async { + if (isReady) { + return; + } + final Crashlytics crashlytics = Services.instance.crashlytics; + await crashlytics.log("Initializing user model"); + await user.init(); + await crashlytics.log("Initializing reminders model"); await reminders.init(); - schedule = Schedule(); + await crashlytics.log("Initializing schedule model"); await schedule.init(); - sports = Sports(); - await sports.init(refresh: true); - if (await Auth.isAdmin) { - admin = AdminModel(); - await admin.init(); - } + await crashlytics.log("Initializing sports model"); + await sports.init(); + isReady = true; } - static void reset() { - reminders = null; - schedule = null; - sports = null; - admin = null; + @override + // This object can be revived using [init]. + // ignore: must_call_super + void dispose() { + _schedule?.dispose(); + _reminders?.dispose(); + _sports?.dispose(); + _user?.dispose(); + // These data models have been disposed and cannot be used again + _reminders = null; + _schedule = null; + _sports = null; + _user = null; + isReady = false; } -} \ No newline at end of file +} diff --git a/lib/pages.dart b/lib/pages.dart index 7bc0b0ee8..65c05068f 100644 --- a/lib/pages.dart +++ b/lib/pages.dart @@ -1,18 +1,51 @@ library pages; +export "src/pages/admin/calendar.dart"; +export "src/pages/admin/schedules.dart"; -export "src/pages/admin.dart"; export "src/pages/builders/day_builder.dart"; export "src/pages/builders/reminder_builder.dart"; -export "src/pages/builders/special_builder.dart"; +export "src/pages/builders/schedule_builder.dart"; export "src/pages/builders/sports_builder.dart"; -export "src/pages/calendar.dart"; + +export "src/pages/credits.dart"; export "src/pages/drawer.dart"; export "src/pages/feedback.dart"; export "src/pages/home.dart"; export "src/pages/login.dart"; export "src/pages/reminders.dart"; +export "src/pages/route_initializer.dart"; export "src/pages/schedule.dart"; -export "src/pages/specials.dart"; -export "src/pages/splash.dart"; export "src/pages/sports.dart"; + +/// Route names for each page in the app. +/// +/// These would be enums, but Flutter requires Strings. +class Routes { + /// The route name for the home page. + static const String home = "home"; + + /// The route name for the schedule page. + static const String schedule = "schedule"; + + /// The route name for the reminders page. + static const String reminders = "reminders"; + + /// The route name for the login page. + static const String login = "login"; + + /// The route name for the feedback page. + static const String feedback = "feedback"; + + /// The route name for the calendar page. + static const String calendar = "calendar"; + + /// The route name for the schedules manager page. + static const String schedules = "schedules"; + + /// The route name for the sports games page. + static const String sports = "sports"; + + /// The route name for the credits page. + static const String credits = "credits"; +} diff --git a/lib/services.dart b/lib/services.dart index fc4275137..c81ffec01 100644 --- a/lib/services.dart +++ b/lib/services.dart @@ -12,166 +12,81 @@ /// For example, retrieving data from a database or file can be abstracted /// to keep the app focused on the content of the data rather than how to /// properly access it. -/// library ramaz_services; -import "package:firebase_core/firebase_core.dart"; -import "package:shared_preferences/shared_preferences.dart"; - -import "src/services/auth.dart"; -import "src/services/cloud_db.dart"; -import "src/services/fcm.dart"; -import "src/services/local_db.dart"; +import "src/services/crashlytics.dart"; +import "src/services/database.dart"; +import "src/services/notifications.dart"; import "src/services/preferences.dart"; +import "src/services/push_notifications.dart"; import "src/services/service.dart"; export "src/services/auth.dart"; -export "src/services/cloud_db.dart"; export "src/services/crashlytics.dart"; +export "src/services/database.dart"; +export "src/services/firebase_core.dart"; export "src/services/notifications.dart"; export "src/services/preferences.dart"; +export "src/services/push_notifications.dart"; +export "src/services/service.dart"; +/// Bundles all the services. +/// +/// A [Service] has an [init] and a [signIn] function. This service serves +/// to bundle them all, so that you only need to call the functions of this +/// service, and they will call all the other services' functions. class Services implements Service { /// The singleton instance of this class. - static Services instance; + static Services instance = Services(); - static Future init() async { - await Firebase.initializeApp(); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - Services.instance = Services(prefs); - } - - final Preferences prefs; + /// The Crashlytics interface. + final Crashlytics crashlytics = Crashlytics.instance; - /// Provides a connection to the online database. - final CloudDatabase cloudDatabase = CloudDatabase(); + /// The database bundle. + final Database database = Database(); - /// The local device storage. + /// The local notifications interface. /// - /// Used to minimize the number of requests to the database and keep the app - /// offline-first. - final LocalDatabase localDatabase; - - /// Creates a wrapper around the services. - Services(SharedPreferences prefs) : - prefs = Preferences(prefs), - localDatabase = LocalDatabase(); - - @override - Future get isReady async { - // This can be shortened but DON'T - // - // The && operator short-circuits -- meaning if cloudDatabase is not ready - // then this getter won't even check localdatabase. But the [isReady] getter - // also doubles as a setup method, so it NEEDS to be called. - // - // Doing it this way ensures that all services will be setup even if a [reset] - // is needed - final bool cloudIsReady = await cloudDatabase.isReady; - final bool localIsReady = await localDatabase.isReady; - return cloudIsReady && localIsReady; - } - - @override - Future reset() async { - await localDatabase.reset(); - await cloudDatabase.reset(); - } + /// Local notifications come from the app and not a server. + final Notifications notifications = Notifications.instance; - @override - Future initialize() async { - await cloudDatabase.initialize(); + /// The push notifications interface. + /// + /// Push notifications come from the server. + final PushNotifications pushNotifications = PushNotifications.instance; - await localDatabase.setUser(await cloudDatabase.user); - await localDatabase.setReminders(await cloudDatabase.reminders); + /// The shared preferences interface. + /// + /// Useful for storing small key-value pairs. + final Preferences prefs = Preferences(); - await updateSports(); - await updateCalendar(); + /// All the services in a list. + /// + /// The functions of this service operate on these services. + late final List services; - if (await Auth.isAdmin) { - await localDatabase.setAdmin(await cloudDatabase.admin); - } + /// Whether the services are ready to use. + bool isReady = false; - // Register for FCM notifications. - // We don't care when this happens - // ignore: unawaited_futures - Future( - () async { - await FCM.registerNotifications( - { - "refresh": initialize, - "updateCalendar": updateCalendar, - "updateSports": updateSports, - } - ); - await FCM.subscribeToTopics(); - } - ); + /// Bundles services together. + /// + /// Also initializes [services]. + Services() { + services = [prefs, database, crashlytics, notifications]; } @override - Future> get user => localDatabase.user; - - @override - Future setUser(_) async {} // user cannot modify data - - @override - Future>> getSections(Set ids) async { - Map> result = - await localDatabase.getSections(ids); - if (result.values.every((value) => value == null)) { - result = await cloudDatabase.getSections(ids); - await localDatabase.setSections(result); + Future init() async { + for (final Service service in services) { + await service.init(); } - return result; + isReady = true; } @override - Future setSections(_) async {} // user cannot modify sections - - @override - Future>>> get calendar => - localDatabase.calendar; - - @override - Future setCalendar(int month, Map json) async { - await cloudDatabase.setCalendar(month, json); - await localDatabase.setCalendar(month, json); - } - - @override - Future>> get reminders => localDatabase.reminders; - - @override - Future setReminders(List> json) async { - await cloudDatabase.setReminders(json); - await localDatabase.setReminders(json); - } - - @override - Future> get admin => localDatabase.admin; - - @override - Future setAdmin(Map json) async { - await cloudDatabase.setAdmin(json); - await localDatabase.setAdmin(json); - } - - @override - Future>> get sports => localDatabase.sports; - - @override - Future setSports(List> json) async { - await cloudDatabase.setSports(json); - await localDatabase.setSports(json); - } - - Future updateCalendar() async { - for (int month = 1; month <= 12; month++) { - await localDatabase.setCalendar(month, await cloudDatabase.getMonth(month)); + Future signIn() async { + for (final Service service in services) { + await service.signIn(); } } - - Future updateSports() async => - localDatabase.setSports(await cloudDatabase.sports); } diff --git a/lib/src/data/admin.dart b/lib/src/data/admin.dart deleted file mode 100644 index 9c327f696..000000000 --- a/lib/src/data/admin.dart +++ /dev/null @@ -1,67 +0,0 @@ -import "package:flutter/foundation.dart"; - -import "times.dart"; - -/// Scopes for administrative privileges. -/// -/// [Admin] users use these scopes ([Admin.scopes]) to determine what they can -/// access and/or modify. -enum Scope { - /// The admin can access and modify the calendar. - calendar, - - /// The admin can access and modify student schedules. - schedule -} - -/// Maps Strings to their respective [Scope]s. -const Map stringToScope = { - "calendar": Scope.calendar, - "schedule": Scope.schedule, -}; - -/// Maps [Scope]s to Strings. -const Map scopeToString = { - Scope.calendar: "calendar", - Scope.schedule: "schedule", -}; - -/// A system administrator. -/// -/// Based on the scopes granted to them, they can -/// access and/or modify data not normally visible/modifiable to all users. -@immutable -class Admin { - /// A list of scopes available to this user. - final List scopes; - - /// A list of custom-made [Special]s by this admin. - /// - /// These can be saved so the admin does not have to recreate them. - final List specials; - - /// Creates a user with administrative privileges. - const Admin ({ - this.scopes, - this.specials - }); - - /// Creates an admin from a JSON entry. - Admin.fromJson(Map json, List _scopes) : - scopes = [ - for (String scope in _scopes) - stringToScope [scope] - ], - specials = [ - for (dynamic special in json ["specials"] ?? []) - Special.fromJson (Map.from(special)) - ]; - - /// Converts an admin to JSON form. - Map toJson() => { - "specials": [ - for (final Special special in specials) - special.toJson(), - ] - }; -} diff --git a/lib/src/data/clubs/club.dart b/lib/src/data/clubs/club.dart new file mode 100644 index 000000000..9624af9e0 --- /dev/null +++ b/lib/src/data/clubs/club.dart @@ -0,0 +1,56 @@ +import "../contact_info.dart"; +import "message.dart"; + +/// An after-school club. +/// +/// Users can "register" for clubs and get notifications on certain events. +/// Captains can send messages and mark certain events as important. +class Club { + /// The name of the club. + final String name; + + /// A short description of the club. + final String shortDescription; + + /// A fullfull description of full description of the club. + final String description; + + /// A URL to an image for this club. + final String image; + + /// A URL to a form needed to register for the club. + final String? formUrl; + + /// Whether a phone number is needed to join the club. + final bool phoneNumberRequested; + + /// A list of members in this club. + final List members; + + /// A list of messages sent by the club. + final List messages; + + /// A list of attendance for each member of the club. + final Map attendance; + + /// The captains of the club. + final List captains; + + /// The faculty advisor for this club. + final ContactInfo facultyAdvisor; + + /// Creates a new club. + Club({ + required this.name, + required this.shortDescription, + required this.description, + required this.phoneNumberRequested, + required this.captains, + required this.facultyAdvisor, + required this.image, + this.formUrl, + }) : + members = [], + attendance = {}, + messages = []; +} diff --git a/lib/src/data/clubs/message.dart b/lib/src/data/clubs/message.dart new file mode 100644 index 000000000..75071ed86 --- /dev/null +++ b/lib/src/data/clubs/message.dart @@ -0,0 +1,22 @@ +import "../contact_info.dart"; + +/// A message in a message board. +/// +/// This is meant for the clubs feature, but can be used anywhere. +class Message { + /// Who sent this message. + ContactInfo sender; + + /// When this message was sent. + DateTime timestamp; + + /// The content of this message. + String body; + + /// Creates a new message. + Message({ + required this.sender, + required this.timestamp, + required this.body, + }); +} diff --git a/lib/src/data/contact_info.dart b/lib/src/data/contact_info.dart new file mode 100644 index 000000000..e29810313 --- /dev/null +++ b/lib/src/data/contact_info.dart @@ -0,0 +1,32 @@ +/// Holds personal information about the user. +/// +/// While [name] and [email] can be read from the authentication service, +/// bundling them together in the user object can be useful too. However, +/// this data is retrieved from the database, which needs the user's email, +/// so the authentication service is still needed for that. +class ContactInfo { + /// The user's name. + final String name; + + /// The user's email. + final String email; + + /// The user's phone number. + /// + /// This is filled in voluntary by the user, and cannot be retrieved from the + /// database. So this field will start off null, and be populated over time. + final String? phoneNumber; + + /// Bundles personal info about the user. + ContactInfo({ + required this.name, + required this.email, + this.phoneNumber, + }); + + /// Creates a contact from JSON. + ContactInfo.fromJson(Map json) : + name = json ["name"], + email = json ["email"], + phoneNumber = json ["phoneNumber"]; +} diff --git a/lib/src/data/contributor.dart b/lib/src/data/contributor.dart new file mode 100644 index 000000000..ab59c5dea --- /dev/null +++ b/lib/src/data/contributor.dart @@ -0,0 +1,99 @@ +/// Someone who worked on the app and will be recognized for it. +class Contributor { + /// The list of all contributors. + /// + /// The most recent contributors go at the top of the list to keep it relveant. + static const List contributors = [ + Contributor( + name: "David T.", + gradYear: "'23", + title: "Frontend", + url: "mailto:davidtbassist@gmail.com", + linkName: "Email me", + imageName: "images/contributors/david-tarrab.jpg", + description: "David has been working on RamLife since sophomore year and " + "mainly works on creating the user interface and page layouts." + ), + Contributor( + name: "Brayden K.", + gradYear: "'23", + title: "Backend", + url: "https://github.com/BraydenKO", + linkName: "See my other projects", + imageName: "images/contributors/brayden-kohler.jpg", + description: "Brayden joined the RamLife team as a sophomore and manages " + "the logic of the app. He also handles the database and other services." + ), + Contributor( + name: "Josh Todes", + gradYear: "'23", + title: "Middleware and Apple Expert", + url: "mailto:todesj@ramaz.org", + linkName: "Email me", + imageName: "images/contributors/josh-todes.jpg", + description: "Josh worked on the iPhone app since he was a Freshman " + "and now handles tying the logic and graphics together seamlessly." + ), + Contributor( + name: "Mr. Vovsha", + gradYear: "", + title: "Faculty Advisor", + url: "mailto:evovsha@ramaz.org", + linkName: "Email me", + imageName: "images/contributors/eli-vovsha.jpg", + description: "Mr. Vovsha led the group since its conception, and has worked" + " tirelessly with the school to help bring RamLife where it is today." + ), + Contributor( + name: "Levi Lesches", + gradYear: "'21", + title: "Creator and Head Programmer", + url: "https://github.com/Levi-Lesches", + linkName: "See my other projects", + imageName: "images/contributors/levi-lesches.jpg", + description: "Levi created RamLife when he was a freshman and expanded it " + "over four years. He was the sole programmer until his senior year" + ), + ]; + + /// The name of the contributor. + final String name; + + /// The graduation year of the contributor. + /// + /// This can be years worked at Ramaz (for faculty). + final String gradYear; + + /// The title of the contributor. + final String title; + + /// A link to a webpage about this contributor. + /// + /// Can also be a mailto URL. + final String url; + + /// A label to show for [url]. + /// + /// Not everyone recognizes github.com or `mailto`, so this should be used + /// to describe what resource the link will take you to. + final String linkName; + + /// How this person contributed to RamLife. + /// + /// Limit this to 150 characters so it looks nice on web and mobile. + final String description; + + /// The path for the contributor's picture. + final String imageName; + + /// A constructor that defines what data a Contributor should have. + const Contributor({ + required this.description, + required this.gradYear, + required this.url, + required this.linkName, + required this.name, + required this.title, + required this.imageName, + }); +} diff --git a/lib/src/data/feedback.dart b/lib/src/data/feedback.dart index 4418229ee..91d24a918 100644 --- a/lib/src/data/feedback.dart +++ b/lib/src/data/feedback.dart @@ -1,16 +1,13 @@ -import "package:flutter/foundation.dart" show immutable, required; - /// Feedback from the user. -@immutable class Feedback { /// The message to the developer. final String message; /// The user's email - final String email; + final String? email; /// The user's name - final String name; + final String? name; /// If the feedback should be anonymized. final bool anonymous; @@ -23,20 +20,17 @@ class Feedback { /// If [anonymous] is true, [email] and [name] must be null. /// This is for privacy reasons. const Feedback({ - @required this.message, - @required this.email, - @required this.name, - @required this.anonymous, - @required this.timestamp + required this.message, + required this.anonymous, + required this.timestamp, + String? email, + String? name, }) : - assert( - !anonymous || (name == null && email == null), - "If the user does not consent to a follow up response, " - "their name and email must not be submitted." - ); + email = anonymous ? null : email, + name = anonymous ? null : name; /// A JSON representation of this feedback. - Map toJson() => { + Map toJson() => { "message": message, "email": email, "name": name, diff --git a/lib/src/data/reminder.dart b/lib/src/data/reminder.dart index fce635d1b..b800747c6 100644 --- a/lib/src/data/reminder.dart +++ b/lib/src/data/reminder.dart @@ -1,215 +1,10 @@ -/// This library handles serialization and deserialization of reminders. -/// -/// Each reminder has a [Reminder.time] property, which is a [ReminderTime], -/// describing when said reminder should be displayed. Since reminders could -/// be shown on a specific class or period, the classes [PeriodReminderTime] -/// and [SubjectReminderTime] are used. -library reminder_dataclasses; +import "package:meta/meta.dart"; -import "dart:convert" show JsonUnsupportedObjectError; -import "package:flutter/foundation.dart" show required, immutable; +import "reminders/reminder_time.dart"; -import "schedule.dart"; - -/// An enum to decide when the reminder should appear. -/// -/// `period` means the reminder needs a [Letters] and a period (as [String]) -/// `subject` means the reminder needs a name of a class. -enum ReminderTimeType { - /// Whether the reminder should be displayed on a specific period. - period, - - /// Whether the reminder should be displayed on a specific subject. - subject -} - -/// Used to convert [ReminderTimeType] to JSON. -const Map reminderTimeToString = { - ReminderTimeType.period: "period", - ReminderTimeType.subject: "subject", -}; - -/// Used to convert JSON to [ReminderTimeType]. -const Map stringToReminderTime = { - "period": ReminderTimeType.period, - "subject": ReminderTimeType.subject, -}; - -/// A time that a reminder should show. -/// -/// Should be used for [Reminder.time]. -@immutable -abstract class ReminderTime { - /// The type of reminder. - /// - /// This field is here for other objects to use. - final ReminderTimeType type; - - /// Whether the reminder should repeat. - final bool repeats; - - /// Allows its subclasses to be `const`. - const ReminderTime({ - @required this.repeats, - @required this.type - }); - - /// Initializes a new instance from JSON. - /// - /// Mainly looks at `json ["type"]` to choose which type of - /// [ReminderTime] it should instantiate, and then leaves the - /// work to that subclass' `.fromJson` method. - /// - /// Example JSON: - /// ``` - /// { - /// "type": "period", - /// "repeats": false, - /// "period": "9", - /// "letter": "M", - /// } - /// ``` - factory ReminderTime.fromJson(Map json) { - switch (stringToReminderTime [json ["type"]]) { - case ReminderTimeType.period: return PeriodReminderTime.fromJson(json); - case ReminderTimeType.subject: return SubjectReminderTime.fromJson(json); - default: throw JsonUnsupportedObjectError( - json, - cause: "Invalid time for reminder: $json" - ); - } - } - - /// Instantiates new [ReminderTime] with all possible parameters. - /// - /// Used for cases where the caller doesn't care about the [ReminderTimeType], - /// such as a UI reminder builder. - factory ReminderTime.fromType({ - @required ReminderTimeType type, - @required Letters letter, - @required String period, - @required String name, - @required bool repeats, - }) { - switch (type) { - case ReminderTimeType.period: return PeriodReminderTime( - period: period, - letter: letter, - repeats: repeats, - ); case ReminderTimeType.subject: return SubjectReminderTime( - name: name, - repeats: repeats, - ); default: throw ArgumentError.notNull("type"); - } - } - - /// Returns this [ReminderTime] as JSON. - Map toJson(); - - /// Checks if the [Reminder] should be displayed. - /// - /// All possible parameters are required. - bool doesApply({ - @required Letters letter, - @required String subject, - @required String period, - }); - - /// Returns a String representation of this [ReminderTime]. - /// - /// Used for debugging and throughout the UI. - @override - String toString(); -} - -/// A [ReminderTime] that depends on a [Letters] and period. -@immutable -class PeriodReminderTime extends ReminderTime { - /// The [Letters] for when this [Reminder] should be displayed. - final Letters letter; - - /// The period when this [Reminder] should be displayed. - final String period; - - /// Returns a new [PeriodReminderTime]. - /// - /// All parameters must be non-null. - const PeriodReminderTime({ - @required this.letter, - @required this.period, - @required bool repeats - }) : super (repeats: repeats, type: ReminderTimeType.period); - - /// Creates a new [ReminderTime] from JSON. - /// - /// `json ["letter"]` should be one of the [Letters]. - /// - /// `json ["period"]` should be a valid period for that letter, - /// notwithstanding any schedule changes (like an "early dismissal"). - PeriodReminderTime.fromJson(Map json) : - letter = stringToLetters [json ["letter"]], - period = json ["period"], - super (repeats: json ["repeats"], type: ReminderTimeType.period); - - @override - String toString() => - "${repeats ? 'Repeats every ' : ''}${lettersToString [letter]}-$period"; - - @override - Map toJson() => { - "letter": lettersToString [letter], - "period": period, - "repeats": repeats, - "type": reminderTimeToString [type], - }; - - /// Returns true if [letter] and [period] match this instance's fields. - @override - bool doesApply({ - @required Letters letter, - @required String subject, - @required String period, - }) => letter == this.letter && period == this.period; -} - -/// A [ReminderTime] that depends on a subject. -@immutable -class SubjectReminderTime extends ReminderTime { - /// The name of the subject this [ReminderTime] depends on. - final String name; - - /// Returns a new [SubjectReminderTime]. All parameters must be non-null. - const SubjectReminderTime({ - @required this.name, - @required bool repeats, - }) : super (repeats: repeats, type: ReminderTimeType.subject); - - /// Returns a new [SubjectReminderTime] from a JSON object. - /// - /// The fields `repeats` and `name` must not be null. - SubjectReminderTime.fromJson(Map json) : - name = json ["name"], - super (repeats: json ["repeats"], type: ReminderTimeType.subject); - - @override - String toString() => (repeats ? "Repeats every " : "") + name; - - @override - Map toJson() => { - "name": name, - "repeats": repeats, - "type": reminderTimeToString [type], - }; - - /// Returns true if this instance's [subject] field - /// matches the `subject` parameter. - @override - bool doesApply({ - @required Letters letter, - @required String subject, - @required String period, - }) => subject == name; -} +export "reminders/period_reminder_time.dart"; +export "reminders/reminder_time.dart"; +export "reminders/subject_reminder_time.dart"; /// A user-generated reminder. @immutable @@ -219,14 +14,14 @@ class Reminder { /// All possible parameters are required. /// This function delegates logic to [ReminderTime.doesApply] static List getReminders({ - @required List reminders, - @required Letters letter, - @required String period, - @required String subject, + required List reminders, + required String? dayName, + required String? period, + required String? subject, }) => [ for (int index = 0; index < reminders.length; index++) if (reminders [index].time.doesApply( - letter: letter, + dayName: dayName, period: period, subject: subject )) index @@ -237,7 +32,7 @@ class Reminder { /// Calls [Reminder.fromJson] for every JSON object in the list. static List fromList(List reminders) => [ for (final dynamic json in reminders) - Reminder.fromJson(Map.from(json)) + Reminder.fromJson(Map.from(json)) ]; /// The message this reminder should show. @@ -246,10 +41,16 @@ class Reminder { /// The [ReminderTime] for this reminder. final ReminderTime time; + /// A unique ID for this reminder. + /// + /// Used to locate this in a database. + final String id; + /// Creates a new reminder. const Reminder({ - @required this.message, - this.time, + required this.message, + required this.time, + required this.id, }); /// Creates a new [Reminder] from a JSON object. @@ -258,13 +59,15 @@ class Reminder { /// to [ReminderTime.fromJson] Reminder.fromJson(dynamic json) : message = json ["message"], - time = ReminderTime.fromJson(Map.from(json ["time"])); + id = json ["id"], + time = ReminderTime.fromJson(Map.from(json ["time"])); @override String toString() => "$message ($time)"; /// Returns a JSON representation of this reminder. - Map toJson() => { + Map toJson() => { "message": message, "time": time.toJson(), + "id": id, }; } diff --git a/lib/src/data/reminders/period_reminder_time.dart b/lib/src/data/reminders/period_reminder_time.dart new file mode 100644 index 000000000..2a05c1015 --- /dev/null +++ b/lib/src/data/reminders/period_reminder_time.dart @@ -0,0 +1,50 @@ +import "reminder_time.dart"; + +/// A [ReminderTime] that depends on a name and period. +class PeriodReminderTime extends ReminderTime { + /// The day for when this reminder should be displayed. + final String dayName; + + /// The period when this reminder should be displayed. + final String period; + + /// Returns a new [PeriodReminderTime]. + /// + /// All parameters must be non-null. + const PeriodReminderTime({ + required this.dayName, + required this.period, + required bool repeats + }) : super (repeats: repeats, type: ReminderTimeType.period); + + /// Creates a new [ReminderTime] from JSON. + /// + /// `json ["dayName"]` should be one of the valid names. + /// + /// `json ["period"]` should be a valid period for that day, + /// notwithstanding any schedule changes (like an "early dismissal"). + PeriodReminderTime.fromJson(Map json) : + dayName = json ["dayName"], + period = json ["period"], + super (repeats: json ["repeats"], type: ReminderTimeType.period); + + @override + String toString() => + "${repeats ? 'Repeats every ' : ''}$dayName-$period"; + + @override + Map toJson() => { + "dayName": dayName, + "period": period, + "repeats": repeats, + "type": reminderTimeToString [type], + }; + + /// Returns true if [dayName] and [period] match this instance's fields. + @override + bool doesApply({ + required String? dayName, + required String? subject, + required String? period, + }) => dayName == this.dayName && period == this.period; +} diff --git a/lib/src/data/reminders/reminder_time.dart b/lib/src/data/reminders/reminder_time.dart new file mode 100644 index 000000000..326101c25 --- /dev/null +++ b/lib/src/data/reminders/reminder_time.dart @@ -0,0 +1,115 @@ +import "dart:convert"; + +import "package:meta/meta.dart"; + +import "period_reminder_time.dart"; +import "subject_reminder_time.dart"; + +/// An enum to decide when the reminder should appear. +/// +/// `period` means the reminder needs a Day name and a period (as [String]) +/// `subject` means the reminder needs a name of a class. +enum ReminderTimeType { + /// Whether the reminder should be displayed on a specific period. + period, + + /// Whether the reminder should be displayed on a specific subject. + subject +} + +/// Used to convert [ReminderTimeType] to JSON. +const Map reminderTimeToString = { + ReminderTimeType.period: "period", + ReminderTimeType.subject: "subject", +}; + +/// Used to convert JSON to [ReminderTimeType]. +const Map stringToReminderTime = { + "period": ReminderTimeType.period, + "subject": ReminderTimeType.subject, +}; + +/// A time that a reminder should show. +@immutable +abstract class ReminderTime { + /// The type of reminder. + /// + /// This field is here for other objects to use. + final ReminderTimeType type; + + /// Whether the reminder should repeat. + final bool repeats; + + /// Allows its subclasses to be `const`. + const ReminderTime({ + required this.repeats, + required this.type + }); + + /// Initializes a new instance from JSON. + /// + /// Mainly looks at `json ["type"]` to choose which type of + /// [ReminderTime] it should instantiate, and then leaves the + /// work to that subclass' `.fromJson` method. + /// + /// Example JSON: + /// ``` + /// { + /// "type": "period", + /// "repeats": false, + /// "period": "9", + /// "name": "Monday", + /// } + /// ``` + factory ReminderTime.fromJson(Map json) { + switch (stringToReminderTime [json ["type"]]) { + case ReminderTimeType.period: return PeriodReminderTime.fromJson(json); + case ReminderTimeType.subject: return SubjectReminderTime.fromJson(json); + default: throw JsonUnsupportedObjectError( + json, + cause: "Invalid time for reminder: $json" + ); + } + } + + /// Instantiates new [ReminderTime] with all possible parameters. + /// + /// Used for cases where the caller doesn't care about the [ReminderTimeType], + /// such as a UI reminder builder. + factory ReminderTime.fromType({ + required ReminderTimeType type, + required String? dayName, + required String? period, + required String? name, + required bool repeats, + }) { + switch (type) { + case ReminderTimeType.period: return PeriodReminderTime( + period: period!, + dayName: dayName!, + repeats: repeats, + ); case ReminderTimeType.subject: return SubjectReminderTime( + name: name!, + repeats: repeats, + ); default: throw ArgumentError.notNull("type"); + } + } + + /// Returns this [ReminderTime] as JSON. + Map toJson(); + + /// Checks if the reminder should be displayed. + /// + /// All possible parameters are required. + bool doesApply({ + required String? dayName, + required String? subject, + required String? period, + }); + + /// Returns a String representation of this [ReminderTime]. + /// + /// Used for debugging and throughout the UI. + @override + String toString(); +} diff --git a/lib/src/data/reminders/subject_reminder_time.dart b/lib/src/data/reminders/subject_reminder_time.dart new file mode 100644 index 000000000..e300b40e5 --- /dev/null +++ b/lib/src/data/reminders/subject_reminder_time.dart @@ -0,0 +1,39 @@ +import "reminder_time.dart"; + +/// A [ReminderTime] that depends on a subject. +class SubjectReminderTime extends ReminderTime { + /// The name of the subject this [ReminderTime] depends on. + final String name; + + /// Returns a new [SubjectReminderTime]. All parameters must be non-null. + const SubjectReminderTime({ + required this.name, + required bool repeats, + }) : super (repeats: repeats, type: ReminderTimeType.subject); + + /// Returns a new [SubjectReminderTime] from a JSON object. + /// + /// The fields `repeats` and `name` must not be null. + SubjectReminderTime.fromJson(Map json) : + name = json ["name"], + super (repeats: json ["repeats"], type: ReminderTimeType.subject); + + @override + String toString() => (repeats ? "Repeats every " : "") + name; + + @override + Map toJson() => { + "name": name, + "repeats": repeats, + "type": reminderTimeToString [type], + }; + + /// Returns true if this instance's [subject] field + /// matches the `subject` parameter. + @override + bool doesApply({ + required String? dayName, + required String? subject, + required String? period, + }) => subject == name; +} diff --git a/lib/src/data/schedule.dart b/lib/src/data/schedule.dart deleted file mode 100644 index a33ca81aa..000000000 --- a/lib/src/data/schedule.dart +++ /dev/null @@ -1,507 +0,0 @@ -/// This library holds data classes for various schedule-related data. -/// -/// Classes here are used to abstract the weird schedule details -/// to make the code a whole lot simpler. -library schedule_dataclasses; - -import "dart:convert" show JsonUnsupportedObjectError; -import "package:flutter/foundation.dart"; - -import "times.dart"; - -/// An enum describing the different letter days. -enum Letters { - /// An M day at Ramaz - /// - /// Happens every Monday - M, - - /// An R day at Ramaz. - /// - /// Happens every Thursday. - R, - - /// A day at Ramaz. - /// - /// Tuesdays and Wednesdays rotate between A, B, and C days. - A, - - /// B day at Ramaz. - /// - /// Tuesdays and Wednesdays rotate between A, B, and C days. - B, - - /// C day at Ramaz. - /// - /// Tuesdays and Wednesdays rotate between A, B, and C days. - C, - - /// E day at Ramaz. - /// - /// Fridays rotate between E and F days. - E, - - /// F day at Ramaz. - /// - /// Fridays rotate between E and F days. - F -} - -/// Maps a [Letters] to a [String] without a function. -const Map lettersToString = { - Letters.A: "A", - Letters.B: "B", - Letters.C: "C", - Letters.M: "M", - Letters.R: "R", - Letters.E: "E", - Letters.F: "F", -}; - -/// Maps a [String] to a [Letters] without a function. -const Map stringToLetters = { - "A": Letters.A, - "B": Letters.B, - "C": Letters.C, - "M": Letters.M, - "R": Letters.R, - "E": Letters.E, - "F": Letters.F, - null: null, -}; - -/// A subject, or class, that a student can take. -/// -/// Since one's schedule contains multiple instances of the same subject, -/// subjects are represented externally by an ID, which is used to look up -/// a canonicalized [Subject] instance. This saves space and simplifies -/// compatibility with existing school databases. -@immutable -class Subject { - /// Returns a map of [Subject]s from a list of JSON objects. - /// - /// The keys are IDs to the subject, and the values are the - /// corresponding [Subject] instances. - /// See [Subject.fromJson] for more details. - static Map getSubjects( - Map> data - ) => data.map ( - (String id, Map json) => MapEntry ( - id, - Subject.fromJson(json) - ) - ); - - /// The name of this subject. - final String name; - - /// The teacher who teaches this subject. - final String teacher; - - /// A const constructor for a [Subject]. - const Subject ({ - @required this.name, - @required this.teacher - }); - - /// Returns a [Subject] instance from a JSON object. - /// - /// The JSON map must have a `teacher` and `name` field. - Subject.fromJson(Map json) : - name = json ["name"], - teacher = json ["teacher"] - { - if (name == null || teacher == null) { - throw JsonUnsupportedObjectError (json.toString()); - } - } - - @override - String toString() => "$name ($teacher)"; - - @override - int get hashCode => "$name-$teacher".hashCode; - - @override - bool operator == (dynamic other) => other is Subject && - other.name == name && - other.teacher == teacher; -} - -/// A representation of a period, independent of the time. -/// -/// This is needed since the time can change on any day. -/// See [Special] for when the times can change. -@immutable -class PeriodData { - /// Returns a list of [PeriodData] from a JSON object. - /// - /// Note that some entries in the list may be null. - /// They represent a free period in the schedule. - /// See [PeriodData.fromJson] for more details. - static List getList(List json) => [ - for (final dynamic periodJson in json) - periodJson == null ? null : - PeriodData.fromJson(Map.from(periodJson)) - ]; - - /// The room the student needs to be in for this period. - final String room; - - /// The id for this period's subject. - /// - /// See the [Subject] class for more details. - final String id; - - /// A const constructor for a [PeriodData]. - /// - /// If both [id] and [room] are null, then it is a free period. - /// Use [PeriodData.free] instead. Otherwise, it is considered an error - /// to have a null [room] OR [id]. - const PeriodData ({ - @required this.room, - @required this.id - }) : - assert ( - room != null || id != null, - "Room and id must both be null or not." - ); - - const PeriodData._free() : - room = null, - id = null; - - /// A free period. - /// - /// Use this instead of manually constructing a [PeriodData] - /// to keep consistency throughout the code. - static const free = PeriodData._free(); - - /// Returns a [PeriodData] from a JSON object. - /// - /// If the JSON object is null, then it is considered a free period. - /// Otherwise, both `json ["room"]` and `json ["id"]` must be non-null. - factory PeriodData.fromJson (Map json) => json == null - ? PeriodData.free - : PeriodData( - room: json ["room"], - id: json ["id"] - ); - - @override - String toString() => "PeriodData ($id, $room)"; - - @override - int get hashCode => "$room-$id".hashCode; - - @override - bool operator == (dynamic other) => other is PeriodData && - other.id == id && - other.room == room; -} - -/// A representation of a period, including the time it takes place. -/// -/// Period objects unpack the [PeriodData] passed to them, -/// so that they alone contain all the information to represent a period. -@immutable -class Period { - /// The time this period takes place. - /// - /// If the time is not known (ie, the schedule is [Special.modified]), - /// then this will be null. - final Range time; - - /// The room this period is in. - /// - /// It may be null, indicating that the student is not expected to be in class. - /// - /// Since the room comes from a [PeriodData] object, both the room and [id] - /// must both be null, or both must be non-null. See [PeriodData()] for more. - final String room; - - /// A String representation of this period. - /// - /// Since a period can be a number (like 9), or a word (like "Homeroom"), - /// String was chosen to represent both. This means that the app does not - /// care whether a period is a regular class or something like homeroom. - final String period; - - /// The id of the [Subject] for this period. - /// - /// It may be null, indicating that the student is not expected - /// to be in a class at this time. - /// - /// Since the id comes from a [PeriodData] object, both the id and [room] - /// must both be null, or both must be non-null. See [PeriodData()] for more. - final String id; - - /// The activity for this period. - /// - /// This is set in [Special.activities]. - final Activity activity; - - /// Unpacks a [PeriodData] object and returns a Period. - Period( - PeriodData data, - {@required this.time, @required this.period, @required this.activity} - ) : - room = data.room, - id = data.id; - - /// Returns a period that represents time for Mincha. - /// - /// Use this constructor to keep a consistent definition of "Mincha". - const Period.mincha(this.time, {this.activity}) : - room = null, - id = null, - period = "Mincha"; - - /// This is only for debug purposes. Use [getName] for UI labels. - @override - String toString() => "Period $period"; - - @override - int get hashCode => "$period-$id".hashCode; - - @override - bool operator == (dynamic other) => other is Period && - other.time == time && - other.room == room && - other.period == period && - other.id == id; - - /// Returns a String representation of this period. - /// - /// The expected subject can be retrieved by looking up the [id]. - /// - /// If [period] is an integer and [id] is null, then it is a free period. - /// Otherwise, if [period] is not a number, than it is returned instead. - /// Finally, the [Subject] that corresponds to [id] will be returned. - /// - /// For example: - /// - /// 1. A period with [PeriodData.free] will return "Free period" - /// 2. A period with `period == "Homeroom"` will return "Homeroom" - /// 3. A period with `period == "3"` will return the name of the [Subject]. - /// - String getName(Subject subject) => int.tryParse(period) != null && id == null - ? "Free period" - : subject?.name ?? period; - - /// Returns a list of descriptions for this period. - /// - /// The expected subject can be retrieved by looking up the [id]. - /// - /// Useful throughout the UI. This function will: - /// - /// 1. Always display the time. - /// 2. If [period] is a number, will display the period. - /// 3. If [room] is not null, will display the room. - /// 4. If [id] is valid, will return the name of the [Subject]. - /// - List getInfo (Subject subject) => [ - if (time != null) "Time: $time", - if (int.tryParse(period) != null) "Period: $period", - if (room != null) "Room: $room", - if (subject != null) "Teacher: ${subject.teacher}", - ]; -} - -/// A day at Ramaz. -/// -/// Each day has a [letter] and [special] property. -/// The [letter] property decides which schedule to show, -/// while the [special] property decides what time slots to give the periods. -@immutable -class Day { - /// The default [Special] for a given [Letters]. - /// - /// See [Special.getWinterFriday] for how to determine the Friday schedule. - static final Map specials = { - Letters.A: Special.rotate, - Letters.B: Special.rotate, - Letters.C: Special.rotate, - Letters.M: Special.regular, - Letters.R: Special.regular, - Letters.E: Special.getWinterFriday(), - Letters.F: Special.getWinterFriday(), - }; - - /// Gets the calendar for the whole year. - /// - /// Each element of [data]'s months should be a JSON representation of a [Day]. - /// See [Day.fromJson] for how to represent a Day in JSON. - static List> getCalendar(List>> data) => [ - for (final List> month in data) - getMonth(month) - ]; - - /// Parses a particular month from JSON. - /// - /// See [Day.getCalendar] for details. - static List getMonth(List> data) => [ - for (final Map json in data) - Day.fromJson(json) - ]; - - /// Converts a month in the calendar to JSON. - /// - /// This is how it is currently stored in the database. - static List> monthToJson(List month) => [ - for (final Day day in month) - day.toJson() - ]; - - /// Gets the Day for [date] in the [calendar]. - static Day getDate(List> calendar, DateTime date) => - calendar [date.month - 1] [date.day - 1]; - - /// The letter of this day. - /// - /// This decides which schedule of the student is shown. - final Letters letter; - - /// The time allotment for this day. - /// - /// See the [Special] class for more details. - final Special special; - - /// Returns a new Day from a [Letters] and [Special]. - /// - /// [special] can be null, in which case [specials] will be used. - Day ({ - @required this.letter, - special - }) : special = special ?? specials [letter]; - - /// Returns a Day from a JSON object. - /// - /// `json ["letter"]` must be one of the specials in [Special.stringToSpecial]. - /// `json ["letter"]` must not be null. - /// - /// `json ["special"]` may be: - /// - /// 1. One of the specials from [specials]. - /// 2. JSON of a special. See [Special.fromJson]. - /// 3. null, in which case [specials] will be used. - /// - /// This factory is not a constructor so it can dynamically check - /// for a valid [letter] while keeping the field final. - factory Day.fromJson(Map json) { - if (!json.containsKey("letter")) { - throw JsonUnsupportedObjectError(json); - } - final String jsonLetter = json ["letter"]; - final jsonSpecial = json ["special"]; - if (!stringToLetters.containsKey (jsonLetter)) { - throw ArgumentError.value( - jsonLetter, // invalid value - "letter", // arg name - "$jsonLetter is not a valid letter", // message - ); - } - final Letters letter = stringToLetters [jsonLetter]; - final Special special = Special.fromJson(jsonSpecial); - return Day (letter: letter, special: special); - } - - @override - String toString() => name; - - @override - int get hashCode => name.hashCode; - - @override - bool operator == (dynamic other) => other is Day && - other.letter == letter && - // other.lunch == lunch && - other.special == special; - - /// Returns a JSON representation of this Day. - /// - /// Will convert [special] to its name if it is a built-in special. - /// Otherwise it will convert it to JSON form. - Map toJson() => { - "letter": lettersToString [letter], - "special": special == null ? null : - Special.stringToSpecial.containsKey(special.name) - ? special.name - : special.toJson() - }; - - /// A human-readable string representation of this day. - /// - /// If the letter is null, returns null. - /// Otherwise, returns [letter] and [special]. - /// If [special] was left as the default, will only return the [letter]. - String get name => letter == null - ? "No School" - : "${lettersToString [letter]} day${ - special == Special.regular || special == Special.rotate - ? '' : ' ${special.name}' - }"; - - /// Whether to say "a" or "an". - /// - /// This method is needed since [letter] is a letter and not a word. - /// So a letter like "R" might need "an" while "B" would need "a". - String get n { - switch (letter) { - case Letters.A: - case Letters.E: - case Letters.M: - case Letters.R: - case Letters.F: - return "n"; - case Letters.B: - case Letters.C: - default: - return ""; - } - } - - /// Whether there is school on this day. - bool get school => letter != null; - - /// Whether the times for this day are known. - bool get isModified => special == Special.modified; - - /// The period right now. - /// - /// Uses [special] to calculate the time slots for all the different periods, - /// and uses [DateTime.now()] to look up what period it is right now. - /// - /// See [Time] and [Range] for implementation details. - int get period { - final Time time = Time.fromDateTime (DateTime.now()); - for (int index = 0; index < (special.periods?.length ?? 0); index++) { - final Range range = special.periods [index]; - if ( - range.contains(time) || // during class - ( // between periods - index != 0 && - special.periods [index - 1] < time && - range > time - ) - ) { - return index; - } - } - // ignore: avoid_returning_null - return null; - } -} - -// class Lunch { -// final String main, soup, side1, side2, salad, dessert; - -// const Lunch ({ -// @required this.main, -// @required this.soup, -// @required this.side1, -// @required this.side2, -// @required this.salad, -// this.dessert = "Seasonal Fresh Fruit" -// }); -// } diff --git a/lib/src/data/schedule/activity.dart b/lib/src/data/schedule/activity.dart new file mode 100644 index 000000000..89a59cdbb --- /dev/null +++ b/lib/src/data/schedule/activity.dart @@ -0,0 +1,151 @@ +import "package:meta/meta.dart"; + +/// An activity for each grade. +@immutable +class GradeActivity { + /// The activity for freshmen. + final Activity? freshmen; + + /// The activity for sophomores. + final Activity? sophomores; + + /// The activity for juniors. + final Activity? juniors; + + /// The activity for seniors. + final Activity? seniors; + + /// Creates a container for activities by grade. + const GradeActivity({ + required this.freshmen, + required this.sophomores, + required this.juniors, + required this.seniors, + }); + + /// Creates a container for activities from a JSON object. + GradeActivity.fromJson(Map json) : + freshmen = Activity.fromJson(Map.from(json ["freshmen"])), + sophomores = Activity.fromJson( + Map.from(json ["sophomores"]) + ), + juniors = Activity.fromJson(Map.from(json ["juniors"])), + seniors = Activity.fromJson(Map.from(json ["seniors"])); + + @override + String toString() => + "Freshmen: ${freshmen.toString()}\n\n" + "Sophomores: ${sophomores.toString()}\n\n" + "Juniors: ${juniors.toString()}\n\n" + "Seniors: ${seniors.toString()}"; +} + +/// A type of activity during the day. +enum ActivityType { + /// When students should go to their advisories. + /// + /// The app will show everyone to their advisories. + advisory, + + /// When students should go to a certain room. + room, + + /// A grade activity. + /// + /// Students will be shown the activities for each grade, and in the future, + /// students can be shown their grade's activity. + grade, + + /// This type of activity should not be parsed by the app. + /// + /// Just shows the message associated with the action. + misc, +} + +/// Maps JSON string values to [ActivityType]s. +ActivityType parseActivityType(String type) { + switch (type) { + case "advisory": return ActivityType.advisory; + case "room": return ActivityType.room; + case "grade": return ActivityType.grade; + case "misc": return ActivityType.misc; + default: throw ArgumentError("Invalid activity type: $type"); + } +} + +/// Maps [ActivityType] values to their string counterparts. +String activityTypeToString(ActivityType type) { + switch (type) { + case ActivityType.advisory: return "advisory"; + case ActivityType.room: return "room"; + case ActivityType.grade: return "grade"; + case ActivityType.misc: return "misc"; + } +} + + +/// An activity during a period. +/// +/// Students can either be directed to their advisories or to a certain room. +/// See [ActivityType] for a description of different activities. +/// +/// Activities can also be nested. +@immutable +class Activity { + /// Parses a JSON map of Activities still in JSON. + static Map getActivities(Map json) { + final Map result = {}; + for (final MapEntry entry in json.entries) { + result [entry.key] = Activity.fromJson( + Map.from(entry.value) + ); + } + return result; + } + + /// The type of this activity. + final ActivityType type; + + /// A message to be displayed with this activity. + /// + /// For example, this can be used to direct students to a certain room based + /// on grade, which is better handled by the user rather than the app. + final String message; + + /// Creates an activity. + const Activity({ + required this.type, + required this.message, + }); + + /// Creates an activity for each grade + Activity.grade(GradeActivity gradeActivty) : + message = gradeActivty.toString(), + type = ActivityType.grade; + + /// Creates an activity from a JSON object. + factory Activity.fromJson(Map json) => json ["message"] is Map + ? Activity.grade( + GradeActivity.fromJson(Map.from(json ["message"])) + ) + : Activity( + type: parseActivityType(json ["type"]), + message: json ["message"] + ); + + /// A JSON representation of this object. + Map toJson() => { + "message": message, + "type": activityTypeToString(type), + }; + + @override + String toString() { + switch (type) { + case ActivityType.misc: return message; + case ActivityType.advisory: return "Advisory -- $message"; + case ActivityType.room: return message; + default: return "Activity"; + } + } +} diff --git a/lib/src/data/schedule/advisory.dart b/lib/src/data/schedule/advisory.dart new file mode 100644 index 000000000..5b78358a2 --- /dev/null +++ b/lib/src/data/schedule/advisory.dart @@ -0,0 +1,27 @@ +import "package:meta/meta.dart"; + +/// Bundles data relevant to advisory. +/// +/// This is not incorporated with the schedule since advisories can happen +/// during homeroom, and are thus dependent on the day, not the user's profile. +@immutable +class Advisory { + /// The section ID of this advisory. + final String id; + + /// The room where this advisory meets. + final String room; + + /// Holds advisory data. + const Advisory({ + required this.id, + required this.room, + }); + + /// Creates an advisory object from JSON. + /// + /// This JSON can be null, so this constructor should only be called if needed. + Advisory.fromJson(Map json) : + id = json ["id"], + room = json ["room"]; +} \ No newline at end of file diff --git a/lib/src/data/schedule/day.dart b/lib/src/data/schedule/day.dart new file mode 100644 index 000000000..65849dd5f --- /dev/null +++ b/lib/src/data/schedule/day.dart @@ -0,0 +1,115 @@ +import "package:meta/meta.dart"; + +import "schedule.dart"; +import "time.dart"; + +/// A day at Ramaz. +/// +/// Each day has a [name] and [schedule] property. +/// The [name] property decides which schedule to show, +/// while the [schedule] property decides what time slots to give the periods. +@immutable +class Day { + /// Gets the calendar for the whole year. + /// + /// Each element of [year]'s months should be a JSON representation of a [Day]. + /// See [Day.fromJson] for how to represent a Day in JSON. + static List> getCalendar(List> year) => [ + for (final List month in year) [ + for (final Map? day in month) + if (day == null) null + else Day.fromJson(day) + ] + ]; + + /// Gets the Day for [date] in the [calendar]. + static Day? getDate(List> calendar, DateTime date) => + calendar [date.month - 1] [date.day - 1]; + + /// The name of this day. + /// + /// This decides which schedule of the student is shown. + final String name; + + /// The time allotment for this day. + /// + /// See the [Schedule] class for more details. + final Schedule schedule; + + /// Returns a new Day from a [name] and [Schedule]. + const Day({ + required this.name, + required this.schedule + }); + + /// Returns a Day from a JSON object. + /// + /// `json ["name"]` and `json ["schedule"]` must not be null. + /// `json ["schedule"]` must be the name of a schedule in the calendar. + factory Day.fromJson(Map json) { + final String scheduleName = json ["schedule"]; + final Schedule? schedule = Schedule.schedules.firstWhere( + (Schedule schedule) => schedule.name == scheduleName + ); + if (schedule == null) { + throw ArgumentError.value( + json ["schedule"], // problematic value + "scheduleName", // description of this value + "Unrecognized schedule name" // error message + ); + } + return Day(name: json ["name"], schedule: schedule); + } + + @override + String toString() => displayName; + + @override + int get hashCode => name.hashCode; + + @override + bool operator == (dynamic other) => other is Day && + other.name == name && + other.schedule == schedule; + + /// Returns a JSON representation of this Day. + Map toJson() => { + "name": name, + "schedule": schedule.name, + }; + + /// A human-readable string representation of this day. + String get displayName => "$name ${schedule.name}"; + + /// Whether to say "a" or "an". + /// + /// Remember, [name] can be a letter and not a word. + /// So a letter like "R" might need "an" while "B" would need "a". + String get n => + {"A", "E", "I", "O", "U"}.contains(name [0]) + || {"A", "M", "R", "E", "F"}.contains(name) ? "n" : ""; + + /// The period right now. + /// + /// Uses [schedule] to calculate the time slots for all the different periods, + /// and uses [DateTime.now()] to look up what period it is right now. Also + /// makes use of [Range] and [Time] comparison operators. + int? getCurrentPeriod() { + final Time time = Time.fromDateTime(DateTime.now()); + for (int index = 0; index < schedule.periods.length; index++) { + final Range range = schedule.periods [index].time; + if (range.contains(time) // during class + || ( // between periods + index != 0 && + schedule.periods [index - 1].time < time && + range > time + ) + ) { + return index; + }else if(time < schedule.periods [0].time.start){ + return 0; + } + } + return null; + } +} diff --git a/lib/src/data/schedule/period.dart b/lib/src/data/schedule/period.dart new file mode 100644 index 000000000..08f6d7d0a --- /dev/null +++ b/lib/src/data/schedule/period.dart @@ -0,0 +1,199 @@ +import "package:meta/meta.dart"; + +import "activity.dart"; +import "schedule.dart"; +import "subject.dart"; +import "time.dart"; + +/// A representation of a period, independent of the time. +/// +/// This is needed since the time can change on any day. +/// See [Schedule] for how to handle different times. +@immutable +class PeriodData { + /// Returns a list of [PeriodData] from a JSON object. + /// + /// Note that some entries in the list may be null. + /// They represent a free period in the schedule. + /// See [PeriodData.fromJson] for more details. + static List getList(List json) => [ + for (final dynamic periodJson in json) + periodJson == null ? null : + PeriodData.fromJson(Map.from(periodJson)) + ]; + + /// The room the student needs to be in for this period. + final String room; + + /// The id for this period's subject. + /// + /// See the [Subject] class for more details. + final String id; + + /// The period's name. + final String name; + + /// The day this period occurs. + final String dayName; + + /// A const constructor for a [PeriodData]. + /// + /// Free periods should be represented by null and not [PeriodData]. + const PeriodData ({ + required this.room, + required this.id, + required this.name, + required this.dayName, + }); + + /// Returns a [PeriodData] from a JSON object. + /// + /// Both `json ["room"]` and `json ["id"]` must be non-null. + factory PeriodData.fromJson(Map json) => PeriodData( + room: json ["room"], + id: json ["id"], + name: json ["name"], + dayName: json ["dayName"], + ); + + @override + String toString() => "PeriodData ($id, $room, $name, $dayName)"; + + @override + int get hashCode => "$room-$id".hashCode; + + @override + bool operator == (dynamic other) => other is PeriodData && + other.id == id && + other.room == room; +} + +/// A representation of a period, including the time it takes place. +/// +/// Period objects unpack the [PeriodData] passed to them, +/// so that they alone contain all the information to represent a period. +@immutable +class Period { + /// The time this period takes place. + final Range time; + + /// A String representation of this period. + /// + /// Since a period can be a number (like 9), or a word (like "Homeroom"), + /// String was chosen to represent both. This means that the app does not + /// care whether a period is a regular class or something like homeroom. + final String name; + + /// The section ID and room for this period. + /// + /// If null, it's a free period. + final PeriodData? data; + + /// The activity for this period. + final Activity? activity; + + /// Unpacks a [PeriodData] object and returns a Period. + const Period({ + required this.data, + required this.time, + required this.name, + this.activity + }); + + /// A Period as represented by the calendar. + /// + /// This period is student-agnostic, so [data] is automatically null. This + /// constructor can be used instead of [fromJson()] to build preset schedules + const Period.raw({ + required this.name, + required this.time, + this.activity, + }) : data = null; + + /// A Period as represented by the calendar. + /// + /// This period is student-agnostic, so [data] is automatically null. + Period.fromJson(Map json) : + time = Range.fromJson(json ["time"]), + name = json ["name"], + data = null, + activity = json ["activity"] == null ? null + : Activity.fromJson(json ["activity"]); + + /// The JSON representation of this period. + Map toJson() => { + "time": time.toJson(), + "name": name, + "activity": activity?.toJson(), + }; + + /// Copies this period with the [PeriodData] of another. + /// + /// This is useful because [Period] objects serve a dual purpose. Their first + /// use is in the calendar, where they simply store data about [Range]s and + /// are used to determine when the periods occur. Then, this information is + /// merged with the user's [PeriodData] objects to create a [Period] that has + /// access to the class the user has at a given time. + Period copyWith(PeriodData? data) => Period( + name: name, + data: data, + time: time, + activity: activity, + ); + + /// This is only for debug purposes. Use [getName] for UI labels. + @override + String toString() => "Period $name"; + + @override + int get hashCode => "$name-${data?.id ?? ''}".hashCode; + + @override + bool operator == (dynamic other) => other is Period && + other.time == time && + other.name == name && + other.data == data; + + /// Whether this is a free period. + bool get isFree => data == null; + + /// The section ID for this period. + /// + /// See [PeriodData.id]. + String? get id => data?.id; + + /// Returns a String representation of this period. + /// + /// The expected subject can be retrieved by looking up `data.id`. + /// + /// If [name] is an integer and [data] is null, then it is a free period. + /// Otherwise, if [name] is not a number, than it is returned instead. + /// Finally, the [Subject] that corresponds to [data] will be returned. + /// + /// For example: + /// + /// 1. A period with null [data] will return "Free period" + /// 2. A period with `name == "Homeroom"` will return "Homeroom" + /// 3. A period with `name == "3"` will return the name of the [Subject]. + String getName(Subject? subject) => int.tryParse(name) != null && isFree + ? "Free period" + : subject?.name ?? name; + + /// Returns a list of descriptions for this period. + /// + /// The expected subject can be retrieved by looking up `data.id`. + /// + /// Useful throughout the UI. This function will: + /// + /// 1. Always display the time. + /// 2. If [name] is a number, will display the period. + /// 3. If `data.room` is not null, will display the room. + /// 4. If `data.id` is valid, will return the name of the [Subject]. + List getInfo (Subject? subject) => [ + "Time: $time", + if (int.tryParse(name) != null) "Period: $name", + if (data != null) "Room: ${data!.room}", + if (subject != null) "Teacher: ${subject.teacher}", + // if (subject?.virtualLink != null) "Zoom link: ${subject!.virtualLink}", + ]; +} diff --git a/lib/src/data/schedule/schedule.dart b/lib/src/data/schedule/schedule.dart new file mode 100644 index 000000000..6cdf2c46a --- /dev/null +++ b/lib/src/data/schedule/schedule.dart @@ -0,0 +1,270 @@ +import "package:meta/meta.dart"; + +import "package:ramaz/constants.dart"; + +import "period.dart"; +import "time.dart"; + +/// A description of the time allotment for a day. +/// +/// Some days require different time periods, or even periods that +/// are skipped altogether, as well as homeroom and Mincha movements. +/// This class helps facilitate that. +@immutable +class Schedule { + /// The list of schedules defined in the calendar. + /// + /// This is a rare exception where the database and data layers intermingle. + /// Schedules are defined by their name, but their values exist elsewhere in + /// the database. So, the data layer needs some lookup method in order to be + /// useful. Specifically, [Day.fromJson()] needs to work _somehow_. + /// + /// `late` means that this value is initialized after startup, but the value + /// cannot be used until it is. This means that we need to be careful not to + /// access this value until we can be sure that the database values were + /// synced. Dart will throw a runtime error otherwise, so it should be fairly + /// simple to catch problems during testing. + static late List schedules; + + /// The name of this schedule. + final String name; + + /// The time allotments for the periods. + final List periods; + + /// A const constructor. + const Schedule({ + required this.name, + required this.periods, + }); + + /// Returns a new [Schedule] from a JSON value. + /// + /// The JSON must have: + /// + /// - a "name" field, which should be a string. See [name]. + /// - a "periods" field, which should be a list of [Period] JSON objects. + Schedule.fromJson(Map json) : + name = json ["name"], // name + periods = [ // list of periods + for (final dynamic jsonElement in json ["periods"]) + Period.fromJson(jsonElement) + ]; + + /// Determines whether to use a Winter Friday or regular Friday schedule. + /// + /// Winter Fridays mean shorter periods, with an ultimately shorter dismissal. + static Schedule getWinterFriday([DateTime? today]) { + final DateTime date = today ?? DateTime.now(); + final int month = date.month, day = date.day; + if (month >= Times.schoolStart && month < Times.winterFridayMonthStart) { + return friday; + } else if ( + month > Times.winterFridayMonthStart || + month < Times.winterFridayMonthEnd + ) { + return winterFriday; + } else if ( + month > Times.winterFridayMonthEnd && + month <= Times.schoolEnd + ) { + return friday; + } else if (month == Times.winterFridayMonthStart) { + return day < Times.winterFridayDayStart ? friday : winterFriday; + } else if (month == Times.winterFridayMonthEnd) { + return day < Times.winterFridayDayEnd ? winterFriday : friday; + } else { + return friday; + } + } + + @override + String toString() => name; + + @override + int get hashCode => name.hashCode; + + @override + bool operator == (dynamic other) => other is Schedule && + other.name == name; + + /// Returns a JSON representation of this schedule. + Map toJson() => { + "name": name, + "periods": [ + for (final Period period in periods) + period.toJson(), + ], + }; + + /// The shorter schedule for the COVID-19 pandemic. + static const Schedule covid = Schedule( + name: "COVID-19", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 30), Time(9, 15))), + Period.raw(name: "Tefilla", time: Range(Time(9, 20), Time(9, 55))), + Period.raw(name: "2", time: Range(Time(10, 00), Time(10, 45))), + Period.raw(name: "3", time: Range(Time(10, 55), Time(11, 40))), + Period.raw(name: "4", time: Range(Time(11, 50), Time(12, 35))), + Period.raw(name: "Lunch", time: Range(Time(12, 35), Time(13, 05))), + Period.raw(name: "5", time: Range(Time(13, 15), Time(14, 00))), + Period.raw(name: "Mincha", time: Range(Time(14, 00), Time(14, 10))), + Period.raw(name: "6", time: Range(Time(14, 20), Time(15, 05))), + Period.raw(name: "7", time: Range(Time(15, 15), Time(16, 00))), + ], + ); + + /// The schedule for Rosh Chodesh. + static const Schedule roshChodesh = Schedule( + name: "Rosh Chodesh", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(9, 05))), + Period.raw(name: "2", time: Range(Time(9, 10), Time(9, 50))), + Period.raw(name: "3", time: Range(Time(9, 55), Time(10, 35))), + Period.raw(name: "Homeroom", time: Range(Time(10, 35), Time(10, 50))), + Period.raw(name: "4", time: Range(Time(10, 50), Time(11, 30))), + Period.raw(name: "5", time: Range(Time(11, 35), Time(12, 15))), + Period.raw(name: "6", time: Range(Time(12, 20), Time(12, 55))), + Period.raw(name: "7", time: Range(Time(13, 00), Time(13, 35))), + Period.raw(name: "8", time: Range(Time(13, 40), Time(14, 15))), + Period.raw(name: "9", time: Range(Time(14, 30), Time(15, 00))), + Period.raw(name: "Mincha", time: Range(Time(15, 00), Time(15, 20))), + Period.raw(name: "10", time: Range(Time(15, 20), Time(16, 00))), + Period.raw(name: "11", time: Range(Time(16, 05), Time(16, 45))), + ], + ); + + /// The schedule for fast days. + static const Schedule fastDay = Schedule( + name: "Tzom", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 55))), + Period.raw(name: "2", time: Range(Time(9, 00), Time(9, 35))), + Period.raw(name: "3", time: Range(Time(9, 40), Time(10, 15))), + Period.raw(name: "4", time: Range(Time(10, 20), Time(10, 55))), + Period.raw(name: "5", time: Range(Time(11, 00), Time(11, 35))), + Period.raw(name: "9", time: Range(Time(11, 40), Time(12, 15))), + Period.raw(name: "10", time: Range(Time(12, 20), Time(12, 55))), + Period.raw(name: "11", time: Range(Time(13, 00), Time(13, 35))), + Period.raw(name: "Mincha", time: Range(Time(13, 35), Time(14, 05))), + ], + ); + + /// The schedule for Fridays. + static const Schedule friday = Schedule( + name: "Friday", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 45))), + Period.raw(name: "2", time: Range(Time(8, 50), Time(9, 30))), + Period.raw(name: "3", time: Range(Time(9, 35), Time(10, 15))), + Period.raw(name: "4", time: Range(Time(10, 20), Time(11, 00))), + Period.raw(name: "Homeroom", time: Range(Time(11, 00), Time(11, 20))), + Period.raw(name: "5", time: Range(Time(11, 20), Time(12, 00))), + Period.raw(name: "6", time: Range(Time(12, 05), Time(12, 45))), + Period.raw(name: "7", time: Range(Time(12, 50), Time(13, 30))), + ], + ); + + /// The schedule for when Rosh Chodesh falls on a Friday. + static const Schedule fridayRoshChodesh = Schedule( + name: "Friday Rosh Chodesh", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(9, 05))), + Period.raw(name: "2", time: Range(Time(9, 10), Time(9, 45))), + Period.raw(name: "3", time: Range(Time(9, 50), Time(10, 25))), + Period.raw(name: "4", time: Range(Time(10, 30), Time(11, 05))), + Period.raw(name: "Homeroom", time: Range(Time(11, 05), Time(11, 25))), + Period.raw(name: "5", time: Range(Time(11, 25), Time(12, 00))), + Period.raw(name: "6", time: Range(Time(12, 05), Time(12, 40))), + Period.raw(name: "7", time: Range(Time(12, 45), Time(13, 20))), + ], + ); + + /// The schedule for a winter Friday. See [Schedule.getWinterFriday]. + static const Schedule winterFriday = Schedule( + name: "Winter Friday", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 45))), + Period.raw(name: "2", time: Range(Time(8, 50), Time(9, 25))), + Period.raw(name: "3", time: Range(Time(9, 30), Time(10, 05))), + Period.raw(name: "4", time: Range(Time(10, 10), Time(10, 45))), + Period.raw(name: "Homeroom", time: Range(Time(10, 45), Time(11, 05))), + Period.raw(name: "5", time: Range(Time(11, 05), Time(11, 40))), + Period.raw(name: "6", time: Range(Time(11, 45), Time(12, 20))), + Period.raw(name: "7", time: Range(Time(12, 25), Time(13, 00))), + ], + ); + + /// The schedule for when a Rosh Chodesh falls on a Winter Friday. + static const Schedule winterFridayRoshChodesh = Schedule( + name: "Winter Friday Rosh Chodesh", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(9, 05))), + Period.raw(name: "2", time: Range(Time(9, 10), Time(9, 40))), + Period.raw(name: "3", time: Range(Time(9, 45), Time(10, 15))), + Period.raw(name: "4", time: Range(Time(10, 20), Time(10, 50))), + Period.raw(name: "Homeroom", time: Range(Time(10, 50), Time(11, 10))), + Period.raw(name: "5", time: Range(Time(11, 10), Time(11, 40))), + Period.raw(name: "6", time: Range(Time(11, 45), Time(12, 15))), + Period.raw(name: "7", time: Range(Time(12, 20), Time(12, 50))), + ], + ); + + /// The schedule for when there is an assembly during Homeroom. + static const Schedule amAssembly = Schedule( + name: "AM Assembly", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 50))), + Period.raw(name: "2", time: Range(Time(8, 55), Time(9, 30))), + Period.raw(name: "3", time: Range(Time(9, 35), Time(10, 10))), + Period.raw(name: "Homeroom", time: Range(Time(10, 10), Time(11, 10))), + Period.raw(name: "4", time: Range(Time(11, 10), Time(11, 45))), + Period.raw(name: "5", time: Range(Time(11, 50), Time(12, 25))), + Period.raw(name: "6", time: Range(Time(12, 30), Time(13, 05))), + Period.raw(name: "7", time: Range(Time(13, 10), Time(13, 45))), + Period.raw(name: "8", time: Range(Time(13, 50), Time(14, 25))), + Period.raw(name: "9", time: Range(Time(14, 30), Time(15, 05))), + Period.raw(name: "Mincha", time: Range(Time(15, 05), Time(15, 25))), + Period.raw(name: "10", time: Range(Time(15, 25), Time(16, 00))), + Period.raw(name: "11", time: Range(Time(16, 05), Time(16, 45))), + ], + ); + /// The schedule for when there is an assembly during Mincha. + static const Schedule pmAssembly = Schedule( + name: "PM Assembly", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 50))), + Period.raw(name: "2", time: Range(Time(8, 55), Time(9, 30))), + Period.raw(name: "3", time: Range(Time(9, 35), Time(10, 10))), + Period.raw(name: "4", time: Range(Time(10, 15), Time(10, 50))), + Period.raw(name: "5", time: Range(Time(10, 55), Time(11, 30))), + Period.raw(name: "6", time: Range(Time(11, 35), Time(12, 10))), + Period.raw(name: "7", time: Range(Time(12, 15), Time(12, 50))), + Period.raw(name: "8", time: Range(Time(12, 55), Time(13, 30))), + Period.raw(name: "9", time: Range(Time(13, 35), Time(14, 10))), + Period.raw(name: "Mincha", time: Range(Time(14, 10), Time(15, 30))), + Period.raw(name: "10", time: Range(Time(15, 30), Time(16, 05))), + Period.raw(name: "11", time: Range(Time(16, 10), Time(16, 45))), + ], + ); + + /// The schedule for an early dismissal. + static const Schedule early = Schedule( + name: "Early Dismissal", + periods: [ + Period.raw(name: "1", time: Range(Time(8, 00), Time(8, 45))), + Period.raw(name: "2", time: Range(Time(8, 50), Time(9, 25))), + Period.raw(name: "3", time: Range(Time(9, 30), Time(10, 05))), + Period.raw(name: "Homeroom", time: Range(Time(10, 05), Time(10, 20))), + Period.raw(name: "4", time: Range(Time(10, 20), Time(10, 55))), + Period.raw(name: "5", time: Range(Time(11, 00), Time(11, 35))), + Period.raw(name: "6", time: Range(Time(11, 40), Time(12, 15))), + Period.raw(name: "7", time: Range(Time(12, 20), Time(12, 55))), + Period.raw(name: "8", time: Range(Time(13, 00), Time(13, 35))), + Period.raw(name: "9", time: Range(Time(13, 40), Time(14, 15))), + Period.raw(name: "Mincha", time: Range(Time(14, 15), Time(14, 35))), + Period.raw(name: "10", time: Range(Time(14, 35), Time(15, 10))), + Period.raw(name: "11", time: Range(Time(15, 15), Time(15, 50))), + ], + ); +} diff --git a/lib/src/data/schedule/subject.dart b/lib/src/data/schedule/subject.dart new file mode 100644 index 000000000..068054135 --- /dev/null +++ b/lib/src/data/schedule/subject.dart @@ -0,0 +1,64 @@ +import "package:flutter/foundation.dart"; + +/// A subject, or class, that a student can take. +/// +/// Since one's schedule contains multiple instances of the same subject, +/// subjects are represented externally by an ID, which is used to look up +/// a canonicalized [Subject] instance. This saves space and simplifies +/// compatibility with existing school databases. +@immutable +class Subject { + /// Returns a map of [Subject]s from a list of JSON objects. + /// + /// The keys are IDs to the subject, and the values are the + /// corresponding [Subject] instances. + /// See [Subject.fromJson] for more details. + static Map getSubjects( + Map data + ) => data.map ( + (String id, Map json) => MapEntry ( + id, + Subject.fromJson(json) + ) + ); + + /// The name of this subject. + final String name; + + /// The teacher who teaches this subject. + final String teacher; + + /// The section-ID for this subject + final String id; + + /// A link to a virtual class, like Zoom. + final String? virtualLink; + + /// A const constructor for a [Subject]. + const Subject ({ + required this.name, + required this.teacher, + required this.id, + this.virtualLink, + }); + + /// Returns a [Subject] instance from a JSON object. + /// + /// The JSON map must have a `teacher` and `name` field. + Subject.fromJson(Map json) : + name = json ["name"], + teacher = json ["teacher"], + id = json ["id"], + virtualLink = json ["virtualLink"]; + + @override + String toString() => "$name ($teacher)"; + + @override + int get hashCode => "$name-$teacher".hashCode; + + @override + bool operator == (dynamic other) => other is Subject && + other.name == name && + other.teacher == teacher; +} diff --git a/lib/src/data/schedule/time.dart b/lib/src/data/schedule/time.dart new file mode 100644 index 000000000..3eb7c2c0d --- /dev/null +++ b/lib/src/data/schedule/time.dart @@ -0,0 +1,124 @@ +import "package:meta/meta.dart"; + +/// The hour and minute representation of a time. +/// +/// This is used instead of [Flutter's TimeOfDay](https://api.flutter.dev/flutter/material/TimeOfDay-class.html) +/// to provide the `>` and `<` operators. +@immutable +class Time { + /// The hour in 24-hour format. + final int hour; + + /// The minutes. + final int minutes; + + /// A const constructor. + const Time(this.hour, this.minutes); + + /// Simplifies a [DateTime] object to a [Time]. + Time.fromDateTime (DateTime date) : + hour = date.hour, + minutes = date.minute; + + /// Returns a new [Time] object from JSON data. + /// + /// The json must have `hour` and `minutes` fields that map to integers. + Time.fromJson(Map json) : + hour = json ["hour"], + minutes = json ["minutes"]; + + /// Returns this obect in JSON form + Map toJson() => { + "hour": hour, + "minutes": minutes, + }; + + @override + int get hashCode => toString().hashCode; + + @override + bool operator == (dynamic other) => other.runtimeType == Time && + other.hour == hour && + other.minutes == minutes; + + /// Returns whether this [Time] is before another [Time]. + bool operator < (Time other) => hour < other.hour || + (hour == other.hour && minutes < other.minutes); + + /// Returns whether this [Time] is at or before another [Time]. + bool operator <= (Time other) => this < other || this == other; + + /// Returns whether this [Time] is after another [Time]. + bool operator > (Time other) => hour > other.hour || + (hour == other.hour && minutes > other.minutes); + + /// Returns whether this [Time] is at or after another [Time]. + bool operator >= (Time other) => this > other || this == other; + + @override + String toString() => + "${hour > 12 ? hour - 12 : hour}:${minutes.toString().padLeft(2, '0')}"; +} + +/// A range of times. +@immutable +class Range { + /// When this range starts. + final Time start; + + /// When this range ends. + final Time end; + + /// Provides a const constructor. + const Range (this.start, this.end); + + /// Convenience method for manually creating a range by hand. + Range.nums ( + int startHour, + int startMinute, + int endHour, + int endMinute + ) : + start = Time (startHour, startMinute), + end = Time (endHour, endMinute); + + /// Returns a new [Range] from JSON data + /// + /// The json must have `start` and `end` fields + /// that map to [Time] JSON objects. + /// See [Time.fromJson] for more details. + Range.fromJson(Map json) : + start = Time.fromJson(Map.from(json ["start"])), + end = Time.fromJson(Map.from(json ["end"])); + + /// Returns a JSON representation of this range. + Map toJson() => { + "start": start.toJson(), + "end": end.toJson(), + }; + + /// Returns whether [other] is in this range. + bool contains (Time other) => start <= other && other <= end; + + @override String toString() => "$start-$end"; + + @override bool operator == (dynamic other) => other is Range && + other.start == start && other.end == end; + + @override int get hashCode => toString().hashCode; + + /// Returns whether this range is before another range. + bool operator < (Time other) => end.hour < other.hour || + ( + end.hour == other.hour && + end.minutes < other.minutes + ); + + /// Returns whether this range is after another range. + bool operator > (Time other) => start.hour > other.hour || + ( + start.hour == other.hour && + start.minutes > other.minutes + ); +} + diff --git a/lib/src/data/sports.dart b/lib/src/data/sports.dart index 868c25962..e5f40defe 100644 --- a/lib/src/data/sports.dart +++ b/lib/src/data/sports.dart @@ -1,7 +1,8 @@ -import "package:flutter/foundation.dart"; +import "package:meta/meta.dart"; -import "package:ramaz/constants.dart" show DayComparison; -import "package:ramaz/data.dart"; +import "package:ramaz/constants.dart"; + +import "schedule/time.dart"; /// All the different sports that can be played. /// @@ -64,7 +65,11 @@ class Scores { final bool isHome; /// Holds the scores for a [SportsGame]. - const Scores(this.ramazScore, this.otherScore, {@required this.isHome}); + const Scores({ + required this.ramazScore, + required this.otherScore, + required this.isHome + }); /// Creates a [Scores] object from a JSON entry. /// @@ -73,7 +78,7 @@ class Scores { /// - an "isHome" field, which should be a bool. See [isHome]. /// - an "otherScore" field, which should be an integer. See [otherScore]. /// - a "ramazScore" field, which should be an integer. See [ramazScore]. - Scores.fromJson(Map json) : + Scores.fromJson(Map json) : isHome = json ["isHome"], ramazScore = json ["ramaz"], otherScore = json ["other"]; @@ -82,7 +87,7 @@ class Scores { /// /// Passing the result of this function to [Scores.fromJson()] should /// return an equivalent object. - Map toJson() => { + Map toJson() => { "isHome": isHome, "ramaz": ramazScore, "other": otherScore, @@ -98,31 +103,31 @@ class Scores { bool get didWin => ramazScore > otherScore; /// Gets the score for either the home team or the away team. - int getScore({bool home}) => home == isHome + int getScore({required bool home}) => home == isHome ? ramazScore : otherScore; } -/// A sports game. -@immutable +/// A sports game. class SportsGame { /// Capitalizes a word. /// /// Useful for the [Sport] enum. static String capitalize(Sport sport) => - sportToString [sport] [0].toUpperCase() + sportToString [sport].substring(1); + sportToString [sport]! [0].toUpperCase() + + sportToString [sport]!.substring(1); /// Converts a list of JSON entries into a list of [SportsGame]s. /// /// This method is needed since it casts each `dynamic` entry to a - /// `Map`, and then passes those values to + /// `Map`, and then passes those values to /// [SportsGame.fromJson]. - static List fromList(List> listJson) => [ - for (final Map json in listJson) + static List fromList(List listJson) => [ + for (final Map json in listJson) SportsGame.fromJson(json) ]; /// Converts a list of [SportsGame]s into a list of JSON entries. - static List> getJsonList(List games) => [ + static List getJsonList(List games) => [ for (final SportsGame game in games) game.toJson() ]; @@ -150,23 +155,27 @@ class SportsGame { /// Whether the game is being played at home or somewhere else. /// /// This affects the UI representation of the game, as well as [Scores.isHome]. - final bool home; + final bool isHome; /// The scores for this game. /// /// The [Scores] dataclass holds helper methods to simplify logic about who - /// won, and which score to get depending on [home]. - final Scores scores; + /// won, and which score to get depending on [isHome]. + final Scores? scores; + + /// The URL to the livestream for this game + late String? livestreamUrl; /// Creates a game dataclass. - const SportsGame({ - @required this.sport, - @required this.date, - @required this.times, - @required this.team, - @required this.opponent, - @required this.home, + SportsGame({ + required this.sport, + required this.date, + required this.times, + required this.team, + required this.opponent, + required this.isHome, this.scores, + this.livestreamUrl, }); /// Converts a JSON entry to a [SportsGame]. @@ -180,16 +189,17 @@ class SportsGame { /// - a "home" field (bool) /// - an "opponent" field (String) /// - a "scores" field. See [Scores.fromJson] for format. - SportsGame.fromJson(Map json) : - sport = stringToSport [json ["sport"]], - date = DateTime.fromMicrosecondsSinceEpoch(json ["date"]), + SportsGame.fromJson(Map json) : + sport = stringToSport [json ["sport"]]!, + date = DateTime.parse(json ["date"]), times = Range.fromJson(json ["times"]), team = json ["team"], - home = json ["home"], + isHome = json ["isHome"], opponent = json ["opponent"], scores = json ["scores"] == null ? null : Scores.fromJson( - Map.from(json ["scores"]) - ); + Map.from(json ["scores"]) + ), + livestreamUrl = json["livestreamUrl"]; // Specifically not including scores, since this can be used // to replace scores. @@ -197,26 +207,27 @@ class SportsGame { bool operator == (dynamic other) => other is SportsGame && other.sport == sport && other.opponent == opponent && - other.home == home && + other.isHome == isHome && other.team == team && other.date.isSameDay(date) && other.times == times; @override - int get hashCode => "$home-$opponent-$sport-$team-$date-$times".hashCode; + int get hashCode => "$isHome-$opponent-$sport-$team-$date-$times".hashCode; /// Converts this game to JSON. /// /// Passing the result of this function to [SportsGame.fromJson()] should /// return an equivalent object. - Map toJson() => { + Map toJson() => { "sport": sportToString [sport], - "date": date, + "date": date.toString(), "times": times.toJson(), "team": team, - "home": home, + "isHome": isHome, "opponent": opponent, "scores": scores?.toJson(), + "livestreamUrl": livestreamUrl, }; /// The end of the match. @@ -233,10 +244,10 @@ class SportsGame { /// Returns a new [SportsGame] with the scores switched out. /// /// This method allows [SportsGame]s to stay immutable. - SportsGame replaceScores(Scores newScores) => SportsGame( + SportsGame replaceScores(Scores? newScores) => SportsGame( sport: sport, team: team, - home: home, + isHome: isHome, date: date, opponent: opponent, times: times, @@ -244,10 +255,10 @@ class SportsGame { ); /// The name of the home team. - String get homeTeam => home ? "Ramaz" : opponent; + String get homeTeam => isHome ? "Ramaz" : opponent; /// The name of the away team. - String get awayTeam => home ? opponent : "Ramaz"; + String get awayTeam => isHome ? opponent : "Ramaz"; /// Specifies which team is away and which team is home. String get description => "$awayTeam @ $homeTeam"; diff --git a/lib/src/data/student.dart b/lib/src/data/student.dart deleted file mode 100644 index 8f27b761e..000000000 --- a/lib/src/data/student.dart +++ /dev/null @@ -1,161 +0,0 @@ -library student_dataclasses; - -import "dart:convert" show JsonUnsupportedObjectError; -import "package:flutter/foundation.dart"; - -import "schedule.dart"; -import "times.dart"; - -/// A representation of a student. -/// -/// This object holds their schedule, and is a convenience class for getting -/// their schedule, as well as some other notable data, such as when and where -/// to meet for homeroom. -@immutable -class Student { - /// This student's schedule. - /// - /// Each key is a different day, and the value is a list of periods. - /// See [Letters] and [PeriodData] for more information. - final Map > schedule; - - /// The section ID for this student's homeroom; - final String homeroomId; - - /// The room location for this student's homeroom; - final String homeroomLocation; - - /// `const` constructor for a student. - const Student ({ - @required this.schedule, - @required this.homeroomId, - @required this.homeroomLocation, - }); - - @override - String toString() => schedule.toString(); - - @override - int get hashCode => schedule.hashCode; - - @override - bool operator == (dynamic other) => other is Student && - other.schedule == schedule; - - /// Creates a student from a JSON object. - /// - /// Needs to be a factory so there can be proper error checking. - factory Student.fromJson (Map json) { - // Fun Fact: ALl this is error checking. - // Your welcome. - - // Check for null homeroom - const String homeroomLocationKey = "homeroom meeting room"; - if (!json.containsKey(homeroomLocationKey)) { - throw JsonUnsupportedObjectError( - json, cause: "No homeroom location present" - ); - } - final String homeroomLocation = json [homeroomLocationKey]; - - const String homeroomKey = "homeroom"; - if (!json.containsKey (homeroomKey)) { - throw JsonUnsupportedObjectError(json, cause: "No homeroom present"); - } - final String homeroomId = json [homeroomKey]; - - // Check for null schedules - const List letters = ["A", "B", "C", "E", "F", "M", "R"]; - for (final String letter in letters) { - if (!json.containsKey (letter)) { - throw JsonUnsupportedObjectError( - json, cause: "Cannot find letter $letter" - ); - } - if (json [letter] == null) { - throw ArgumentError.notNull ("$letter has no schedule"); - } - } - - // Real code starts here - return Student ( - schedule: { - Letters.A: PeriodData.getList (json ["A"]), - Letters.B: PeriodData.getList (json ["B"]), - Letters.C: PeriodData.getList (json ["C"]), - Letters.E: PeriodData.getList (json ["E"]), - Letters.F: PeriodData.getList (json ["F"]), - Letters.M: PeriodData.getList (json ["M"]), - Letters.R: PeriodData.getList (json ["R"]), - }, - homeroomId: homeroomId, - homeroomLocation: homeroomLocation, - ); - } - - /// Returns the schedule for this student on a given day. - /// - /// Iterates over the schedule for [day] in [schedule], and converts the - /// [PeriodData]s to [Period] objects using the [Range]s in [Day.special]. - /// - /// If `day.special` is [Special.modified], every [Period] will have their - /// [Period.time] property set to null. - List getPeriods (Day day) { - if (!day.school) { - return []; - } - - // Get indices for `schedule [day.letter]`, keeping skipped periods in mind - int periodIndex = 0; - final List periodIndices = []; - final Map activities = day.special.activities ?? {}; - final Special special = day.isModified - ? Day.specials [day.letter] - : day.special; - - for (int index = 0; index < special.periods.length; index++) { - while (special?.skip?.contains(periodIndex + 1) ?? false) { - periodIndex++; - } - periodIndices.add( - special.homeroom == index || special.mincha == index - ? null - : periodIndex++ - ); - } - - // Loop over all the periods and assign each one a Period. - return [ - for (int index = 0; index < special.periods.length; index++) - if (special.homeroom == index) - Period( - PeriodData.free, - time: day.isModified ? null : special.periods [index], - period: "Homeroom", - activity: activities ["Homeroom"] - ) - else if (special.mincha == index) - Period.mincha( - day.isModified ? null : special.periods [index], - activity: activities ["Mincha"], - ) - else Period( - schedule [day.letter] [periodIndices [index]] ?? PeriodData.free, - time: day.isModified ? null : special.periods [index], - period: (periodIndices [index] + 1).toString(), - activity: activities [(periodIndices [index] + 1).toString()] - ) - ]; - } - - /// Gets the section ids for this student. - /// - /// This includes every course in every day of the student's schedule, - /// without taking duplicates. - Set getIds() => { - for (final List schedule in schedule.values) - for (final PeriodData period in schedule) - if (period != null && period != PeriodData.free) // skip free periods - period.id - }; -} diff --git a/lib/src/data/times.dart b/lib/src/data/times.dart deleted file mode 100644 index 309df67ac..000000000 --- a/lib/src/data/times.dart +++ /dev/null @@ -1,657 +0,0 @@ -/// A collection of dataclasses to sufficiently represent time for a student. -library time_dataclasses; - -import "package:flutter/foundation.dart"; -import "package:ramaz/constants.dart"; - -/// The hour and minute representation of a time. -/// -/// This is used instead of [Flutter's TimeOfDay](https://api.flutter.dev/flutter/material/TimeOfDay-class.html) -/// to provide the `>` and `<` operators. -@immutable -class Time { - /// The hour in 24-hour format. - final int hour; - - /// The minutes. - final int minutes; - - /// A const constructor. - const Time (this.hour, this.minutes); - - /// Simplifies a [DateTime] object to a [Time]. - Time.fromDateTime (DateTime date) : - hour = date.hour, - minutes = date.minute; - - /// Returns a new [Time] object from JSON data. - /// - /// The json must have `hour` and `minutes` fields that map to integers. - Time.fromJson(Map json) : - hour = json ["hour"], - minutes = json ["minutes"]; - - /// Returns this obect in JSON form - Map toJson() => { - "hour": hour, - "minutes": minutes, - }; - - @override - int get hashCode => toString().hashCode; - - @override - bool operator == (dynamic other) => other.runtimeType == Time && - other.hour == hour && - other.minutes == minutes; - - /// Returns whether this [Time] is before another [Time]. - bool operator < (Time other) => hour < other.hour || - (hour == other.hour && minutes < other.minutes); - - /// Returns whether this [Time] is at or before another [Time]. - bool operator <= (Time other) => this < other || this == other; - - /// Returns whether this [Time] is after another [Time]. - bool operator > (Time other) => hour > other.hour || - (hour == other.hour && minutes > other.minutes); - - /// Returns whether this [Time] is at or after another [Time]. - bool operator >= (Time other) => this > other || this == other; - - @override - String toString() => - "${hour > 12 ? hour - 12 : hour}:${minutes.toString().padLeft(2, '0')}"; -} - -/// A range of times. -@immutable -class Range { - /// When this range starts. - final Time start; - - /// When this range ends. - final Time end; - - /// Provides a const constructor. - const Range (this.start, this.end); - - /// Convenience method for manually creating a range by hand. - Range.nums ( - int startHour, - int startMinute, - int endHour, - int endMinute - ) : - start = Time (startHour, startMinute), - end = Time (endHour, endMinute); - - /// Returns a new [Range] from JSON data - /// - /// The json must have `start` and `end` fields - /// that map to [Time] JSON objects. - /// See [Time.fromJson] for more details. - Range.fromJson(Map json) : - start = Time.fromJson(Map.from(json ["start"])), - end = Time.fromJson(Map.from(json ["end"])); - - /// Returns a JSON representation of this range. - Map toJson() => { - "start": start.toJson(), - "end": end.toJson(), - }; - - /// Returns whether [other] is in this range. - bool contains (Time other) => start <= other && other <= end; - - @override String toString() => "${start ?? ''}-${end ?? ''}"; - - @override bool operator == (dynamic other) => other is Range && - other.start == start && other.end == end; - - @override int get hashCode => toString().hashCode; - - /// Returns whether this range is before another range. - bool operator < (Time other) => end.hour < other.hour || - ( - end.hour == other.hour && - end.minutes < other.minutes - ); - - /// Returns whether this range is after another range. - bool operator > (Time other) => start.hour > other.hour || - ( - start.hour == other.hour && - start.minutes > other.minutes - ); -} - -/// An activity for each grade. -@immutable -class GradeActivity { - /// The activity for freshmen. - final Activity freshmen; - - /// The activity for sophomores. - final Activity sophomores; - - /// The activity for juniors. - final Activity juniors; - - /// The activity for seniors. - final Activity seniors; - - /// Creates a container for activities by grade. - const GradeActivity({ - @required this.freshmen, - @required this.sophomores, - @required this.juniors, - @required this.seniors, - }); - - /// Creates a container for activities from a JSON object. - GradeActivity.fromJson(Map json) : - freshmen = Activity.fromJson(Map.from(json ["freshmen"])), - sophomores = Activity.fromJson( - Map.from(json ["sophomores"]) - ), - juniors = Activity.fromJson(Map.from(json ["juniors"])), - seniors = Activity.fromJson(Map.from(json ["seniors"])); - - @override - String toString() => - "Freshmen: ${freshmen.toString()}\n\n" - "Sophomores: ${sophomores.toString()}\n\n" - "Juniors: ${juniors.toString()}\n\n" - "Seniors: ${seniors.toString()}"; -} - -/// A type of activity during the day. -enum ActivityType { - /// When students should go to their advisories. - /// - /// The app will show everyone to their advisories. - advisory, - - /// When students should go to a certain room. - room, - - /// A grade activity. - /// - /// Students will be shown the activities for each grade, and in the future, - /// students can be shown their grade's activity. - grade, - - /// This type of activity should not be parsed by the app. - /// - /// Just shows the message associated with the action. - misc, -} - -/// An activity during a period. -/// -/// Students can either be directed to their advisories or to a certain room. -/// See [ActivityType] for a description of different activities. -/// -/// Activities can also be nested. -@immutable -class Activity { - /// Parses a JSON map of Activities still in JSON. - static Map getActivities(Map json) { - final Map result = {}; - for (final MapEntry entry in json.entries) { - result [entry.key] = Activity.fromJson( - Map.from(entry.value) - ); - } - return result; - } - - /// Maps JSON string values to [ActivityType]s. - static const Map stringToActivityType = { - "advisory": ActivityType.advisory, - "room": ActivityType.room, - "grade": ActivityType.grade, - "misc": ActivityType.misc, - }; - - /// The type of this activity. - final ActivityType type; - - /// A message to be displayed with this activity. - /// - /// For example, this can be used to direct students to a certain room based - /// on grade, which is better handled by the user rather than the app. - final String message; - - /// Creates an activity. - const Activity({ - @required this.type, - @required this.message, - }) : - assert(type != null, "Type cannot be null"); - - /// Creates an activity for each grade - Activity.grade(GradeActivity gradeActivty) : - message = gradeActivty.toString(), - type = ActivityType.grade; - - /// Creates an activity from a JSON object. - factory Activity.fromJson(Map json) => json ["message"] is Map - ? Activity.grade( - GradeActivity.fromJson(Map.from(json ["message"])) - ) - : Activity( - type: stringToActivityType[json ["type"]], - message: json ["message"] - ); - - @override - String toString() { - switch (type) { - case ActivityType.misc: return message; - case ActivityType.advisory: - return "Advisory${message != null ? ' -- $message' : ''}"; - case ActivityType.room: return message; - default: return "Activity"; - } - } -} - -/// A description of the time allotment for a day. -/// -/// Some days require different time periods, or even periods that -/// are skipped altogether, as well as homeroom and Mincha movements. -/// This class helps facilitate that. -@immutable -class Special { - /// The name of this special. - final String name; - - /// The time allotments for the periods. - final List periods; - - /// The indices of periods to skip. - /// - /// For example, on fast days, all lunch periods are skipped. - /// So here, skip would be `[6, 7, 8]`, to skip 6th, 7th and 8th periods. - final List skip; - - /// The index in [periods] that represents Mincha. - final int mincha; - - /// The index in [periods] that represents homeroom. - final int homeroom; - - /// Maps activities to the periods. - final Map activities; - - /// A const constructor. - const Special ( - this.name, - this.periods, - { - this.homeroom, - this.mincha, - this.skip, - this.activities, - } - ); - - /// Returns a new [Special] from a JSON value. - /// - /// The value must either be: - /// - /// - `null`, in which case, `null` will be returned. - /// - a string, in which case it should be in the [specials] list, or - /// - a map, in which case it will be interpreted as JSON. The JSON must have: - /// - a "name" field, which should be a string. See [name]. - /// - a "periods" field, which should be a list of [Range] JSON objects. - /// - a "homeroom" field, which should be an integer. See [homeroom]. - /// - a "skip" field, which should be a list of integers. See [skip]. - factory Special.fromJson(dynamic value) { - if (value == null) { - return null; - } else if (value is String) { - if (!stringToSpecial.containsKey(value)) { - throw ArgumentError.value( - value, - "Special.fromJson: value", - "'$value' needs to be one of ${stringToSpecial.keys.join(", ")}" - ); - } else { - return stringToSpecial [value]; - } - } else if (value is Map) { - final Map json = Map.from(value); - return Special ( - json ["name"], // name - [ // list of periods - for (final dynamic jsonElement in json ["periods"]) - Range.fromJson(Map.from(jsonElement)) - ], - homeroom: json ["homeroom"], - mincha: json ["mincha"], - skip: List.from(json ["skip"] ?? []), - activities: Activity.getActivities( - Map.from(json ["activities"] ?? {}) - ), - ); - } else { - throw ArgumentError.value ( - value, // invalid value - "Special.fromJson: value", // arg name - "$value is not a valid special", // message - ); - } - } - - /// Determines whether to use a Winter Friday or regular Friday schedule. - /// - /// Winter Fridays mean shorter periods, with an ultimately shorter dismissal. - static Special getWinterFriday([DateTime today]) { - final DateTime date = today ?? DateTime.now(); - final int month = date.month, day = date.day; - if (month >= Times.schoolStart && month < Times.winterFridayMonthStart) { - return friday; - } else if ( - month > Times.winterFridayMonthStart || - month < Times.winterFridayMonthEnd - ) { - return winterFriday; - } else if ( - month > Times.winterFridayMonthEnd && - month <= Times.schoolEnd - ) { - return friday; - } else if (month == Times.winterFridayMonthStart) { - return day < Times.winterFridayDayStart ? friday : winterFriday; - } else if (month == Times.winterFridayMonthEnd) { - return day < Times.winterFridayDayEnd ? winterFriday : friday; - } else { - return friday; - } - } - - /// Compares two lists - /// - /// This function is used to compare the [periods] property of two Specials. - static bool deepEquals(List a, List b) => - (a == null) == (b == null) && - a?.length == b?.length && - [ - for (int index = 0; index < (a?.length ?? 0); index++) - index - ].every( - (int index) => a [index] == b [index] - ); - - @override - String toString() => name; - - @override - int get hashCode => name.hashCode; - - @override - bool operator == (dynamic other) => other is Special && - other.name == name && - deepEquals(other.periods, periods) && - deepEquals(other.skip ?? const [], skip ?? const []) && - other.mincha == mincha && - other.homeroom == homeroom; - - /// Returns a JSON representation of this Special. - Map toJson() => { - "periods": [ - for (final Range period in periods) - period.toJson() - ], - "skip": skip, - "name": name, - "mincha": mincha, - "homeroom": homeroom, - }; - - /// The [Special] for Rosh Chodesh. - static const Special roshChodesh = Special ( - "Rosh Chodesh", - [ - Range(Time(8, 00), Time(9, 05)), - Range(Time(9, 10), Time(9, 50)), - Range(Time(9, 55), Time(10, 35)), - Range(Time(10, 35), Time(10, 50)), - Range(Time(10, 50), Time(11, 30)), - Range(Time(11, 35), Time(12, 15)), - Range(Time(12, 20), Time(12, 55)), - Range(Time(13, 00), Time(13, 35)), - Range(Time(13, 40), Time(14, 15)), - Range(Time(14, 30), Time(15, 00)), - Range(Time(15, 00), Time(15, 20)), - Range(Time(15, 20), Time(16, 00)), - Range(Time(16, 05), Time(16, 45)), - ], - homeroom: 3, - mincha: 10, - ); - - /// The [Special] for fast days. - static const Special fastDay = Special ( - "Tzom", - [ - Range(Time(8, 00), Time(8, 55)), - Range(Time(9, 00), Time(9, 35)), - Range(Time(9, 40), Time(10, 15)), - Range(Time(10, 20), Time(10, 55)), - Range(Time(11, 00), Time(11, 35)), - Range(Time(11, 40), Time(12, 15)), - Range(Time(12, 20), Time(12, 55)), - Range(Time(13, 00), Time(13, 35)), - Range(Time(13, 35), Time(14, 05)), - ], - mincha: 8, - skip: [6, 7, 8] - ); - - /// The [Special] for Fridays. - static const Special friday = Special ( - "Friday", - [ - Range(Time(8, 00), Time(8, 45)), - Range(Time(8, 50), Time(9, 30)), - Range(Time(9, 35), Time(10, 15)), - Range(Time(10, 20), Time(11, 00)), - Range(Time(11, 00), Time(11, 20)), - Range(Time(11, 20), Time(12, 00)), - Range(Time(12, 05), Time(12, 45)), - Range(Time(12, 50), Time(13, 30)), - ], - homeroom: 4 - ); - - /// The [Special] for when Rosh Chodesh falls on a Friday. - static const Special fridayRoshChodesh = Special ( - "Friday Rosh Chodesh", - [ - Range(Time(8, 00), Time(9, 05)), - Range(Time(9, 10), Time(9, 45)), - Range(Time(9, 50), Time(10, 25)), - Range(Time(10, 30), Time(11, 05)), - Range(Time(11, 05), Time(11, 25)), - Range(Time(11, 25), Time(12, 00)), - Range(Time(12, 05), Time(12, 40)), - Range(Time(12, 45), Time(13, 20)), - ], - homeroom: 4 - ); - - /// The [Special] for a winter Friday. See [Special.getWinterFriday]. - static const Special winterFriday = Special ( - "Winter Friday", - [ - Range(Time(8, 00), Time(8, 45)), - Range(Time(8, 50), Time(9, 25)), - Range(Time(9, 30), Time(10, 05)), - Range(Time(10, 10), Time(10, 45)), - Range(Time(10, 45), Time(11, 05)), - Range(Time(11, 05), Time(11, 40)), - Range(Time(11, 45), Time(12, 20)), - Range(Time(12, 25), Time(13, 00)), - ], - homeroom: 4 - ); - - /// The [Special] for when a Rosh Chodesh falls on a Winter Friday. - static const Special winterFridayRoshChodesh = Special ( - "Winter Friday Rosh Chodesh", - [ - Range(Time(8, 00), Time(9, 05)), - Range(Time(9, 10), Time(9, 40)), - Range(Time(9, 45), Time(10, 15)), - Range(Time(10, 20), Time(10, 50)), - Range(Time(10, 50), Time(11, 10)), - Range(Time(11, 10), Time(11, 40)), - Range(Time(11, 45), Time(12, 15)), - Range(Time(12, 20), Time(12, 50)), - ], - homeroom: 4 - ); - - /// The [Special] for when there is an assembly during Homeroom. - static const Special amAssembly = Special ( - "AM Assembly", - [ - Range(Time(8, 00), Time(8, 50)), - Range(Time(8, 55), Time(9, 30)), - Range(Time(9, 35), Time(10, 10)), - Range(Time(10, 10), Time(11, 10)), - Range(Time(11, 10), Time(11, 45)), - Range(Time(11, 50), Time(12, 25)), - Range(Time(12, 30), Time(13, 05)), - Range(Time(13, 10), Time(13, 45)), - Range(Time(13, 50), Time(14, 25)), - Range(Time(14, 30), Time(15, 05)), - Range(Time(15, 05), Time(15, 25)), - Range(Time(15, 25), Time(16, 00)), - Range(Time(16, 05), Time(16, 45)), - ], - homeroom: 3, - - mincha: 10 - ); - - /// The [Special] for when there is an assembly during Mincha. - static const Special pmAssembly = Special ( - "PM Assembly", - [ - Range(Time(8, 00), Time(8, 50)), - Range(Time(8, 55), Time(9, 30)), - Range(Time(9, 35), Time(10, 10)), - Range(Time(10, 15), Time(10, 50)), - Range(Time(10, 55), Time(11, 30)), - Range(Time(11, 35), Time(12, 10)), - Range(Time(12, 15), Time(12, 50)), - Range(Time(12, 55), Time(13, 30)), - Range(Time(13, 35), Time(14, 10)), - Range(Time(14, 10), Time(15, 30)), - Range(Time(15, 30), Time(16, 05)), - Range(Time(16, 10), Time(16, 45)), - ], - mincha: 9 - ); - - /// The [Special] for Mondays and Thursdays. - static const Special regular = Special ( - "M or R day", - [ - Range(Time(8, 00), Time(8, 50)), - Range(Time(8, 55), Time(9, 35)), - Range(Time(9, 40), Time(10, 20)), - Range(Time(10, 20), Time(10, 35)), - Range(Time(10, 35), Time(11, 15)), - Range(Time(11, 20), Time(12, 00)), - Range(Time(12, 05), Time(12, 45)), - Range(Time(12, 50), Time(13, 30)), - Range(Time(13, 35), Time(14, 15)), - Range(Time(14, 20), Time(15, 00)), - Range(Time(15, 00), Time(15, 20)), - Range(Time(15, 20), Time(16, 00)), - Range(Time(16, 05), Time(16, 45)), - ], - homeroom: 3, - mincha: 10 - ); - - /// The [Special] for Tuesday and Wednesday (letters A, B, and C) - static const Special rotate = Special ( - "A, B, or C day", - [ - Range(Time(8, 00), Time(8, 45)), - Range(Time(8, 50), Time(9, 30)), - Range(Time(9, 35), Time(10, 15)), - Range(Time(10, 15), Time(10, 35)), - Range(Time(10, 35), Time(11, 15)), - Range(Time(11, 20), Time(12, 00)), - Range(Time(12, 05), Time(12, 45)), - Range(Time(12, 50), Time(13, 30)), - Range(Time(13, 35), Time(14, 15)), - Range(Time(14, 20), Time(15, 00)), - Range(Time(15, 00), Time(15, 20)), - Range(Time(15, 20), Time(16, 00)), - Range(Time(16, 05), Time(16, 45)), - ], - homeroom: 3, - mincha: 10 - ); - - /// The [Special] for an early dismissal. - static const Special early = Special ( - "Early Dismissal", - [ - Range(Time(8, 00), Time(8, 45)), - Range(Time(8, 50), Time(9, 25)), - Range(Time(9, 30), Time(10, 05)), - Range(Time(10, 05), Time(10, 20)), - Range(Time(10, 20), Time(10, 55)), - Range(Time(11, 00), Time(11, 35)), - Range(Time(11, 40), Time(12, 15)), - Range(Time(12, 20), Time(12, 55)), - Range(Time(13, 00), Time(13, 35)), - Range(Time(13, 40), Time(14, 15)), - Range(Time(14, 15), Time(14, 35)), - Range(Time(14, 35), Time(15, 10)), - Range(Time(15, 15), Time(15, 50)), - ], - homeroom: 3, - mincha: 10 - ); - - /// A day where the schedule is not known. - static const Special modified = Special ( - "Modified", - null, - ); - - /// A collection of all the [Special]s - /// - /// Used in the UI - static const List specials = [ - regular, - roshChodesh, - fastDay, - friday, - fridayRoshChodesh, - winterFriday, - winterFridayRoshChodesh, - amAssembly, - pmAssembly, - rotate, - early, - modified, - ]; - - /// Maps the default special names to their [Special] objects - static final Map stringToSpecial = Map.fromIterable( - specials, - key: (special) => special.name, - ); -} diff --git a/lib/src/data/user.dart b/lib/src/data/user.dart new file mode 100644 index 000000000..a8b6c4fd9 --- /dev/null +++ b/lib/src/data/user.dart @@ -0,0 +1,166 @@ +import "package:meta/meta.dart"; + +import "contact_info.dart"; +import "schedule/advisory.dart"; +import "schedule/day.dart"; +import "schedule/period.dart"; +import "schedule/time.dart"; + +/// Scopes for administrative privileges. +/// +/// Admin users use these scopes to determine what they can read/write. +enum AdminScope { + /// The admin can access and modify the calendar. + calendar, + + /// The admin can access and modify student schedules. + schedule, + + /// The admin can create and update sports games. + sports, +} + +/// Maps Strings to [AdminScope]s. +AdminScope parseAdminScope(String scope) { + switch (scope) { + case "calendar": return AdminScope.calendar; + case "schedule": return AdminScope.schedule; + case "sports": return AdminScope.sports; + default: throw ArgumentError("Invalid admin scope: $scope"); + } +} + +/// Maps [AdminScope]s to Strings. +String adminScopeToString(AdminScope scope) { + switch (scope) { + case AdminScope.calendar: return "calendar"; + case AdminScope.schedule: return "schedule"; + case AdminScope.sports: return "sports"; + } +} + +/// What grade the user is in. +/// +/// The [User.grade] field could be an `int`, but by specifying the exact +/// possible values, we avoid any possible errors, as well as possibly cleaner +/// code. +/// +/// Faculty users can have [User.grade] be null. +enum Grade { + /// A Freshman. + freshman, + + /// A Sophomore. + sophomore, + + /// A Junior. + junior, + + /// A Senior. + senior +} + +/// Maps grade numbers to a [Grade] type. +Map intToGrade = { + 9: Grade.freshman, + 10: Grade.sophomore, + 11: Grade.junior, + 12: Grade.senior, +}; + +/// Represents a user and all their data. +/// +/// This objects includes data like the user's schedule, grade, list of clubs, +/// and more. +@immutable +class User { + /// The user's schedule. + /// + /// Each key is a different day, and the values are list of periods in that + /// day. Possible key values are defined by [dayNames]. + /// + /// Periods may be null to indicate free periods (or, in the case of faculty, + /// periods where they don't teach). + final Map> schedule; + + /// The advisory for this user. + final Advisory? advisory; + + /// This user's contact information. + final ContactInfo contactInfo; + + /// The grade this user is in. + /// + /// This property is null for faculty. + final Grade? grade; + + /// The IDs of the clubs this user attends. + /// + /// TODO: decide if this is relevant for captains. + final List registeredClubs; + + /// The possible day names for this user's schedule. + /// + /// These will be used as the keys for [schedule]. + final Iterable dayNames; + + /// Creates a new user. + const User({ + required this.schedule, + required this.contactInfo, + required this.registeredClubs, + required this.dayNames, + this.grade, + this.advisory, + }); + + /// Gets a value from JSON, throwing if null. + /// + /// This function is needed since null checks don't run on dynamic values. + static dynamic safeJson(Map json, String key) { + final dynamic value = json [key]; + if (value == null) { + throw ArgumentError.notNull(key); + } else { + return value; + } + } + + /// Creates a new user from JSON. + User.fromJson(Map json) : + dayNames = List.from(safeJson(json, "dayNames")), + schedule = { + for (final String dayName in safeJson(json, "dayNames")) + dayName: PeriodData.getList(json [dayName]) + }, + advisory = json ["advisory"] == null ? null : Advisory.fromJson( + Map.from(safeJson(json, "advisory")) + ), + contactInfo = ContactInfo.fromJson( + Map.from(safeJson(json, "contactInfo")) + ), + grade = json ["grade"] == null ? null : intToGrade [safeJson(json, "grade")], + registeredClubs = List.from(json ["registeredClubs"] ?? []); + + /// Gets the unique section IDs for the courses this user is enrolled in. + /// + /// For teachers, these will be the courses they teach. + Set get sectionIDs => { + for (final List daySchedule in schedule.values) + for (final PeriodData? period in daySchedule) + if (period != null) + period.id + }; + + /// Computes the periods, in order, for a given day. + /// + /// This method converts the [PeriodData]s in [schedule] into [Period]s using + /// [Day.schedule]. [PeriodData] objects are specific to the user's schedule, + /// whereas the times of the day [Range]s are specific to the calendar. + List getPeriods(Day day) => [ + for (final Period period in day.schedule.periods) period.copyWith( + int.tryParse(period.name) == null ? null + : schedule [day.name]! [int.parse(period.name) - 1] + ) + ]; +} diff --git a/lib/src/models/data/admin.dart b/lib/src/models/data/admin.dart deleted file mode 100644 index 6035bd44c..000000000 --- a/lib/src/models/data/admin.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:ramaz/services.dart"; - -import "admin/calendar.dart"; -import "admin/user.dart"; - -export "admin/calendar.dart"; -export "admin/user.dart"; - -/// A data model that manages all admin responsibilities. -class AdminModel { - CalendarModel _calendar; - - /// The admin user this model is managing. - /// - /// This is an [AdminUserModel], not just the user itself. - AdminUserModel user; - - Future init() async { - user = AdminUserModel( - json: await Services.instance.admin, - scopes: await Auth.adminScopes - ); - } - - /// The data model for modifying the calendar. - CalendarModel get calendar => _calendar ??= CalendarModel(); -} \ No newline at end of file diff --git a/lib/src/models/data/admin/calendar.dart b/lib/src/models/data/admin/calendar.dart deleted file mode 100644 index f00fc27a0..000000000 --- a/lib/src/models/data/admin/calendar.dart +++ /dev/null @@ -1,110 +0,0 @@ -import "dart:async"; - -import "package:flutter/foundation.dart" show ChangeNotifier; - -import "package:ramaz/data.dart"; -import "package:ramaz/services.dart"; - -/// A data model to manage the calendar. -/// -/// This model listens to the calendar and can modify it in the database. -// ignore: prefer_mixin -class CalendarModel with ChangeNotifier { - /// How many days there are in every month. - static const int daysInMonth = 7 * 5; - - /// The current date. - static final DateTime now = DateTime.now(); - - /// The current year. - static final int currentYear = now.year; - - /// The current month. - static final int currentMonth = now.month; - - // The raw JSON-filled calendar. - // final List>> data = List.filled(12, null); - - /// The calendar filled with [Day]s. - final List> calendar = List.filled(12, null); - - /// A list of callbacks on the Firebase streams. - /// - /// This list is needed so that the model can cancel the listeners - /// when the user leaves the page. - final List subscriptions = []; - - /// The year of each month. - /// - /// Because the school year goes from September to June, determining the year - /// of any given month is not trivial. The year is computed from the current - /// month and the month in question. - final List years = [ - for (int month = 0; month < 12; month++) - currentMonth > 7 - ? month > 7 ? currentYear : currentYear + 1 - : month > 7 ? currentYear - 1 : currentYear - ]; - - /// A list of calendar paddings for each month. - /// - /// Every entry is a list of two numbers. The first one is the amount of days - /// from Sunday before the month starts, and the second one is the amount of - /// days after the month until Saturday. They will be represented by blanks. - final List> paddings = List.filled(12, null); - - /// Creates a data model to hold the calendar. - /// - /// Initializing a [CalendarModel] automatically listens to the calendar in - /// Firebase. See [CloudDatabase.getCalendarStream] for details. - CalendarModel() { - for (int month = 0; month < 12; month++) { - subscriptions.add( - CloudDatabase.getCalendarStream(month + 1).listen( - (List> cal) { - calendar [month] = Day.getMonth(cal); - calendar [month] = layoutMonth(month); - notifyListeners(); - } - ) - ); - } - } - - @override - void dispose() { - for (final StreamSubscription subscription in subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - /// Fits the calendar to a 5-day week layout. - /// - /// Adjusts the calendar so that it begins on the correct day of the week - /// (starting on Sunday) instead of defaulting to the first open cell on - /// the calendar grid. This function pads the calendar with the correct - /// amount of empty days before and after the month. - List layoutMonth(int month) { - final List cal = calendar [month]; - final int firstDayOfWeek = DateTime(years [month], month + 1, 1).weekday; - final int weekday = firstDayOfWeek == 7 ? 0 : firstDayOfWeek; - paddings [month] = [weekday, daysInMonth - (weekday + cal.length)]; - return cal; - } - - /// Updates the calendar. - Future updateDay(DateTime date, Day day) async { - if (day == null) { - return; - } - calendar [date.month - 1] [date.day - 1] = day; - await Services.instance.setCalendar( - date.month, - { - "calendar": Day.monthToJson(calendar [date.month - 1]), - "month": date.month - } - ); - } -} diff --git a/lib/src/models/data/admin/user.dart b/lib/src/models/data/admin/user.dart deleted file mode 100644 index 7ec96144c..000000000 --- a/lib/src/models/data/admin/user.dart +++ /dev/null @@ -1,53 +0,0 @@ -import "package:flutter/foundation.dart"; - -import "package:ramaz/data.dart"; -import "package:ramaz/services.dart"; - -/// A data model to manage an [Admin] user. -/// -/// While the [Admin] class handles admin data, this class handles syncing -/// the admin's data to the database. -// ignore: prefer_mixin -class AdminUserModel with ChangeNotifier { - /// The admin being managed by this model. - final Admin admin; - - /// Creates a data model to manage admin data. - AdminUserModel({ - @required Map json, - @required List scopes - }) : admin = Admin.fromJson(json, scopes); - - /// The list of this admin's custom [Special]s. - List get specials => admin.specials; - - /// Saves the admin's data both to the device and the cloud. - Future save() async { - await Services.instance.setAdmin(admin.toJson()); - notifyListeners(); - } - - /// Adds a [Special] to the admin's list of custom specials. - void addSpecial(Special special) { - if (special == null) { - return; - } - admin.specials.add(special); - save(); - } - - /// Replaces a [Special] in the admin's list of custom specials. - void replaceSpecial(int index, Special special) { - if (special == null) { - return; - } - admin.specials [index] = special; - save(); - } - - /// Removes a [Special] in the admin's list of custom specials. - void removeSpecial(int index) { - admin.specials.removeAt(index); - save(); - } -} diff --git a/lib/src/models/data/model.dart b/lib/src/models/data/model.dart new file mode 100644 index 000000000..efb1854f1 --- /dev/null +++ b/lib/src/models/data/model.dart @@ -0,0 +1,24 @@ +import "package:flutter/foundation.dart"; + +/// A data model to provide data to the app. +/// +/// A data model combines services with dataclasses to provide functionality. +/// Whereas dataclasses don't care where they get their data from, and services +/// don't care what data they get, a data model uses dataclasses to structure +/// the data retrieved by a service. +/// +/// A data model should have functions and properties that the app as a whole +/// may need. Any part of the UI may depend on any part of a data model. To +/// update that data, call [notifyListeners], and every UI element that depends +/// on it will update. +/// +/// When implementing a data model, use the constructor for any synchronous +/// work, such as accessing another data model. For any async work, override the +/// [init] function. For cleanup, override the [dispose] function. +// ignore: prefer_mixin +abstract class Model with ChangeNotifier { + /// Gets whatever data is needed by this model from a service. + /// + /// This model may not function properly if this function is not called. + Future init(); +} \ No newline at end of file diff --git a/lib/src/models/data/reminders.dart b/lib/src/models/data/reminders.dart index 3cf85a784..5289ea794 100644 --- a/lib/src/models/data/reminders.dart +++ b/lib/src/models/data/reminders.dart @@ -1,37 +1,37 @@ -import "package:flutter/foundation.dart"; - import "package:ramaz/data.dart"; import "package:ramaz/services.dart"; +import "model.dart"; + /// A DataModel that keeps the state of the user's reminders. /// /// This data model abstracts all operations that have to do with reminders, /// and all other parts of the app that want to operate on reminders should use /// this data model. // ignore: prefer_mixin -class Reminders with ChangeNotifier { +class Reminders extends Model { /// The reminders for the user. - List reminders; + late final List reminders; /// The reminders that apply for this period. /// /// This is managed by the Schedule data model. - List currentReminders; + List currentReminders = []; /// The reminders that apply for next period. /// /// This is managed by the Schedule data model. - List nextReminders; + List nextReminders = []; /// Reminders that applied for previous periods. /// /// These reminders will be marked for deletion if they do not repeat. - List readReminders; + final List readReminders = []; - /// Initializes the data model. + @override Future init() async { reminders = [ - for (final Map json in await Services.instance.reminders) + for (final Map json in await Services.instance.database.reminders.getAll()) Reminder.fromJson(json) ]; } @@ -52,7 +52,6 @@ class Reminders with ChangeNotifier { } readReminders.add(index); cleanReminders(); - updateReminders(); } /// Gets all reminders that apply to the a given period. @@ -60,25 +59,16 @@ class Reminders with ChangeNotifier { /// This method is a wrapper around [Reminder.getReminders], and should only /// be called by an object with access to the relevant period. List getReminders({ - @required String subject, - @required String period, - @required Letters letter, + required String? subject, + required String? period, + required String? dayName, }) => Reminder.getReminders( reminders: reminders, subject: subject, - letter: letter, + dayName: dayName, period: period, ).toList(); - /// Saves all reminders to the device and the cloud. - Future saveReminders() async { - final List> json = [ - for (final Reminder reminder in reminders) - reminder.toJson() - ]; - await Services.instance.setReminders(json); - } - /// Checks if any reminders have been modified and removes them. /// /// This makes sure that any reminders in [currentReminders], @@ -90,64 +80,48 @@ class Reminders with ChangeNotifier { readReminders ]; for (final List remindersList in reminderLists) { - int toRemove; - for (final int index in remindersList ?? []) { - if (index == changedIndex) { - toRemove = index; - break; - } + final int removeIndex = remindersList.indexOf(changedIndex); + if (removeIndex != -1) { + remindersList.removeAt(removeIndex); } - if (toRemove != null) { - remindersList.remove(toRemove); - } - } - } - - /// Runs errands whenever reminders have changed. - /// - /// Does the following: - /// - /// 1. Runs [verifyReminders] (if a reminder has changed and not simply added). - /// 2. Runs [saveReminders]. - /// 3. Calls [notifyListeners]. - void updateReminders([int changedIndex]) { - if (changedIndex != null) { - verifyReminders(changedIndex); } - saveReminders(); - notifyListeners(); } /// Replaces a reminder at a given index. - void replaceReminder(int index, Reminder reminder) { + void replaceReminder(int index, Reminder? reminder) { if (reminder == null) { return; } - reminders - ..removeAt(index) - ..insert(index, reminder); - updateReminders(index); + if (reminders [index].id != reminder.id) { + throw ArgumentError.value( + reminder.id, // value + "New reminder id", // name of value + "New reminder ID must match old reminder ID", // message + ); + } + reminders [index] = reminder; + Services.instance.database.reminders.set(reminder.toJson()); + verifyReminders(index); + notifyListeners(); } - /// Adds a reminder to the reminders list. - /// - /// Use this method instead of simply `reminders.add` to - /// ensure that [updateReminders] is called. - void addReminder(Reminder reminder) { + /// Creates a new reminder. + void addReminder(Reminder? reminder) { if (reminder == null) { return; } reminders.add(reminder); - updateReminders(); + Services.instance.database.reminders.set(reminder.toJson()); + notifyListeners(); } /// Deletes the reminder at a given index. - /// - /// Use this instead of `reminders.removeAt` to ensure that - /// [updateReminders] is called. - void deleteReminder(int index) { + Future deleteReminder(int index) async { + final String id = reminders [index].id; + await Services.instance.database.reminders.delete(id); reminders.removeAt(index); - updateReminders(index); + verifyReminders(index); // remove the reminder from the schedule + notifyListeners(); } /// Deletes expired reminders. diff --git a/lib/src/models/data/schedule.dart b/lib/src/models/data/schedule.dart index 7bfb77cab..2f100359c 100644 --- a/lib/src/models/data/schedule.dart +++ b/lib/src/models/data/schedule.dart @@ -1,15 +1,13 @@ import "dart:async" show Timer; -import "package:flutter/foundation.dart"; - -import "package:ramaz/services.dart"; import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; -import "reminders.dart"; +import "package:ramaz/services.dart"; + +import "model.dart"; /// A data model for the user's schedule. -// ignore: prefer_mixin -class Schedule with ChangeNotifier { +class ScheduleModel extends Model { /// The current date. /// /// This helps track when the day has changed. @@ -18,58 +16,70 @@ class Schedule with ChangeNotifier { /// How often to refresh the schedule. static const timerInterval = Duration (minutes: 1); - /// The student object for the user. - Student student; + /// The current user. + late User user; /// The subjects this user has. - Map subjects; + late Map subjects; /// The calendar for the month. - List> calendar; + late List> calendar; /// A timer that updates the period. /// /// This timer fires once every [timerInterval], and calls [onNewPeriod] /// when it does. - Timer timer; + Timer? timer; /// The [Day] that represents today. - Day today; + Day? today; /// The current period. - Period period; + Period? period; /// The next period. - Period nextPeriod; + Period? nextPeriod; /// A list of today's periods. - List periods; + List? periods; /// The index that represents [period]'s location in [periods]. - int periodIndex; + int? periodIndex; - /// Initializes the schedule model. - Schedule( - ) { - Models.reminders.addListener(remindersListener); - } + /// The reminders data model. + late Reminders reminders; - /// Does the main initialization work for the schedule model. - /// - /// Should be called whenever there is new data for this model to work with. + @override Future init() async { - final Services services = Services.instance; - student = Student.fromJson(await services.user); - subjects = Subject.getSubjects(await services.getSections(student.getIds())); - calendar = Day.getCalendar(await services.calendar); + reminders = Models.instance.reminders + ..addListener(remindersListener); + await initCalendar(); + } + + /// Initializes the calendar. + Future initCalendar() async { + final HybridCalendar calendarDatabase = Services.instance.database.calendar; + final List json = await calendarDatabase.getSchedules(); + Schedule.schedules = [ + for (final Map schedule in json) + Schedule.fromJson(schedule) + ]; + calendar = [ + for (int month = 1; month <= 12; month++) [ + for (final Map? day in await calendarDatabase.getMonth(month)) + day == null ? null : Day.fromJson(day) + ] + ]; + user = Models.instance.user.data; + subjects = Models.instance.user.subjects; setToday(); notifyListeners(); } @override void dispose() { - Models.reminders.removeListener(remindersListener); - timer.cancel(); + Models.instance.reminders.removeListener(remindersListener); + timer?.cancel(); super.dispose(); } @@ -79,13 +89,13 @@ class Schedule with ChangeNotifier { void remindersListener() => updateReminders(scheduleNotifications: true); /// The current subject. - Subject get subject => subjects [period?.id]; + Subject? get subject => subjects [period?.id]; /// The next subject. - Subject get nextSubject => subjects [nextPeriod?.id]; + Subject? get nextSubject => subjects [nextPeriod?.id]; /// Whether there is school today. - bool get hasSchool => today.school; + bool get hasSchool => today != null; /// Changes the current day. /// @@ -97,9 +107,9 @@ class Schedule with ChangeNotifier { // initialize today today = Day.getDate(calendar, now); timer?.cancel(); - if (today.school) { + if (hasSchool) { // initialize periods. - periods = student.getPeriods(today); + periods = user.getPeriods(today!); // initialize the current period. onNewPeriod(first: true); // initialize the timer. See comments for [timer]. @@ -119,38 +129,29 @@ class Schedule with ChangeNotifier { now = newDate; return setToday(); } - - updateReminders(scheduleNotifications: first); // no school today. - if (!today.school) { + if (!hasSchool) { period = nextPeriod = periods = null; return; } // So we have school today... - final int newIndex = today.period; - - - // Maybe the day changed - if (newIndex != null && newIndex == periodIndex) { - // return notifyListeners(); - return; - } + final int? newIndex = today?.getCurrentPeriod(); // period changed since last checked. periodIndex = newIndex; // School ended - if (periodIndex == null) { + if (newIndex == null) { period = nextPeriod = null; return; } // Period changed and there is still school. - period = periods [periodIndex]; - nextPeriod = periodIndex < periods.length - 1 - ? periods [periodIndex + 1] + period = periods! [newIndex]; + nextPeriod = newIndex < periods!.length - 1 + ? periods! [newIndex + 1] : null; updateReminders(scheduleNotifications: first); @@ -164,19 +165,19 @@ class Schedule with ChangeNotifier { /// response to changed reminders. See [scheduleReminders] for more details /// on scheduling notifications. void updateReminders({bool scheduleNotifications = false}) { - Models.reminders - ..currentReminders = Models.reminders.getReminders( - period: period?.period, + reminders + ..currentReminders = reminders.getReminders( + period: period?.name, subject: subjects [period?.id]?.name, - letter: today.letter, + dayName: today?.name, ) - ..nextReminders = Models.reminders.getReminders( - period: nextPeriod?.period, + ..nextReminders = reminders.getReminders( + period: nextPeriod?.name, subject: subjects [nextPeriod?.id]?.name, - letter: today.letter, + dayName: today?.name, ); - (Models.reminders.currentReminders ?? []).forEach(Models.reminders.markShown); + reminders.currentReminders.forEach(reminders.markShown); if (scheduleNotifications) { Future(scheduleReminders); @@ -187,25 +188,25 @@ class Schedule with ChangeNotifier { /// Schedules notifications for today's reminders. /// /// Starting from the current period, schedules a notification for the period - /// using [Notifications.scheduleNotification]. + /// using [Notifications.scheduleNotification] Future scheduleReminders() async { - Notifications.cancelAll(); + Services.instance.notifications.cancelAll(); final DateTime now = DateTime.now(); // No school today/right now - if (!today.school || periodIndex == null || periods == null) { + if (!hasSchool || periodIndex == null || periods == null) { return; } // For all periods starting from periodIndex, schedule applicable reminders. - for (int index = periodIndex; index < periods.length; index++) { - final Period period = periods [index]; - for (final int reminderIndex in Models.reminders.getReminders( - period: period?.period, - subject: subjects [period?.id]?.name, - letter: today.letter, + for (int index = periodIndex!; index < periods!.length; index++) { + final Period period = periods! [index]; + for (final int reminderIndex in reminders.getReminders( + period: period.name, + subject: subjects [period.id]?.name, + dayName: today?.name, )) { - Notifications.scheduleNotification( + Services.instance.notifications.scheduleNotification( date: DateTime( now.year, now.month, @@ -215,10 +216,30 @@ class Schedule with ChangeNotifier { ), notification: Notification.reminder( title: "New reminder", - message: Models.reminders.reminders [reminderIndex].message, + message: reminders.reminders [reminderIndex].message, ) ); } } } + + /// Determines whether a reminder is compatible with the user's schedule. + /// + /// If [User.dayNames] has changed, then reminders with [PeriodReminderTime] + /// might fail. Similarly, if the user changes classes, [SubjectReminderTime] + /// might fail. This method helps the app spot these inconsistencies and get + /// rid of the problematic reminders. + bool isValidReminder(Reminder reminder) { + switch(reminder.time.type) { + case ReminderTimeType.period: + final PeriodReminderTime time = reminder.time as PeriodReminderTime; + final Iterable dayNames = user.schedule.keys; + return dayNames.contains(time.dayName) + && int.parse(time.period) <= user.schedule [time.dayName]!.length; + case ReminderTimeType.subject: + final SubjectReminderTime time = reminder.time as SubjectReminderTime; + return subjects.values.any((Subject subject) => subject.name == time.name); + default: throw StateError("Reminder <$reminder> has invalid ReminderTime"); + } + } } diff --git a/lib/src/models/data/sports.dart b/lib/src/models/data/sports.dart index 8c8b644f9..0d5491245 100644 --- a/lib/src/models/data/sports.dart +++ b/lib/src/models/data/sports.dart @@ -1,49 +1,31 @@ import "dart:async"; -import "package:flutter/foundation.dart"; - -import "package:ramaz/constants.dart" show DayComparison; +import "package:ramaz/constants.dart"; import "package:ramaz/data.dart"; import "package:ramaz/services.dart"; +import "model.dart"; /// A data model for sports games. /// /// This class hosts [todayGames], a list of games being played today, /// as well as CRUD methods for the database (if permissions allow). // ignore: prefer_mixin -class Sports with ChangeNotifier { - static const Duration _minute = Duration(minutes: 1); - - /// A timer to refresh [todayGames]. - Timer timer; - - DateTime now; +class Sports extends Model { + /// Helps partition [games] by past and future. + DateTime now = DateTime.now(); /// A list of all the games taking place. - List games; + List games = []; /// A list of games being played today to be showed on the home screen. - List todayGames; - - /// Creates a data model for sports games. - Sports() { - timer = Timer.periodic(_minute, (_) => todayGames = getTodayGames()); - } + List todayGames = []; /// Loads data from the device and - Future init({bool refresh = false}) async { - if (refresh) { - games = SportsGame.fromList(await Services.instance.sports); - todayGames = getTodayGames(); - now = DateTime.now(); - } else { - final DateTime newDate = DateTime.now(); - if (!newDate.isSameDay(now)) { - todayGames = getTodayGames(); - now = newDate; - } - } - notifyListeners(); + @override + Future init() async { + games = SportsGame.fromList(await Services.instance.database.sports.getAll()); + todayGames = getTodayGames(); + now = DateTime.now(); } /// Returns a list of all the games taking place today. @@ -56,7 +38,7 @@ class Sports with ChangeNotifier { ]; /// Adds a game to the database. - Future addGame(SportsGame game) async { + Future addGame(SportsGame? game) async { if (game == null) { return; } @@ -68,7 +50,7 @@ class Sports with ChangeNotifier { /// /// Since [SportsGame] objects are immutable, they cannot be changed in place. /// Instead, they are replaced with a new one. - Future replace(int index, SportsGame newGame) async { + Future replace(int index, SportsGame? newGame) async { if (newGame == null) { return; } @@ -77,14 +59,15 @@ class Sports with ChangeNotifier { } /// Deletes a game from the database. - Future delete(int index) { + Future delete(int index) async { games.removeAt(index); - return saveGames(); + await saveGames(); + await init(); } /// Saves the games to the database. /// /// Used in any database CRUD methods. - Future saveGames() => - Services.instance.setSports(SportsGame.getJsonList(games)); + Future saveGames() => Services.instance.database.sports + .setAll(SportsGame.getJsonList(games)); } \ No newline at end of file diff --git a/lib/src/models/data/user.dart b/lib/src/models/data/user.dart new file mode 100644 index 000000000..d52546349 --- /dev/null +++ b/lib/src/models/data/user.dart @@ -0,0 +1,46 @@ +import "package:ramaz/data.dart"; +import "package:ramaz/services.dart"; + +import "model.dart"; + +/// A data model to hold and initialize the [User] object. +/// +/// This data model doesn't really update, however it's a convenient place to +/// keep the user object. Any functionality relating to the admin is also +/// implemented here, so that the code for updating both the database and the +/// UI is in one place. +class UserModel extends Model { + /// The user object. + late User data; + + /// The subjects this user has. + /// + /// For students this will be the courses they attend. For teachers, this + /// will be the courses they teach. + late Map subjects; + + /// The permissions this user has, if they are an administrator. + List? adminScopes; + + /// Whether this user is an admin. + /// + /// Unlike [Auth.isAdmin], which authenticates with the server, this getter + /// simply checks to see if [adminScopes] was initialized. + bool get isAdmin => adminScopes != null; + + @override + Future init() async { + data = User.fromJson(await Services.instance.database.user.getProfile()); + subjects = { + for (final String id in data.sectionIDs) + id: Subject.fromJson( + await Services.instance.database.schedule.getCourse(id) + ) + }; + final List? scopeStrings = await Auth.adminScopes; + adminScopes = scopeStrings == null ? null : [ + for (final String scope in scopeStrings) + parseAdminScope(scope) + ]; + } +} diff --git a/lib/src/models/view/admin_schedules.dart b/lib/src/models/view/admin_schedules.dart new file mode 100644 index 000000000..c02d2d6dd --- /dev/null +++ b/lib/src/models/view/admin_schedules.dart @@ -0,0 +1,44 @@ +import "package:flutter/foundation.dart"; + +import "package:ramaz/data.dart"; +import "package:ramaz/services.dart"; + +/// A view model for managing the schedules. +// ignore: prefer_mixin +class AdminScheduleModel with ChangeNotifier { + /// All available schedules. + final List schedules = Schedule.schedules; + + /// All schedules in JSON form. + List get jsonSchedules => [ + for (final Schedule schedule in schedules) + schedule.toJson() + ]; + + /// Saves the schedules to the database and refreshes. + Future saveSchedules() async { + await Services.instance.database.calendar.setSchedules(jsonSchedules); + Schedule.schedules = schedules; + notifyListeners(); + } + + /// Creates a new schedule. + Future createSchedule(Schedule schedule) async { + schedules.add(schedule); + return saveSchedules(); + } + + /// Updates a schedule. + Future replaceSchedule(Schedule schedule) async { + schedules + ..removeWhere((Schedule other) => other.name == schedule.name) + ..add(schedule); + return saveSchedules(); + } + + /// Deletes a schedule + Future deleteSchedule(Schedule schedule) async { + schedules.removeWhere((Schedule other) => other.name == schedule.name); + return saveSchedules(); + } +} diff --git a/lib/src/models/view/builders/day_builder.dart b/lib/src/models/view/builders/day_builder.dart index 8194a4c3e..7bc504c8c 100644 --- a/lib/src/models/view/builders/day_builder.dart +++ b/lib/src/models/view/builders/day_builder.dart @@ -1,65 +1,41 @@ import "package:flutter/foundation.dart" show ChangeNotifier; import "package:ramaz/data.dart"; -import "package:ramaz/models.dart"; /// A view model for the creating a new [Day]. // ignore: prefer_mixin class DayBuilderModel with ChangeNotifier { - /// The admin creating this [Day]. - /// - /// This is used to create [userSpecials]. - final AdminUserModel admin; + /// The date being modified. + final DateTime date; - Letters _letter; - Special _special; bool _hasSchool; + String? _name; + Schedule? _schedule; /// Creates a view model for modifying a [Day]. - DayBuilderModel(Day day) : admin = Models.admin.user { - admin.addListener(notifyListeners); - _letter = day?.letter; - _special = day?.special; - _hasSchool = day?.school; - } - - @override - void dispose() { - admin.removeListener(notifyListeners); - super.dispose(); - } - - /// The letter for this day. - Letters get letter => _letter; - set letter (Letters value) { - _letter = value; + DayBuilderModel({required Day? day, required this.date}) : + _name = day?.name, + _schedule = day?.schedule, + _hasSchool = day != null; + + /// The name for this day. + String? get name => _name; + set name (String? value) { + _name = value; notifyListeners(); } - /// The special for this day. - Special get special => _special; - set special (Special value) { + /// The schedule for this day. + Schedule? get schedule => _schedule; + set schedule (Schedule? value) { if (value == null) { return; } - _special = value; - if( - !presetSpecials.any( - (Special preset) => preset.name == value.name - ) && !userSpecials.any( - (Special preset) => preset.name == value.name - ) - ) { - admin.addSpecial(value); - } - + _schedule = value; notifyListeners(); } /// If this day has school. - /// - /// This is different than [Day.school] because it doesn't belong to [day], - /// it dictates whether [letter] and [special] is used in [day]. bool get hasSchool => _hasSchool; set hasSchool(bool value) { _hasSchool = value; @@ -67,18 +43,11 @@ class DayBuilderModel with ChangeNotifier { } /// The day being created (in present state). - /// - /// The model uses [letter] and [special]. - Day get day => hasSchool - ? Day(letter: letter, special: special) - : Day(letter: null, special: null); - - /// The built-in specials. - List get presetSpecials => Special.specials; - - /// Custom user-created specials. - List get userSpecials => admin.admin.specials; + Day? get day => !hasSchool ? null : Day( + name: name ?? "", + schedule: schedule ?? Schedule.schedules.first, + ); /// Whether this day is ready to be created. - bool get ready => letter != null && special != null; + bool get ready => name != null && schedule != null; } diff --git a/lib/src/models/view/builders/reminder_builder.dart b/lib/src/models/view/builders/reminder_builder.dart index 875e1a47a..2c3c59dff 100644 --- a/lib/src/models/view/builders/reminder_builder.dart +++ b/lib/src/models/view/builders/reminder_builder.dart @@ -2,41 +2,43 @@ import "package:flutter/foundation.dart"; import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; +import "package:ramaz/services.dart"; /// A view model for the dialog that allows the user to build a reminder. // ignore: prefer_mixin class RemindersBuilderModel with ChangeNotifier { - final Schedule _schedule; + final ScheduleModel _schedule; /// The type of reminder the user is building. - ReminderTimeType type; + ReminderTimeType? type; /// The time this reminder will be displayed. - ReminderTime time; + ReminderTime? time; /// The message for this reminder. String message = ""; + /// The ID for this reminder. + late String _id; + /// Whether this reminder repeats. /// /// This affects whether it will be deleted after /// being displayed once. bool shouldRepeat = false; - /// The day this reminder should be displayed. - /// - /// Only relevant for [PeriodReminderTime]. - Day day; + /// The name of the day. + String? dayName; /// The period this reminder should be displayed. /// /// Only relevant for [PeriodReminderTime]. - String period; + String? period; /// The name of the class this reminder should be displayed. /// /// Only relevant for [SubjectReminderTime]. - String course; + String? course; /// All the names of the user's courses. final List courses; @@ -45,30 +47,31 @@ class RemindersBuilderModel with ChangeNotifier { /// /// If [reminder] is not null, then the relevant fields of this /// class are filled in with the corresponding fields of the reminder. - RemindersBuilderModel(Reminder reminder) : - _schedule = Models.schedule, + RemindersBuilderModel([Reminder? reminder]) : + _schedule = Models.instance.schedule, courses = [ - for (final Subject subject in Models.schedule.subjects.values) + for (final Subject subject in Models.instance.schedule.subjects.values) subject.name ] { if (reminder == null) { + _id = Services.instance.database.reminders.getId(); return; } + _id = reminder.id; message = reminder.message; time = reminder.time; - - shouldRepeat = time.repeats; - type = time.type; + shouldRepeat = time!.repeats; + type = time!.type; switch (type) { case ReminderTimeType.period: - final PeriodReminderTime reminderTime = time; + final PeriodReminderTime reminderTime = time as PeriodReminderTime; period = reminderTime.period; - day = Day (letter: reminderTime.letter); + dayName = reminderTime.dayName; break; case ReminderTimeType.subject: - final SubjectReminderTime reminderTime = time; + final SubjectReminderTime reminderTime = time as SubjectReminderTime; course = reminderTime.name; break; default: @@ -79,9 +82,10 @@ class RemindersBuilderModel with ChangeNotifier { /// Returns a new reminder from the model's fields. Reminder build() => Reminder ( message: message, + id: _id, time: ReminderTime.fromType( - type: type, - letter: letter, + type: type!, + dayName: dayName, period: period, name: course, repeats: shouldRepeat, @@ -94,27 +98,34 @@ class RemindersBuilderModel with ChangeNotifier { /// /// - [message] is null or empty, /// - [type] is null, - /// - [type] is [ReminderTimeType.period] and [letter] or [period] is null, or + /// - [type] is [ReminderTimeType.period] and [dayName] or [period] is null, or /// - [type] is [ReminderTimeType.subject] and [course] is null. - /// - bool get ready => (message?.isNotEmpty ?? false) && - type != null && - (type != ReminderTimeType.period || - (day?.letter != null && period != null) - ) && ( - type != ReminderTimeType.subject || course != null - ); - - /// A list of all the periods in [day]. - /// - /// Make sure this field is only accessed *after* setting [day]. - List get periods => day == null ? null : [ - for (final Period period in _schedule.student.getPeriods(day)) - period.period - ]; + bool get ready { + if (message.isEmpty) { + return false; + } + switch (type) { + case ReminderTimeType.period: + return dayName != null && period != null; + case ReminderTimeType.subject: + return type != ReminderTimeType.subject || course != null; + case null: return false; + } + } - /// The selected letter. - Letters get letter => day?.letter; + /// A list of all the periods in [dayName]. + /// + /// Make sure this field is only accessed *after* setting [dayName]. + List? get periods { + if (dayName == null) { + return null; + } + final List schedule = _schedule.user.schedule [dayName]!; + return [ + for (int index = 0; index < schedule.length; index++) + (index + 1).toString() + ]; + } /// Sets the message to the given string. /// @@ -140,8 +151,8 @@ class RemindersBuilderModel with ChangeNotifier { /// Changes the [period] of this reminder. /// /// Only relevant when [type] is [ReminderTimeType.period]. - void changeLetter(Letters value) { - day = Day (letter: value); + void changeDay(String value) { + dayName = value; period = null; notifyListeners(); } diff --git a/lib/src/models/view/builders/schedule_builder.dart b/lib/src/models/view/builders/schedule_builder.dart new file mode 100644 index 000000000..2fafd4d10 --- /dev/null +++ b/lib/src/models/view/builders/schedule_builder.dart @@ -0,0 +1,151 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/data.dart"; + +extension on TimeOfDay { + Time get ramazTime => Time(hour, minute); + + DateTime get toDateTime => DateTime(200, 01, 01, hour, minute); +} + +extension on Time { + TimeOfDay get toFlutterTime => TimeOfDay(hour: hour, minute: minutes); +} + +/// A variant of [Period] with all nullable fields. +/// +/// This class also helps the UI by providing data validation. +class EditablePeriod { + /// The text controller for this period entry. + /// + /// We save the controller instead of the string value to make the workload + /// easier on the UI side. + final TextEditingController controller; + + /// The time this period starts. + /// + /// Equivalent to [Range.start]. + TimeOfDay? start; + + /// The time this period ends. + /// + /// Equivalent to [Range.end]. + TimeOfDay? end; + + /// Creates an [EditablePeriod] with the period name pre-set. + EditablePeriod({required int index}) : + controller = TextEditingController(text: (index + 1).toString()); + + /// Creates an [EditablePeriod] with the properties of a [Period]. + EditablePeriod.fromPeriod(Period period) : + controller = TextEditingController(text: period.name), + start = period.time.start.toFlutterTime, + end = period.time.end.toFlutterTime; + + /// The name of this period. + String get name => controller.text; + + /// Whether this period is ready to be saved. + bool get isReady => name.isNotEmpty + && start != null + && end != null; + + /// Whether this period has an invalid time. + /// + /// Invalid means anything that will trip up the normal scheduling code. + /// For example, a [start] that's _after_ the [end]. Also, sometimes the UI + /// can suggest the wrong half of the day (AM vs PM) so this catches that too. + bool get hasInvalidTime { + if (start == null || end == null) { + return false; + } + final DateTime startDt = start!.toDateTime; + final DateTime endDt = end!.toDateTime; + return startDt.isAfter(endDt) || endDt.difference(startDt).inHours > 10; + } + + /// Converts this into a regular [Period] object. + /// + /// [isReady] must be true, since [Period] has non-nullable fields. + Period get ramazPeriod => Period.raw( + name: name.trim(), + time: Range(start!.ramazTime, end!.ramazTime), + ); + + /// Disposes this object's [TextEditingController]. + void dispose() => controller.dispose(); +} + +/// A view model for the schedule builder page. +/// +/// This model provides [EditablePeriod]s instead of [Period]s to allow all +/// fields to be null, and also for convenient data validation. +// ignore: prefer_mixin +class ScheduleBuilderModel with ChangeNotifier { + /// The periods in this schedule. + List periods = [ + for (int index = 0; index < 7; index++) + EditablePeriod(index: index), + ]; + + String _name = ""; + + /// The name of this schedule. + String get name => _name; + set name(String value) { + _name = value; + notifyListeners(); + } + + /// Whether this schedule is ready to save. + bool get isReady => name.isNotEmpty && periods + .every((EditablePeriod period) => period.isReady); + + /// The finished schedule. + /// + /// [isReady] must be true, since this calls [EditablePeriod.ramazPeriod]. + Schedule get schedule => Schedule( + name: name, + periods: [ + for (final EditablePeriod period in periods) + period.ramazPeriod, + ] + ); + + /// Adds a period to the schedule. + void addPeriod() { + periods.add(EditablePeriod(index: periods.length)); + notifyListeners(); + } + + /// Removes a period from the schedule. + void removePeriod() { + periods.removeLast(); + notifyListeners(); + } + + /// Copies data from another schedule to this one. + /// + /// Allows the user to quickly make small changes to existing schedules. + void usePreset(Schedule? preset, {bool includeName = false}) { + if (preset == null) { + return; + } + periods = [ + for (final Period period in preset.periods) + EditablePeriod.fromPeriod(period) + ]; + if (includeName) { + _name = preset.name; + } + notifyListeners(); + } + + @override + void dispose() { + for (final EditablePeriod period in periods) { + period.dispose(); + } + super.dispose(); + } +} diff --git a/lib/src/models/view/builders/special_builder.dart b/lib/src/models/view/builders/special_builder.dart deleted file mode 100644 index 8d4499ae9..000000000 --- a/lib/src/models/view/builders/special_builder.dart +++ /dev/null @@ -1,173 +0,0 @@ -import "package:flutter/foundation.dart" show ChangeNotifier; - -import "package:ramaz/data.dart"; - -/// A view model to create a [Special]. -// ignore: prefer_mixin -class SpecialBuilderModel with ChangeNotifier { - /// The special that this special is based on. - Special preset; - - /// Numbers for the periods. - /// - /// Regular periods have numbers, others (eg, homeroom and mincha) are null. - List periods = []; - final Map activities = {}; - - List _times = []; - List _skips = []; - String _name; - int _numPeriods = 0, _mincha, _homeroom; - - /// The times for the periods. - /// - /// See [Special.periods] - List get times => _times; - set times (List value) { - _times = List.of(value); - notifyListeners(); - } - - /// Indices of skipped periods. - /// - /// See [Special.skip]. - List get skips => _skips; - set skips (List value) { - _skips = value; - notifyListeners(); - } - - /// The name of this special. - /// - /// See [Special.name]. - String get name => _name; - set name (String value) { - _name = value; - notifyListeners(); - } - - /// The amount of periods. - /// - /// If a period is added, a [Range] is added to [times]. - /// In any case, [periods] is recalculated. - /// - /// This is essentially `special.periods.length`. - int get numPeriods => _numPeriods; - set numPeriods (int value) { - if (value == 0) { - times.clear(); - homeroom = null; - mincha = null; - } - if (value < numPeriods) { - times.removeRange(value, times.length); - if (homeroom == value) { - homeroom = null; - } - if (mincha == value) { - mincha = null; - } - } else { - if (_numPeriods == 0) { - times.add( - const Range(Time(8, 00), Time(8, 50)) - ); - } else { - final Range prev = times [value - 2]; - times.add( - Range( - Time(prev.end.hour + 1, 0), - Time(prev.end.hour + 2, 0) - ) - ); - } - } - _numPeriods = value; - periods = getIndices(); - notifyListeners(); - } - - /// The index of Mincha in [times]. - /// - /// See [Special.mincha]. - int get mincha => _mincha; - set mincha (int value) { - _mincha = value; - periods = getIndices(); - notifyListeners(); - } - - /// The index of homeroom in [times]. - /// - /// See [Special.homeroom]. - int get homeroom => _homeroom; - set homeroom (int value) { - _homeroom = value; - periods = getIndices(); - notifyListeners(); - } - - /// Whether this special is ready to be built. - bool get ready => numPeriods != null && - numPeriods > 0 && - times.isNotEmpty && - name != null && name.isNotEmpty && - !Special.specials.any((Special special) => special.name == name) && - (preset == null || special != preset); - - /// The special being built. - Special get special => Special( - name, times, - homeroom: homeroom, - mincha: mincha, - skip: skips - ); - - /// Switches out a time in [times] with a new time. - void replaceTime(int index, Range range) { - times [index] = range; - notifyListeners(); - } - - /// Toggle whether a period is skipped. - void toggleSkip(int index) { - skips.contains(index) - ? skips.remove(index) - : skips.add(index); - notifyListeners(); - } - - /// Sets properties of this special based on an existing special. - /// - /// The special can then be fine-tuned afterwards. - void usePreset(Special special) { - if (special == null) { - return; - } - preset = special; - _times = List.of(special.periods); - _skips = special.skip ?? []; - _name = special.name; - _numPeriods = special.periods.length; - _mincha = special.mincha; - _homeroom = special.homeroom; - periods = getIndices(); - notifyListeners(); - } - - /// Gets the period numbers for all periods. - /// - /// Any non-normal periods (eg, homeroom) are represented by `null` - List getIndices() { - int counter = 1; - return [ - for (int index = 0; index < _times.length; index++) - if (index == homeroom) - "homeroom" - else if (index == mincha) - "Mincha" - else - (counter++).toString() - ]; - } -} diff --git a/lib/src/models/view/builders/sports_builder.dart b/lib/src/models/view/builders/sports_builder.dart index 91c0ad337..255ea2b88 100644 --- a/lib/src/models/view/builders/sports_builder.dart +++ b/lib/src/models/view/builders/sports_builder.dart @@ -2,50 +2,52 @@ import "package:flutter/material.dart" show ChangeNotifier, TimeOfDay; import "package:ramaz/constants.dart"; import "package:ramaz/data.dart"; +import "package:string_extensions/string_extensions.dart"; /// A ViewModel for the Sports game builder. // ignore: prefer_mixin class SportsBuilderModel with ChangeNotifier { - Scores _scores; - Sport _sport; - DateTime _date; - TimeOfDay _start, _end; + Scores? _scores; + Sport? _sport; + DateTime? _date; + TimeOfDay? _start, _end; - String _opponent, _team; + String? _opponent, _team, _livestreamUrl; bool _away = false, _loading = false; /// Creates a ViewModel for the sports game builder page. /// /// Passing in a [SportsGame] for [parent] will fill this page with all the /// relevant properties of [parent] before building. - SportsBuilderModel([SportsGame parent]) : + SportsBuilderModel([SportsGame? parent]) : _scores = parent?.scores, _sport = parent?.sport, _date = parent?.date, - _start = parent?.times?.start?.asTimeOfDay, - _end = parent?.times?.end?.asTimeOfDay, + _start = parent?.times.start.asTimeOfDay, + _end = parent?.times.end.asTimeOfDay, _opponent = parent?.opponent, _team = parent?.team, - _away = !(parent?.home ?? true); + _away = !(parent?.isHome ?? true), + _livestreamUrl = parent?.livestreamUrl; /// Whether this game is ready to submit. bool get ready => sport != null && team != null && - away != null && date != null && start != null && end != null && - opponent.isNotEmpty; + (opponent?.isNotEmpty ?? false); /// The game being created. SportsGame get game => SportsGame( - date: date, - home: !away, - times: Range(start?.asTime, end?.asTime), + date: date!, + isHome: !away, + times: Range(start!.asTime, end!.asTime), team: team ?? "", opponent: opponent ?? "", - sport: sport, + sport: sport!, scores: scores, + livestreamUrl: away ? livestreamUrl : Urls.sportsLivestream, ); /// The scores for this game. @@ -53,8 +55,8 @@ class SportsBuilderModel with ChangeNotifier { /// This only applies if the game has already been finished. /// /// Changing this will update the page. - Scores get scores => _scores; - set scores(Scores value) { + Scores? get scores => _scores; + set scores(Scores? value) { _scores = value; notifyListeners(); } @@ -62,8 +64,8 @@ class SportsBuilderModel with ChangeNotifier { /// The sport being played. /// /// Changing this will update the page. - Sport get sport => _sport; - set sport(Sport value) { + Sport? get sport => _sport; + set sport(Sport? value) { _sport = value; notifyListeners(); } @@ -71,8 +73,8 @@ class SportsBuilderModel with ChangeNotifier { /// The date this game takes place. /// /// Changing this will update the page. - DateTime get date => _date; - set date(DateTime value) { + DateTime? get date => _date; + set date(DateTime? value) { _date = value; notifyListeners(); } @@ -80,8 +82,8 @@ class SportsBuilderModel with ChangeNotifier { /// The time this game starts. /// /// Changing this will update the page. - TimeOfDay get start => _start; - set start(TimeOfDay value) { + TimeOfDay? get start => _start; + set start(TimeOfDay? value) { _start = value; notifyListeners(); } @@ -89,8 +91,8 @@ class SportsBuilderModel with ChangeNotifier { /// The time this game ends. /// /// Changing this will update the page. - TimeOfDay get end => _end; - set end(TimeOfDay value) { + TimeOfDay? get end => _end; + set end(TimeOfDay? value) { _end = value; notifyListeners(); } @@ -98,21 +100,30 @@ class SportsBuilderModel with ChangeNotifier { /// The (home) team playing this game. /// /// Changing this will update the page. - String get team => _team; - set team(String value) { - _team = value; + String? get team => _team; + set team(String? value) { + _team = "$value ${sport?.name.capitalize}"; notifyListeners(); } /// The name of the opponent school. /// /// Changing this will update the page. - String get opponent => _opponent; - set opponent(String value) { + String? get opponent => _opponent; + set opponent(String? value) { _opponent = value; notifyListeners(); } + /// The URL to the livestream for this game + /// + /// Changing this will update the page. + String? get livestreamUrl => _livestreamUrl; + set livestreamUrl(String? value){ + _livestreamUrl = value!.isEmpty ? null : value; + notifyListeners(); + } + /// Whether this game is being played away. /// /// Changing this will update the page. diff --git a/lib/src/models/view/calendar_editor.dart b/lib/src/models/view/calendar_editor.dart new file mode 100644 index 000000000..3acd7d352 --- /dev/null +++ b/lib/src/models/view/calendar_editor.dart @@ -0,0 +1,128 @@ +import "dart:async"; + +import "package:flutter/foundation.dart" show ChangeNotifier; + +import "package:ramaz/data.dart"; +import "package:ramaz/services.dart"; + +/// Bundles a [DateTime] with a [Day] to edit the calendar. +class CalendarDay { + /// The date being represented. + final DateTime date; + + /// The school day for the given date. + Day? schoolDay; + + /// Bundles a date and a [Day] together. + CalendarDay({required this.date, required this.schoolDay}); +} + +/// A model to manage the calendar. +/// +/// This model listens to the calendar and can modify it in the database. +// ignore: prefer_mixin +class CalendarEditor with ChangeNotifier { + /// How many days there are in every month. + List daysInMonth = List.filled(12, null); + + /// The current date. + static final DateTime now = DateTime.now(); + + /// The current year. + static final int currentYear = now.year; + + /// The current month. + static final int currentMonth = now.month; + + /// The calendar filled with [Day]s. + /// + /// Each month is lazy-loaded from the database, so it's null until selected. + final List?> calendar = List.filled(12, null); + + /// A list of streams on the Firebase streams. + final List subscriptions = List.filled(12, null); + + /// The year of each month. + /// + /// Because the school year goes from September to June, determining the year + /// of any given month is not trivial. The year is computed from the current + /// month and the month in question. + final List years = [ + for (int month = 0; month < 12; month++) + currentMonth > 7 + ? month > 7 ? currentYear : currentYear + 1 + : month > 7 ? currentYear - 1 : currentYear + ]; + + /// Loads the current month to create the calendar. + CalendarEditor() { + loadMonth(DateTime.now().month - 1); + } + + /// Loads a month from the database and pads it accordingly. + void loadMonth(int month) => subscriptions [month] ??= Services + .instance.database.firestore.getCalendarStream(month + 1) + .listen( + (List cal) { + calendar [month] = layoutMonth( + [ + for (final Map? day in cal) + day == null ? null : Day.fromJson(day), + ], + month + ); + notifyListeners(); + } + ); + + @override + void dispose() { + for (final StreamSubscription? subscription in subscriptions) { + subscription?.cancel(); + } + super.dispose(); + } + + /// Fits the calendar to a 6-week layout. + /// + /// Adjusts the calendar so that it begins on the correct day of the week + /// (starting on Sunday) instead of defaulting to the first open cell on + /// the calendar grid. This function pads the calendar with the correct + /// amount of empty days before and after the month. + List layoutMonth(List cal, int month) { + final int year = years [month]; + final int firstDayOfMonth = DateTime(year, month + 1, 1).weekday; + final int weekday = firstDayOfMonth == 7 ? 0 : firstDayOfMonth; + int weeks = 0; // the number of sundays (except for the first week) + if (firstDayOfMonth != 7) { // First week doesn't have a sunday + weeks++; + } + for (int date = 0; date < cal.length; date++) { + if (DateTime(year, month + 1, date + 1).weekday == 7) { // Sunday + weeks++; + } + } + final int leftPad = weekday; + final int rightPad = (weeks * 7) - (weekday + cal.length); + return [ + for (int _ = 0; _ < leftPad; _++) null, + for (int date = 0; date < cal.length; date++) CalendarDay( + date: DateTime(year, month + 1, date + 1), + schoolDay: cal [date], + ), + for (int _ = 0; _ < rightPad; _++) null, + ]; + } + + /// Updates the calendar. + Future updateDay({required Day? day, required DateTime date}) async { + final int index = calendar [date.month - 1]! + .indexWhere((CalendarDay? day) => day != null); + calendar [date.month - 1]! [index + date.day - 1]!.schoolDay = day; + await Services.instance.database.calendar.setMonth(date.month, [ + for (final CalendarDay? day in calendar [date.month - 1]!) + if (day != null) + day.schoolDay?.toJson(), + ]); + } +} diff --git a/lib/src/models/view/feedback.dart b/lib/src/models/view/feedback.dart index 54eb0961e..754749e34 100644 --- a/lib/src/models/view/feedback.dart +++ b/lib/src/models/view/feedback.dart @@ -6,17 +6,17 @@ import "package:ramaz/services.dart"; /// A view model for the feedback page. // ignore: prefer_mixin class FeedbackModel with ChangeNotifier { - String _message; - bool _anonymous = true; + String? _message; + bool _anonymous = false; /// Whether the user is ready to submit /// /// Is true if the [message] is non-null and not empty. - bool get ready => message != null && message.trim().isNotEmpty; + bool get ready => message != null && message!.trim().isNotEmpty; /// The message that will be sent along with the feedback. - String get message => _message; - set message (String value) { + String? get message => _message; + set message (String? value) { _message = value; notifyListeners(); } @@ -31,13 +31,17 @@ class FeedbackModel with ChangeNotifier { /// Sends the feedback to Cloud Firestore. /// /// The feedback is anonymized if [anonymous] is true. - Future send() async => CloudDatabase.sendFeedback( - Feedback ( - message: message, - timestamp: DateTime.now(), - anonymous: anonymous, - name: anonymous ? null : Auth.name, - email: anonymous ? null : Auth.email, - ).toJson() + Future send() async => Services + .instance + .database + .firestore + .sendFeedback( + Feedback ( + message: message!, + timestamp: DateTime.now(), + anonymous: anonymous, + name: anonymous ? null : Auth.name, + email: anonymous ? null : Auth.email, + ).toJson() ); } diff --git a/lib/src/models/view/home.dart b/lib/src/models/view/home.dart index 5ca9fa25e..893bfe847 100644 --- a/lib/src/models/view/home.dart +++ b/lib/src/models/view/home.dart @@ -1,34 +1,63 @@ import "package:flutter/foundation.dart"; import "package:ramaz/models.dart"; +import "package:ramaz/services.dart"; -/// A ViewModel for the home page. +/// A view model for the dashboard page. /// -/// The home page does not actually need it's own ViewModel -- it pulls data -/// from other DataModels. But (currently), there is no efficient way for a -/// widget to listen to more than one DataModel, this ViewModel was made to -/// consolidate them into a single listener. +/// This model doesn't actually do much, it just listens to any data models +/// that are relevant to the dashboard. Because we're using [ChangeNotifier], +/// as its the only [Listenable] with a [dispose] method, we can't simply use +/// [Listenable.merge]. +/// +/// Additionally, the data models being listened to here will be disposed after +/// signing in and will be unusable. That's why we can't simply pass in a data +/// model. Instead, we have to reference it through [Models], which will always +/// have an up-to-date instance. // ignore: prefer_mixin -class HomeModel with ChangeNotifier { - /// The schedule data model. - final Schedule schedule; +class DashboardModel with ChangeNotifier { + /// The underlying data representing this widget. + final ScheduleModel schedule = Models.instance.schedule; + + /// The reminders data model. + final Reminders reminders = Models.instance.reminders; - /// The sports data model. - final Sports sports; + /// The sports data model. + final Sports sports = Models.instance.sports; - /// Creates a ViewModel for the home screen. - HomeModel() : - schedule = Models.schedule, - sports = Models.sports - { + /// Listens to [ScheduleModel] (and by extension, [Reminders]) and [Sports]. + DashboardModel() { schedule.addListener(notifyListeners); sports.addListener(notifyListeners); } + // Do not attempt to clean up this code by using a list. + // + // These models may be disposed, and the new instance from [Models] should + // be used instead. @override void dispose() { schedule.removeListener(notifyListeners); sports.removeListener(notifyListeners); super.dispose(); } + + /// Refreshes the database. + /// + /// This only updates the calendar and sports games, not the user profile. To + /// update user data, sign out and sign back in. + Future refresh(VoidCallback onFailure) async { + try { + await Services.instance.database.user.signIn(); + await Services.instance.database.calendar.signIn(); + await Services.instance.database.sports.signIn(); + await Models.instance.user.init(); + await Models.instance.sports.init(); + await schedule.initCalendar(); + } catch (error) { // ignore: avoid_catches_without_on_clauses + // We just want to allow the user to retry. But still rethrow. + onFailure(); + rethrow; + } + } } diff --git a/lib/src/models/view/schedule.dart b/lib/src/models/view/schedule.dart index 23ec16c97..a359e8b25 100644 --- a/lib/src/models/view/schedule.dart +++ b/lib/src/models/view/schedule.dart @@ -5,32 +5,30 @@ import "package:ramaz/models.dart"; /// A view model for the schedule page. // ignore: prefer_mixin -class ScheduleModel with ChangeNotifier { - /// The default [Letters] for the UI. - static const Letters defaultLetter = Letters.M; - - /// The default [Special] for the UI. - static const Special defaultSpecial = Special.regular; +class ScheduleViewModel with ChangeNotifier { + /// The default [Schedule] for the UI. + Schedule defaultWeekday = Schedule.schedules.firstWhere((schedule) => + schedule.name =="Weekday"); + /// + Map get defatulSchedule => { + "Monday":defaultWeekday, + "Tuesday":defaultWeekday, + "Wednesday":defaultWeekday, + "Thursday":defaultWeekday, + "Friday":Schedule.schedules.firstWhere((schedule) => + schedule.name == "Friday"), + }; /// The default [Day] for the UI. - static final Day defaultDay = - Day (letter: defaultLetter, special: defaultSpecial); - - /// A list of valid Friday schedules. - static const List fridays = [ - Special.friday, - Special.winterFriday, - Special.fridayRoshChodesh, - Special.winterFridayRoshChodesh - ]; + late Day defaultDay; /// The schedule data model. /// /// Used to retrieve the schedule for a given day. - final Schedule schedule; + final ScheduleModel dataModel; /// The day whose schedule is being shown in the UI. - Day day; + late Day day; /// The selected date from the calendar. /// @@ -42,85 +40,58 @@ class ScheduleModel with ChangeNotifier { /// /// Also initializes the default day shown to the user. /// If today is a school day, then use that. Otherwise, use the - /// defaults (see [defaultLetter] and [defaultSpecial]). - ScheduleModel () : - schedule = Models.schedule - { - day = schedule.hasSchool - ? schedule.today - : defaultDay; + /// defaults (see [defatulSchedule]). + ScheduleViewModel () : dataModel = Models.instance.schedule { + defaultDay = Day( + name: Models.instance.user.data.schedule.keys.first, + schedule: defatulSchedule[Models.instance.user.data.schedule.keys.first]! + ); + day = dataModel.today ?? defaultDay; } /// Attempts to set the UI to the schedule of the given day. /// /// If there is no school on that day, then [ArgumentError] is thrown. - set date (DateTime date) { + set date(DateTime date) { // Get rid of time final DateTime justDate = DateTime.utc ( date.year, date.month, date.day ); - final Day selected = Day.getDate(schedule.calendar, justDate); - if (!selected.school) { + final Day? selected = Day.getDate(dataModel.calendar, justDate); + if (selected == null) { throw Exception("No School"); } day = selected; _selectedDay = justDate; - update (newLetter: selected.letter, newSpecial: selected.special); + notifyListeners(); } /// Gets the date whose schedule the user is looking at DateTime get date => _selectedDay; - /// Updates the UI to a new day given a new letter or special. - /// - /// If the letter is non-null, then the special is automatically determined. - /// See [Day()] for details. + /// Updates the UI to a new day given a new dayName or schedule. /// - /// If the special is non-null, then the letter is automatically determined - /// to avoid setting a Friday schedule to a day that isn't Friday, and vice - /// versa. See [fridays] for Friday schedules. - void update({Letters newLetter, Special newSpecial}) { - Letters letter = day.letter; - Special special = day.special; - if (newLetter != null) { - letter = newLetter; - day = Day(letter: letter, special: null); - notifyListeners(); - } - if (newSpecial != null) { - switch (letter) { - // Cannot set a Friday schedule to a non-Friday - case Letters.A: - case Letters.B: - case Letters.C: - if (newSpecial != Special.regular) { - continue regular; - } - break; - regular: - case Letters.M: - case Letters.R: - if ( - ( - letter != Letters.M && letter != Letters.R || // if it's M or R - newSpecial != Special.rotate // and newSpecial isn't a rotate - ) && - !fridays.contains(newSpecial) // AND newSpecial is not a Friday - ) { - special = newSpecial; - } - break; - // Cannot set a non-Friday schedule to a Friday - case Letters.E: - case Letters.F: - if (fridays.contains (newSpecial)) { - special = newSpecial; - } + /// If the dayName is non-null, the schedule defaults to [defatulSchedule]. + void update({ + String? newName, + Schedule? newSchedule, + void Function()? onInvalidSchedule, + }) { + final String name = newName ?? day.name; + final Schedule schedule = newSchedule ?? day.schedule; + day = Day(name: name, schedule: schedule); + notifyListeners(); + try { + // Just to see if the computation is possible. + // TODO: Move the logic from ClassList here. + Models.instance.schedule.user.getPeriods(day); + } on RangeError { // ignore: avoid_catching_errors + day = Day(name: day.name, schedule: defatulSchedule[day.name]!); + if (onInvalidSchedule != null) { + onInvalidSchedule(); } - day = Day (letter: letter, special: special); - notifyListeners(); } } } diff --git a/lib/src/models/view/schedule_search.dart b/lib/src/models/view/schedule_search.dart new file mode 100644 index 000000000..2aed5d6f2 --- /dev/null +++ b/lib/src/models/view/schedule_search.dart @@ -0,0 +1,43 @@ +import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; + +/// Searches the user's classes and finds on what days and period it meets. +class ScheduleSearchModel { + /// A list of all the courses this user is enrolled in. + late List subjects; + + /// The user's schedule. + late Map> schedule; + + /// Gathers data for searching the user's schedule. + ScheduleSearchModel() { + final ScheduleModel model = Models.instance.schedule; + subjects = model.subjects.values.toList(); + schedule = model.user.schedule; + } + + /// Finds all courses that match the given query. + /// + /// The query parameter should be a part of a course's name, id, or teacher + /// and classes will be matched by searching against + /// [Subject.name], [Subject.id], or [Subject.teacher]. + /// + /// [query] must be a lower-case string. + List getMatchingClasses(String query) => [ + for (final Subject subject in subjects) + if ( + subject.name.toLowerCase().contains(query) + || subject.id.toLowerCase().contains(query) + || subject.teacher.toLowerCase().contains(query) + ) subject + ]; + + + /// Finds the periods when a given course meets. + List getPeriods(Subject subject) => [ + for (final List day in schedule.values) + for (final PeriodData? period in day) + if (period != null && period.id == subject.id) + period + ]; +} diff --git a/lib/src/models/view/sports.dart b/lib/src/models/view/sports.dart index 9e3f1d59f..74b52b180 100644 --- a/lib/src/models/view/sports.dart +++ b/lib/src/models/view/sports.dart @@ -2,7 +2,7 @@ import "package:flutter/foundation.dart"; import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; -import "package:ramaz/services.dart"; +import "package:ramaz/services.dart" hide AsyncCallback; /// Different ways to sort the sports calendar. enum SortOption { @@ -29,20 +29,20 @@ class SportsModel with ChangeNotifier { final Sports data; /// A list of recent games. - List recents; + List recents = []; /// A list of upcoming games. - List upcoming; + List upcoming = []; /// Recent games sorted by sport. /// /// Generated by calling [sortBySport] with [recents]. - Map> recentBySport; + Map> recentBySport = {}; /// Upcoming games sorted by sport. /// /// Generated by calling [sortBySport] with [upcoming]. - Map> upcomingBySport; + Map> upcomingBySport = {}; /// Whether the user is an admin. /// @@ -96,6 +96,12 @@ class SportsModel with ChangeNotifier { /// /// See [Comparator] and [Comparable.compareTo] for how to sort in Dart. int sortByDate(int a, int b) => + data.games [b].dateTime.compareTo(data.games [a].dateTime); + + /// Helper function to sort games for upcoming chronologically. + /// + /// See [Comparator] and [Comparable.compareTo] for how to sort in Dart. + int sortByDateUpcoming(int a, int b) => data.games [a].dateTime.compareTo(data.games [b].dateTime); /// Divides [Sports.games] into [recents] and [upcoming]. @@ -107,7 +113,7 @@ class SportsModel with ChangeNotifier { (entry.value.dateTime.isAfter(now) ? upcoming : recents).add(entry.key); } recents.sort(sortByDate); - upcoming.sort(sortByDate); + upcoming.sort(sortByDateUpcoming); } /// Sorts a list of games by sports. @@ -121,7 +127,7 @@ class SportsModel with ChangeNotifier { if (!result.containsKey(game.sport)) { result [game.sport] = [index]; } else { - result [game.sport].add(index); + result [game.sport]!.add(index); } } for (final List gamesList in result.values) { @@ -144,13 +150,10 @@ class SportsModel with ChangeNotifier { } } - /// Returns an function that shows loading animations while running. - /// - /// The newly-returned function will set [loading] to true, run [func], - /// and then set [loading] to false. It can be used in the widget tree. - AsyncCallback adminFunc(AsyncCallback func) => () async { - loading = true; - await func(); - loading = false; - }; + /// Refreshes the sports data needed for the sports page. + Future refresh() async { + await Services.instance.database.sports.signIn(); + await Models.instance.sports.init(); + setup(); + } } diff --git a/lib/src/pages/admin.dart b/lib/src/pages/admin.dart deleted file mode 100644 index 950d54275..000000000 --- a/lib/src/pages/admin.dart +++ /dev/null @@ -1,165 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:ramaz/constants.dart"; -import "package:ramaz/pages.dart"; -import "package:ramaz/services.dart"; - -/// A widget to represent a calendar icon. -/// -/// Due to "budget cuts", poor Levi Lesches ('21) had to recreate the calendar -/// icon from scratch, instead of Googling for a png. What a loser. -/// -/// This widget is not used, rather left here as a token of appreciation for -/// all the time Levi has wasted designing (and scrapping said designs) -/// the UI and backend code for this app. That dude deserves a raise. -class OldCalendarWidget extends StatelessWidget { - /// Creates a widget to look like the calendar icon. - const OldCalendarWidget(); - - @override - Widget build(BuildContext context) => InkWell( - onTap: () => Navigator.pushReplacementNamed(context, Routes.calendar), - child: Container( - decoration: BoxDecoration(border: Border.all()), - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(flex: 1), - Expanded( - flex: 1, - child: Container( - decoration: BoxDecoration(border: Border.all()), - child: const Center( - child: Text("Monday") - ), - ), - ), - Expanded( - flex: 4, - child: Container( - decoration: BoxDecoration(border: Border.all()), - child: const Center( - child: Text("01", textScaleFactor: 2), - ) - ) - ), - const Spacer(flex: 1), - ] - ) - ) - ); -} - -/// A menu item for the admin console. -/// -/// This widget holds a [label] over a big [icon], and when tapped, will push -/// a page named [routeName]. -class AdminMenuItem extends StatelessWidget { - /// The icon to display. - final IconData icon; - - /// The label to display. - final String label; - - /// The name of the route to push when tapped. - final String routeName; - - /// Creates a menu item for the admin console. - const AdminMenuItem({ - @required this.icon, - @required this.label, - @required this.routeName - }); - - @override - Widget build(BuildContext context) => InkWell( - onTap: () => Navigator.of(context).pushNamed(routeName), - child: Column( - children: [ - const SizedBox(height: 10), - Text(label, textScaleFactor: 1.25), - const SizedBox(height: 25), - Icon(icon, size: 100), - ] - ) - ); -} - -/// The home page for the admin console. -/// -/// This page shows all the options that the user has the scopes to access. -/// It does this using [Auth.claims] and its helper functions. -/// -/// This widget needs to be stateful because the admin scopes are Futures. -class AdminHomePage extends StatefulWidget { - @override - AdminHomePageState createState() => AdminHomePageState(); -} - -/// State for [AdminHomePage]. -/// -/// This state takes care of loading [Auth.isCalendarAdmin] and -/// [Auth.isSportsAdmin] and then displaying the appropriate [AdminMenuItem]s. -class AdminHomePageState extends State { - bool _isCalendarAdmin, _isSportsAdmin; - - @override - void initState() { - super.initState(); - Auth.isCalendarAdmin.then( - (bool value) => setState(() => _isCalendarAdmin = value) - ); - Auth.isSportsAdmin.then( - (bool value) => setState(() => _isSportsAdmin = value) - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - drawer: NavigationDrawer(), - appBar: AppBar( - title: const Text("Admin Console"), - actions: [ - IconButton( - icon: const Icon(Icons.home), - onPressed: () => - Navigator.of(context).pushReplacementNamed(Routes.home), - ) - ] - ), - body: Column( - children: [ - const SizedBox(height: 10), - const Text("Select an option", textScaleFactor: 2), - const SizedBox(height: 25), - Expanded( - child: GridView.count( - childAspectRatio: 0.9, - padding: const EdgeInsets.symmetric(horizontal: 20), - shrinkWrap: true, - crossAxisCount: 2, - children: [ - if (_isCalendarAdmin ?? false) const AdminMenuItem( - icon: Icons.schedule, - label: "Manage schedules", - routeName: Routes.specials, - ), - if (_isCalendarAdmin ?? false) const AdminMenuItem( - icon: Icons.today, - label: "Edit calendar", - routeName: Routes.calendar, - ), - if (_isSportsAdmin ?? false) const AdminMenuItem( - icon: Icons.directions_run, - label: "Manage games", - routeName: Routes.sports, - ) - ] - ) - ) - ] - ), - ); -} diff --git a/lib/src/pages/admin/calendar.dart b/lib/src/pages/admin/calendar.dart new file mode 100644 index 000000000..bafaef544 --- /dev/null +++ b/lib/src/pages/admin/calendar.dart @@ -0,0 +1,185 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; +import "package:ramaz/widgets.dart"; + +/// A widget to represent a calendar icon. +/// +/// Due to "budget cuts", poor Levi Lesches ('21) had to recreate the calendar +/// icon from scratch, instead of Googling for a png. What a loser. +/// +/// This widget is not used, rather left here as a token of appreciation for +/// all the time Levi has wasted designing (and scrapping said designs) +/// the UI and backend code for this app. That dude deserves a raise. +class OldCalendarWidget extends StatelessWidget { + /// Creates a widget to look like the calendar icon. + const OldCalendarWidget(); + + @override + Widget build(BuildContext context) => InkWell( + onTap: () => Navigator.pushReplacementNamed(context, Routes.calendar), + child: Container( + decoration: BoxDecoration(border: Border.all()), + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(flex: 1), + Expanded( + flex: 1, + child: Container( + decoration: BoxDecoration(border: Border.all()), + child: const Center( + child: Text("Monday") + ), + ), + ), + Expanded( + flex: 4, + child: Container( + decoration: BoxDecoration(border: Border.all()), + child: const Center( + child: Text("01", textScaleFactor: 2), + ) + ) + ), + const Spacer(flex: 1), + ] + ) + ) + ); +} + +/// A page for admins to modify the calendar in the database. +class AdminCalendarPage extends StatefulWidget { + /// Creates a page to edit the calendar. + const AdminCalendarPage(); + + @override + AdminCalendarState createState() => AdminCalendarState(); +} + +/// The state for the admin calendar page. +class AdminCalendarState extends ModelListener< + CalendarEditor, AdminCalendarPage +> { + /// The months of the year. + /// + /// These will be the headers of all the months. + static const List months = [ + "January", "February", "March", "April", "May", + "June", "July", "August", "September", "October", + "November", "December" + ]; + + /// The days of the week. + /// + /// This will be used as the labels of the calendar. + static const List weekdays = [ + "Sun", "Mon", "Tue", "Wed", "Thur", "Fri", "Sat" + ]; + + @override + CalendarEditor getModel() => CalendarEditor(); + + int _currentMonth = DateTime.now().month - 1; + + /// The current month being viewed. + /// + /// Changing this will load the month from the database if needed. + int get currentMonth => _currentMonth; + set currentMonth(int value) { + _currentMonth = loopMonth(value); + model.loadMonth(_currentMonth); // will update later + setState(() {}); + } + + /// Allows the user to go from Dec to Jan and vice-versa. + int loopMonth(int val) { + if (val == 12) { + return 0; + } else if (val == -1) { + return 11; + } else { + return val; + } + } + + @override + Widget build(BuildContext context) => ResponsiveScaffold( + drawer: const NavigationDrawer(), + appBar: AppBar(title: const Text("Calendar")), + bodyBuilder: (_) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 24), + Row( + children: [ + const Spacer(flex: 3), + TextButton.icon( + icon: const Icon(Icons.arrow_back), + onPressed: () => currentMonth--, + label: Text(months [loopMonth(currentMonth - 1)]), + ), + const Spacer(flex: 2), + Text( + "${months [currentMonth]} ${model.years [currentMonth]}", + style: Theme.of(context).textTheme.headline4, + ), + const Spacer(flex: 2), + TextButton.icon( + // icon always goes to the left of the label + // since they're both widgets, we can swap them + label: const Icon(Icons.arrow_forward), + icon: Text(months [loopMonth(currentMonth + 1)]), + onPressed: () => currentMonth++, + ), + const Spacer(flex: 3), + ] + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final String weekday in weekdays) + Text(weekday), + ] + ), + Flexible( + child: model.calendar [currentMonth] == null + ? const Center(child: CircularProgressIndicator()) + : GridView.count( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + shrinkWrap: true, + childAspectRatio: 1.25, + crossAxisCount: 7, + children: [ + for (final CalendarDay? day in model.calendar [currentMonth]!) + if (day == null) CalendarTile.blank + else GestureDetector( + onTap: () => showDialog( + context: context, + builder: (_) => DayBuilder( + day: day.schoolDay, + date: day.date, + upload: (Day? value) => model.updateDay( + day: value, + date: day.date + ), + ) + ), + child: CalendarTile(day: day.schoolDay, date: day.date), + ) + ] + ), + ) + ] + ) + ) + ); +} diff --git a/lib/src/pages/admin/schedules.dart b/lib/src/pages/admin/schedules.dart new file mode 100644 index 000000000..f3958f48a --- /dev/null +++ b/lib/src/pages/admin/schedules.dart @@ -0,0 +1,70 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; +import "package:ramaz/widgets.dart"; + +/// A page to show the admin's custom schedules. +class AdminSchedulesPage extends StatefulWidget { + /// Creates a page for admins to manage schedules. + const AdminSchedulesPage(); + + @override + AdminScheduleState createState() => AdminScheduleState(); +} + +/// The state for the scheudules page. +/// +/// Creates and listens to an [AdminScheduleModel]. +class AdminScheduleState extends + ModelListener +{ + @override + AdminScheduleModel getModel() => AdminScheduleModel(); + + // If the user is on this page, they are an admin. + // So, model.admin != null + @override + Widget build(BuildContext context) => ResponsiveScaffold( + appBar: AppBar(title: const Text("Custom schedules")), + drawer: const NavigationDrawer(), + floatingActionButton: FloatingActionButton( + onPressed: () async { + final Schedule? schedule = await ScheduleBuilder.buildSchedule(context); + if (schedule == null) { + return; + } + await model.createSchedule(schedule); + }, + child: const Icon(Icons.add), + ), + bodyBuilder: (_) => Padding( + padding: const EdgeInsets.all(20), + child: model.schedules.isEmpty + ? const Center ( + child: Text ( + "There are no schedules yet. Feel free to add one.", + textScaleFactor: 1.5, + textAlign: TextAlign.center, + ) + ) + : ListView( + children: [ + for (final Schedule schedule in model.schedules) + ListTile( + title: Text(schedule.name), + onTap: () async { + final Schedule? newSchedule = + await ScheduleBuilder.buildSchedule(context, preset: schedule); + if (newSchedule != null) { + await model.replaceSchedule(newSchedule); + } + }, + ) + ] + ) + ) + ); +} + diff --git a/lib/src/pages/builders/day_builder.dart b/lib/src/pages/builders/day_builder.dart index efc659c57..ef5975c73 100644 --- a/lib/src/pages/builders/day_builder.dart +++ b/lib/src/pages/builders/day_builder.dart @@ -4,136 +4,119 @@ import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; import "package:ramaz/widgets.dart"; -import "special_builder.dart"; - /// A widget to guide the admin in modifying a day in the calendar. /// -/// Creates a pop-up that allows the admin to set the [Letters] and [Special] -/// for a given day in the calendar, even providing an option to create a custom -/// [Special]. +/// Creates a pop-up that allows the admin to set the dayName and [Schedule] +/// for a given day in the calendar. /// -/// If [day] is provided, then the fields [DayBuilderModel.letter], -/// [DayBuilderModel.special], are set to `day.letter` ([Day.letter]) and -/// `day.special` ([Day.special]), respectively. -class DayBuilder extends StatelessWidget { - /// Returns the [Day] created by this widget. - static Future getDay({ - @required BuildContext context, - @required DateTime date, - @required Day day, - }) => showDialog( - context: context, - builder: (_) => DayBuilder(date: date, day: day), - ); - - /// The date to modify. +/// If [day] is provided, then the fields [DayBuilderModel.name], +/// [DayBuilderModel.schedule], are set to [Day.name] and [Day.schedule]. +class DayBuilder extends StatefulWidget { + /// The date this widget is modifying. final DateTime date; /// The day to edit, if it already exists. - final Day day; + final Day? day; + + /// A function to upload the created day to the calendar. + final Future Function(Day?) upload; /// Creates a widget to guide the user in creating a [Day] const DayBuilder({ - @required this.date, - @required this.day, + required this.day, + required this.date, + required this.upload }); @override - Widget build (BuildContext context) => ModelListener( - model: () => DayBuilderModel(day), - // ignore: sort_child_properties_last - child: FlatButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), - ), - builder: (_, DayBuilderModel model, Widget cancel) => AlertDialog( - title: const Text("Edit day"), - content: Column ( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Date: ${date.month}/${date.day}"), - const SizedBox(height: 20), - SwitchListTile( - title: const Text("School?"), - value: model.hasSchool, - onChanged: (bool value) => model.hasSchool = value, - ), - Container( - width: double.infinity, - child: Wrap ( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const Text("Letter", textAlign: TextAlign.center), - DropdownButton( - value: model.letter, - hint: const Text("Letter"), - onChanged: !model.hasSchool ? null : - (Letters letter) => model.letter = letter, - items: [ - for (final Letters letter in Letters.values) - DropdownMenuItem( - value: letter, - child: Text (lettersToString [letter]), - ) - ], - ) - ] - ), + DayBuilderState createState() => DayBuilderState(); +} + +/// The state for a [DayBuilder]. +/// +/// Creates a [DayBuilderModel]. +class DayBuilderState extends ModelListener { + @override + DayBuilderModel getModel() => DayBuilderModel( + day: widget.day, + date: widget.date + ); + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text("Edit day"), + content: Column ( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Date: ${widget.date.month}/${widget.date.day}"), + const SizedBox(height: 20), + SwitchListTile( + title: const Text("School?"), + value: model.hasSchool, + onChanged: (bool value) => model.hasSchool = value, + ), + Container( + width: double.infinity, + child: Wrap ( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text("Day", textAlign: TextAlign.center), + DropdownButton( + value: model.name, + hint: const Text("Day"), + onChanged: !model.hasSchool ? null : + (String? value) => model.name = value, + items: [ + for (final String dayName in Models.instance.schedule.user.dayNames) + DropdownMenuItem( + value: dayName, + child: Text(dayName), + ) + ], + ) + ] ), - const SizedBox(height: 20), - Container( - width: double.infinity, - child: Wrap ( - runSpacing: 3, - children: [ - const Text("Schedule"), - DropdownButton( - value: - (model.presetSpecials + model.userSpecials).contains(model.special) - ? model.special : null, - hint: const Text("Schedule"), - onChanged: !model.hasSchool ? null : - (Special special) async { - if (special.name == null && special.periods == null) { - special = await SpecialBuilder.buildSpecial(context); - } - model.special = special; - }, - items: [ - for ( - final Special special in - model.presetSpecials + model.userSpecials - ) DropdownMenuItem( - value: special, - child: Text(special.name), + ), + const SizedBox(height: 20), + Container( + width: double.infinity, + child: Wrap ( + runSpacing: 3, + children: [ + const Text("Schedule"), + DropdownButton( + value: model.schedule?.name, + hint: const Text("Schedule"), + onChanged: !model.hasSchool ? null : (String? value) => + model.schedule = Schedule.schedules.firstWhere( + (Schedule schedule) => schedule.name == value + ), + items: [ + for (final Schedule schedule in Schedule.schedules) + DropdownMenuItem( + value: schedule.name, + child: Text(schedule.name), ), - DropdownMenuItem( - value: const Special(null, null), - child: SizedBox( - child: Row( - children: const [ - Text("Make new schedule"), - Icon(Icons.add_circle_outline) - ] - ) - ) - ) - ], - ) - ] - ) + ], + ) + ] ) - ] - ), - actions: [ - cancel, - RaisedButton( - onPressed: !model.ready ? null : () => - Navigator.of(context).pop(model.day), - child: const Text("Save", style: TextStyle(color: Colors.white)), ) ] ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: !model.ready ? null : () async { + await widget.upload(model.day); + Navigator.of(context).pop(); + }, + child: const Text("Save", style: TextStyle(color: Colors.white)), + ) + ] ); } diff --git a/lib/src/pages/builders/reminder_builder.dart b/lib/src/pages/builders/reminder_builder.dart index e33b73b59..46416db52 100644 --- a/lib/src/pages/builders/reminder_builder.dart +++ b/lib/src/pages/builders/reminder_builder.dart @@ -9,9 +9,9 @@ import "package:ramaz/widgets.dart"; /// This widget must be a [StatefulWidget] in order to avoid recreating a /// [TextEditingController] every time the widget tree is rebuilt. class ReminderBuilder extends StatefulWidget { - static void _noop(){} - static final Color _disabledColor = const RaisedButton(onPressed: _noop) - .disabledTextColor; + // static void _noop(){} + // static final Color _disabledColor = const RaisedButton(onPressed: _noop) + // .disabledTextColor; /// Trims a string down to a certain length. /// @@ -20,49 +20,22 @@ class ReminderBuilder extends StatefulWidget { static String trimString (String text, int length) => text.length > length ? text.substring(0, length) : text; - /// Gets the text color for a [MaterialButton]. - /// - /// Due to a bug in Flutter v1.9, [RaisedButton]s in [ButtonBar]s in - /// [AlertDialog]s do not respect [MaterialApp.theme.buttonTheme.textTheme]. - /// This function creates a new [RaisedButton] (outside of an [AlertDialog]) - /// and returns its text color. - static Color getButtonTextColor( - BuildContext context, - Brightness brightness, - {bool enabled} - ) { - if (!enabled) { - return _disabledColor; - } - switch (Theme.of(context).buttonTheme.textTheme) { - case ButtonTextTheme.normal: return brightness == Brightness.dark - ? Colors.white : Colors.black87; - case ButtonTextTheme.accent: return Theme.of(context).accentColor; - case ButtonTextTheme.primary: return Theme.of(context).primaryColor; - default: throw ArgumentError.notNull( - "MaterialApp.theme.buttonTheme.textTheme" - ); - } - } - /// Opens a [ReminderBuilder] pop-up to create or modify a [Reminder]. - static Future buildReminder( - BuildContext context, [Reminder reminder] - ) => showDialog( + static Future buildReminder( + BuildContext context, [Reminder? reminder] + ) => showDialog( context: context, - builder: (_) => ReminderBuilder(reminder: reminder), + builder: (_) => ReminderBuilder(reminder), ); /// A reminder to modify. /// /// A [ReminderBuilder] can either create a new [Reminder] from scratch or /// modify an existing reminder (auto-fill its properties). - final Reminder reminder; + final Reminder? reminder; /// Creates a widget to create or modify a [Reminder]. - const ReminderBuilder({ - this.reminder - }); + const ReminderBuilder(this.reminder); @override ReminderBuilderState createState() => ReminderBuilderState(); @@ -72,145 +45,137 @@ class ReminderBuilder extends StatefulWidget { /// /// [State.initState] is needed to instantiate a [TextEditingController] /// exactly once. -class ReminderBuilderState extends State { +class ReminderBuilderState extends ModelListener< + RemindersBuilderModel, ReminderBuilder +> { /// The text controller to hold the message of the [Reminder]. final TextEditingController controller = TextEditingController(); + @override + RemindersBuilderModel getModel() => RemindersBuilderModel(widget.reminder); + @override void initState() { super.initState(); - controller.text = widget.reminder?.message; + controller.text = widget.reminder?.message ?? ""; } @override - Widget build(BuildContext context) => ModelListener( - model: () => RemindersBuilderModel(widget.reminder), - // ignore: sort_child_properties_last - child: FlatButton( - onPressed: Navigator.of(context).pop, - child: Text ( - "Cancel", - style: TextStyle ( - color: ReminderBuilder.getButtonTextColor( - context, - Theme.of(context).brightness, - enabled: true - ), - ) + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text (widget.reminder == null ? "Create reminder" : "Edit reminder"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), ), - ), - builder: (BuildContext context, RemindersBuilderModel model, Widget back) => - AlertDialog( - title: Text (widget.reminder == null ? "Create reminder" : "Edit reminder"), - actions: [ - back, - RaisedButton( - color: Theme.of(context).buttonColor, - onPressed: model.ready - ? () => Navigator.of(context).pop(model.build()) - : null, - child: Text ( - "Save", - style: TextStyle ( - color: ReminderBuilder.getButtonTextColor( - context, - ThemeData.estimateBrightnessForColor( - Theme.of(context).buttonColor + ElevatedButton( + onPressed: model.ready + ? () => Navigator.of(context).pop(model.build()) + : null, + child: const Text("Save"), + ) + ], + content: Column ( + mainAxisSize: MainAxisSize.min, + children: [ + TextField ( + controller: controller, + onChanged: model.onMessageChanged, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox (height: 20), + RadioListTile ( + value: ReminderTimeType.period, + groupValue: model.type, + // if toggleable is false (default), the value can never be null + onChanged: (value) => model.toggleRepeatType(value!), + title: Text ( + "${model.shouldRepeat ? 'Repeats every' : 'On'} period" + ), + ), + RadioListTile ( + value: ReminderTimeType.subject, + groupValue: model.type, + // if toggleable is false (default), the value can never be null + onChanged: (value) => model.toggleRepeatType(value!), + title: Text ( + "${model.shouldRepeat ? 'Repeats every' : 'On'} subject" + ), + ), + const SizedBox (height: 20), + if (model.type == ReminderTimeType.period) ...[ + ListTile ( + title: const Text ("Day"), + trailing: DropdownButton( + items: [ + for (final String dayName in Models.instance.schedule.user.dayNames) + DropdownMenuItem( + value: dayName, + child: Text(dayName), ), - enabled: model.ready, - ), - ) + ], + onChanged: (String? value) { + if (value != null) { + model.changeDay(value); + } + }, + value: model.dayName, + hint: const Text("Day"), ), - ) - ], - content: SingleChildScrollView( - child: Column ( - mainAxisSize: MainAxisSize.min, - children: [ - TextField ( - controller: controller, - onChanged: model.onMessageChanged, - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox (height: 20), - Wrap( - children: [ - RadioListTile ( - value: ReminderTimeType.period, - groupValue: model.type, - onChanged: model.toggleRepeatType, - title: Text ( - "${model.shouldRepeat ? 'Repeats every' : 'On'} period" - ), - ), - RadioListTile ( - value: ReminderTimeType.subject, - groupValue: model.type, - onChanged: model.toggleRepeatType, - title: Text ( - "${model.shouldRepeat ? 'Repeats every' : 'On'} subject" - ), - ), - ] - ), - const SizedBox (height: 20), - if (model.type == ReminderTimeType.period) ...[ - ListTile ( - title: const Text ("Letter day"), - trailing: DropdownButton( - items: [ - for (final Letters letter in Letters.values) - DropdownMenuItem( - value: letter, - child: Text (lettersToString [letter]), - ), - ], - onChanged: model.changeLetter, - value: model.letter, - hint: const Text ("Letter"), - ), - ), - ListTile ( - title: const Text ("Period"), - trailing: DropdownButton ( - items: [ - for (final String period in model.periods ?? []) - DropdownMenuItem( - value: period, - child: Text (period), - ) - ], - onChanged: model.changePeriod, - value: model.period, - hint: const Text ("Period"), - ) - ) - ] else if (model.type == ReminderTimeType.subject) - ListTile ( - title: const Text ("Class"), - trailing: DropdownButton( - items: [ - for (final String course in model.courses) - DropdownMenuItem( - value: course, - child: Text("${ReminderBuilder.trimString(course, 14)}..."), - ) - ], - onChanged: model.changeCourse, - value: model.course, - isDense: true, - hint: const Text ("Class"), + ), + ListTile ( + title: const Text ("Period"), + trailing: DropdownButton ( + items: [ + for (final String period in model.periods ?? []) + DropdownMenuItem( + value: period, + child: Text (period), ) - ), - SwitchListTile ( - value: model.shouldRepeat, - onChanged: model.toggleRepeat, - title: const Text ("Repeat"), - secondary: const Icon (Icons.repeat), - ), - ] + ], + onChanged: (String? value) { + if (value != null) { + model.changePeriod(value); + } + }, + value: model.period, + hint: const Text ("Period"), + ) ) - ) - ) + ] else if (model.type == ReminderTimeType.subject) + ListTile ( + title: const Text ("Class"), + trailing: DropdownButton( + items: [ + for (final String course in model.courses) + DropdownMenuItem( + value: course, + child: Text("${ReminderBuilder.trimString(course, 14)}..."), + ) + ], + onChanged: (String? value) { + if (value != null) { + model.changeCourse(value); + } + }, + value: model.course, + isDense: true, + hint: const Text ("Class"), + ) + ), + SwitchListTile ( + value: model.shouldRepeat, + onChanged: model.toggleRepeat, + title: const Text ("Repeat"), + secondary: const Icon (Icons.repeat), + ), + ] + ) ); } \ No newline at end of file diff --git a/lib/src/pages/builders/schedule_builder.dart b/lib/src/pages/builders/schedule_builder.dart new file mode 100644 index 000000000..63dca634a --- /dev/null +++ b/lib/src/pages/builders/schedule_builder.dart @@ -0,0 +1,243 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; +import "package:ramaz/widgets.dart"; + +import "../drawer.dart"; + +/// Allows the user to input a small integer. +/// +/// Shows a number with + and - buttons nearby. +class IntegerInput extends StatelessWidget { + /// The value being represented. + final int value; + + /// Callback for when the + button is pressed. + final VoidCallback onAdd; + + /// Callback for when the - button is pressed. + final VoidCallback onRemove; + + /// Creates a small integer input widget. + const IntegerInput({ + required this.value, + required this.onAdd, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + const SizedBox(width: 16), + const Text("Periods: "), + const Spacer(), + TextButton( + onPressed: onRemove, + child: const Icon(Icons.remove), + ), + Text(value.toString()), + TextButton( + onPressed: onAdd, + child: const Icon(Icons.add), + ), + const SizedBox(width: 16), + ] + ); +} + +/// A page to create a [Schedule]. +/// +/// This uses [ScheduleBuilderModel] to create a schedule period by period. +/// You can also pass in another schedule to quickly preset all the periods. +/// This allows admins to quickly make small changes to existing schedules. +class ScheduleBuilder extends StatefulWidget { + /// Opens the schedule builder and saves the result. + /// + /// If you pass a schedule, the builder will configure itself to match it. + /// This allows admins to make small changes quickly. + static Future buildSchedule( + BuildContext context, + {Schedule? preset} + ) => Navigator + .of(context) + .push( + PageRouteBuilder( + transitionDuration: Duration.zero, + pageBuilder: (_, __, ___) => ScheduleBuilder(preset: preset)) + ); + + /// The schedule to work off. + final Schedule? preset; + + /// Creates a schedule builder, with an optional preset. + const ScheduleBuilder({this.preset}); + + @override + ScheduleBuilderState createState() => ScheduleBuilderState(); +} + +/// The state for the schedule builder. +class ScheduleBuilderState extends State { + /// The model that represents the data. + late ScheduleBuilderModel model; + + /// The text controller for the name of the schedule. + late TextEditingController nameController; + + /// Triggers whenever the underlying data updates. + void listener() => setState(() {}); + + @override + void initState() { + model = ScheduleBuilderModel() + ..usePreset(widget.preset, includeName: true) + ..addListener(listener); + nameController = TextEditingController(text: widget.preset?.name ?? ""); + super.initState(); + } + + @override + void dispose() { + model + ..removeListener(listener) + ..dispose(); + nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ResponsiveScaffold( + drawer: const NavigationDrawer(), + appBar: AppBar(title: const Text("Create schedule")), + bodyBuilder: (_) => Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(hintText: "Name of schedule"), + onChanged: (String value) => model.name = value, + ), + const SizedBox(height: 16), + IntegerInput( + value: model.periods.length, + onAdd: model.addPeriod, + onRemove: model.removePeriod, + ), + const SizedBox(height: 24), + DataTable( + columns: const [ + DataColumn(label: Text("Name")), + DataColumn(label: Text("Start")), + DataColumn(label: Text("End")), + DataColumn(label: Text("Class?")), + ], + rows: [ + for (final EditablePeriod period in model.periods) DataRow( + cells: [ + DataCell( + TextField( + controller: period.controller, + onChanged: (_) => setState(() {}), + ), + showEditIcon: true, + ), + DataCell( + Text( + period.start?.format(context) ?? "Start time", + style: !period.hasInvalidTime ? null : TextStyle( + color: Theme.of(context).colorScheme.error + ), + ), + placeholder: period.start == null, + showEditIcon: true, + onTap: () async { + period.start = (await editTime(period.start)) + ?? period.start; + setState(() {}); + } + ), + DataCell( + Text( + period.end?.format(context) ?? "End time", + style: !period.hasInvalidTime ? null : TextStyle( + color: Theme.of(context).colorScheme.error + ), + ), + placeholder: period.end == null, + showEditIcon: true, + onTap: () async { + period.end = (await editTime(period.end)) + ?? period.end; + setState(() {}); + } + ), + DataCell( + int.tryParse(period.name) == null ? Container() + : const Icon(Icons.check, color: Colors.green), + ) + ] + ), + ] + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const SizedBox(width: 16), + TextButton( + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => ListView( + children: [ + for (final Schedule schedule in Schedule.schedules) + ListTile( + title: Text(schedule.name), + subtitle: Text("${schedule.periods.length} periods"), + onTap: () { + model.usePreset(schedule); + Navigator.of(context).pop(); + } + ) + ] + ) + ), + child: const Text("Build off another schedule"), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: !model.isReady ? null : + () => Navigator.of(context).pop(model.schedule), + child: const Text("Save"), + ), + const SizedBox(width: 16), + ] + ), + const SizedBox(height: 16), + ] + ) + ); + + /// Picks a new time for a period. + /// + /// On desktop, this shows the keyboard-friendly UI. On all other devices, + /// the touch-friendly UI is the default. + Future editTime([TimeOfDay? initialTime]) { + final LayoutInfo layout = LayoutInfo(context); + return showTimePicker( + context: context, + initialTime: initialTime ?? TimeOfDay.now(), + initialEntryMode: layout.isDesktop + ? TimePickerEntryMode.input : TimePickerEntryMode.dial, + ); + } +} diff --git a/lib/src/pages/builders/special_builder.dart b/lib/src/pages/builders/special_builder.dart deleted file mode 100644 index a743b44e8..000000000 --- a/lib/src/pages/builders/special_builder.dart +++ /dev/null @@ -1,199 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:ramaz/data.dart"; -import "package:ramaz/models.dart"; -import "package:ramaz/widgets.dart"; - -/// A widget to guide the admin in creating a [Special]. -/// -/// The [Special] doesn't have to be created from scratch, it can be based on -/// an existing [Special] by passing it as a parameter to [SpecialBuilder()]. -class SpecialBuilder extends StatefulWidget { - /// Returns the [Special] created by this widget. - static Future buildSpecial( - BuildContext context, - [Special preset] - ) => showDialog( - context: context, - builder: (_) => SpecialBuilder(preset: preset), - ); - - /// The [Special] to base this [Special] on. - final Special preset; - - /// Creates a widget to guide the user in creating a [Special]. - const SpecialBuilder({this.preset}); - - @override - SpecialBuilderState createState() => SpecialBuilderState(); -} - -/// A state for a [SpecialBuilder]. -/// -/// A state is needed to keep the [TextEditingController] from rebuilding. -class SpecialBuilderState extends State { - /// A controller to hold the name of the [Special]. - /// - /// This will be preset with the name of [SpecialBuilder.preset]. - final TextEditingController controller = TextEditingController(); - - /// If the name of the schedule conflicts with another one. - /// - /// Names of custom schedules cannot conflict with the default schedules, since - /// users will be confused when the app displays a familiar schedule name, but - /// updates at unusual times. - bool conflicting = false; - - @override - void initState() { - super.initState(); - controller.text = widget.preset?.name; - } - - @override - Widget build(BuildContext context) => ModelListener( - model: () => SpecialBuilderModel()..usePreset(widget.preset), - builder: (_, SpecialBuilderModel model, Widget cancel) => Scaffold( - appBar: AppBar( - title: const Text("Make new schedule"), - actions: [ - IconButton( - icon: const Icon(Icons.sync), - tooltip: "Use preset", - onPressed: () async { - final Special special = await showModalBottomSheet( - context: context, - builder: (BuildContext context) => ListView( - children: [ - const SizedBox( - width: double.infinity, - height: 60, - child: Center( - child: Text("Use a preset", textScaleFactor: 1.5), - ), - ), - for (final Special special in Special.specials) - ListTile( - title: Text (special.name), - onTap: () => Navigator.of(context).pop(special), - ), - const Divider(), - for ( - final Special special in - Models.admin.user.specials - ) ListTile( - title: Text (special.name), - onTap: () => Navigator.of(context).pop(special), - ), - ] - ) - ); - controller.text = special.name; - model.usePreset(special); - } - ), - ] - ), - floatingActionButton: FloatingActionButton.extended( - label: const Text("Save"), - icon: const Icon(Icons.done), - onPressed: !model.ready ? null : - () => Navigator.of(context).pop(model.special), - backgroundColor: model.ready - ? Theme.of(context).accentColor - : Theme.of(context).disabledColor, - ), - body: ListView( - padding: const EdgeInsets.all(15), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( - controller: controller, - onChanged: (String value) { - conflicting = Special.specials.any( - (Special special) => special.name == value - ); - model.name = value; - }, - textInputAction: TextInputAction.done, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - labelText: "Name", - hintText: "Name of the schedule", - errorText: conflicting - ? "Name cannot match an existing schedule's name" - : null, - ), - ), - ), - const SizedBox(height: 20), - ListTile( - title: const Text("Homeroom"), - trailing: DropdownButton( - value: model.homeroom, - onChanged: (int index) => model.homeroom = index, - items: [ - const DropdownMenuItem( - value: null, - child: Text("None") - ), - for (int index = 0; index < model.numPeriods; index++) - DropdownMenuItem( - value: index, - child: Text ("${index + 1}"), - ) - ] - ) - ), - ListTile( - title: const Text("Mincha"), - trailing: DropdownButton( - value: model.mincha, - onChanged: (int index) => model.mincha = index, - items: [ - const DropdownMenuItem( - value: null, - child: Text("None") - ), - for (int index = 0; index < model.numPeriods; index++) - DropdownMenuItem( - value: index, - child: Text ("${index + 1}"), - ) - ] - ) - ), - const SizedBox(height: 20), - for (int index = 0; index < model.numPeriods; index++) - PeriodTile( - model: model, - range: model.times [index], - index: index, - ), - Row( - children: [ - FlatButton.icon( - icon: const Icon (Icons.add), - label: const Text("Add"), - onPressed: () => model.numPeriods++, - ), - if (model.numPeriods > 0) - FlatButton.icon( - icon: const Icon(Icons.remove), - label: const Text("Remove"), - onPressed: () => model.numPeriods-- - ), - ] - ), - if (model.numPeriods == 0) - const Text( - "You can also select a preset by clicking the button on top", - textScaleFactor: 1.5, - textAlign: TextAlign.center, - ), - ] - ) - ) - ); -} diff --git a/lib/src/pages/builders/sports_builder.dart b/lib/src/pages/builders/sports_builder.dart index c225be0c7..f2d6ac93a 100644 --- a/lib/src/pages/builders/sports_builder.dart +++ b/lib/src/pages/builders/sports_builder.dart @@ -35,18 +35,15 @@ class FormRow extends StatelessWidget { /// displayed in a [Text] widget. Both widgets, when tapped, call /// [setNewValue]. FormRow.editable({ - @required this.title, - @required String value, - @required VoidCallback setNewValue, - @required IconData whenNull, + required this.title, + required VoidCallback setNewValue, + required IconData whenNull, + String? value, }) : sized = false, moreSpace = true, picker = value == null - ? IconButton( - icon: Icon(whenNull), - onPressed: setNewValue - ) + ? IconButton(icon: Icon(whenNull), onPressed: setNewValue) : InkWell( onTap: setNewValue, child: Text( @@ -64,16 +61,13 @@ class FormRow extends StatelessWidget { Text(title), const Spacer(), if (sized) Container( - constraints: const BoxConstraints( - maxWidth: 200, - maxHeight: 75, - ), + constraints: const BoxConstraints(maxWidth: 200, maxHeight: 75), child: picker, ) else picker ] ), - SizedBox(height: moreSpace ? 25 : 15), + const SizedBox(height: 25), ] ); } @@ -85,9 +79,9 @@ class FormRow extends StatelessWidget { /// managed by the view model. class SportsBuilder extends StatefulWidget { /// Opens a form for the user to - static Future createGame( + static Future createGame( BuildContext context, - [SportsGame parent] + [SportsGame? parent] ) => Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) => SportsBuilder(parent), @@ -95,7 +89,7 @@ class SportsBuilder extends StatefulWidget { ); /// Fills all the properties on this page with the properties of this game. - final SportsGame parent; + final SportsGame? parent; /// Creates a page to build a [SportsGame]. /// @@ -110,139 +104,163 @@ class SportsBuilder extends StatefulWidget { /// A state for [SportsBuilder]. /// /// This state keeps [TextEditingController]s intact. -class SportBuilderState extends State { +class SportBuilderState extends ModelListener< + SportsBuilderModel, SportsBuilder +> { /// A controller to hold [SportsBuilder.parent]'s team name. final TextEditingController teamController = TextEditingController(); /// A controller to hold [SportsBuilder.parent]'s opponent. final TextEditingController opponentController = TextEditingController(); + /// A controller to hold [SportsBuilder.parent]'s livestreaming URL. + final TextEditingController livestreamUrlController = TextEditingController(); + + @override + SportsBuilderModel getModel() => SportsBuilderModel(widget.parent); + @override void initState() { - teamController.text = widget.parent?.team; - opponentController.text = widget.parent?.opponent; + teamController.text = widget.parent?.team ?? ""; + opponentController.text = widget.parent?.opponent ?? ""; + livestreamUrlController.text = widget.parent?.livestreamUrl ?? ""; super.initState(); } @override - Widget build(BuildContext context) => ModelListener( - model: () => SportsBuilderModel(widget.parent), - builder: (_, SportsBuilderModel model, __) => Scaffold( - appBar: AppBar(title: const Text("Add game")), - bottomSheet: !model.loading ? null : Container( - height: 60, - padding: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [Text("Saving..."), CircularProgressIndicator()] - ) - ), - body: ListView( - padding: const EdgeInsets.all(20), - children: [ - FormRow( - "Sport", - DropdownButtonFormField( - hint: const Text("Choose a sport"), - value: model.sport, - onChanged: (Sport value) => model.sport = value, - items: [ - for (final Sport sport in Sport.values) - DropdownMenuItem( - value: sport, - child: Text(SportsGame.capitalize(sport)) - ) - ], - ), - sized: true, - ), - FormRow( - "Team", - TextField( - onChanged: (String value) => model.team = value, - textCapitalization: TextCapitalization.words, - controller: teamController, - ), - sized: true, + void dispose() { + teamController.dispose(); + opponentController.dispose(); + livestreamUrlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("Add game")), + bottomSheet: !model.loading ? null : Container( + height: 60, + padding: const EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [Text("Saving..."), CircularProgressIndicator()] + ) + ), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + FormRow( + "Sport", + DropdownButtonFormField( + hint: const Text("Choose a sport"), + value: model.sport, + onChanged: (Sport? value) => model.sport = value, + items: [ + for (final Sport sport in Sport.values) + DropdownMenuItem( + value: sport, + child: Text(SportsGame.capitalize(sport)) + ) + ], ), - FormRow( - "Opponent", - TextField( - onChanged: (String value) => model.opponent = value, - textCapitalization: TextCapitalization.words, - controller: opponentController, - ), - sized: true, + sized: true, + ), + FormRow( + "Team", + TextField( + onChanged: (String value) => model.team = value, + textCapitalization: TextCapitalization.words, + controller: teamController, ), - FormRow( - "Away game", - Checkbox( - value: model.away, - onChanged: (bool value) => model.away = value, - ), + sized: true, + ), + FormRow( + "Opponent", + TextField( + onChanged: (String value) => model.opponent = value, + textCapitalization: TextCapitalization.words, + controller: opponentController, ), - FormRow.editable( - title: "Date", - value: SportsTile.formatDate(model.date, noNull: true), - whenNull: Icons.date_range, - setNewValue: () async => model.date = await pickDate( - initialDate: DateTime.now(), - context: context - ), + sized: true, + ), + FormRow( + "Away game", + Checkbox( + value: model.away, + // If tristate == false (default), value never be null + onChanged: (bool? value) => model.away = value!, ), - FormRow.editable( - title: "Start time", - value: model.start?.format(context), - whenNull: Icons.access_time, - setNewValue: () async => model.start = await showTimePicker( - context: context, - initialTime: model.start ?? TimeOfDay.now(), - ), + ), + if (model.away) FormRow( + "Link to livestream", + TextField( + onChanged: (String value) => model.livestreamUrl = value, + controller: livestreamUrlController, ), - FormRow.editable( - title: "End time", - value: model.end?.format(context), - whenNull: Icons.access_time, - setNewValue: () async => model.end = await showTimePicker( - context: context, - initialTime: model.end ?? TimeOfDay.now(), - ), + sized: true, + ), + FormRow.editable( + title: "Date", + value: SportsTile.formatDate(model.date), + whenNull: Icons.date_range, + setNewValue: () async => model.date = await pickDate( + initialDate: DateTime.now(), + context: context ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Tap on the card to change the scores", - textScaleFactor: 0.9 - ), - FlatButton( - onPressed: () => model.scores = null, - child: const Text("Clear"), - ) - ] + ), + FormRow.editable( + title: "Start time", + value: model.start?.format(context), + whenNull: Icons.access_time, + setNewValue: () async => model.start = await showTimePicker( + context: context, + initialTime: model.start ?? TimeOfDay.now(), ), - const SizedBox(height: 20), - SportsTile( - model.game, - onTap: () async => model.scores = - await SportsScoreUpdater.updateScores(context, model.game) ?? model.scores + ), + FormRow.editable( + title: "End time", + value: model.end?.format(context), + whenNull: Icons.access_time, + setNewValue: () async => model.end = await showTimePicker( + context: context, + initialTime: model.end ?? TimeOfDay.now(), ), - ButtonBar( - children: [ - FlatButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), - ), - RaisedButton( - onPressed: !model.ready ? null : - () => Navigator.of(context).pop(model.game), - child: const Text("Save"), - ) - ] - ) - ] - ) + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Tap on the card to change the scores", + textScaleFactor: 0.9 + ), + TextButton( + onPressed: () => model.scores = null, + child: const Text("Clear"), + ) + ] + ), + const SizedBox(height: 20), + if (model.ready) SportsTile( + model.game, + onTap: () async => model.scores = + await SportsScoreUpdater.updateScores(context, model.game) + ?? model.scores + ), + ButtonBar( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: !model.ready ? null : + () => Navigator.of(context).pop(model.game), + child: const Text("Save"), + ) + ] + ) + ] ) ); } diff --git a/lib/src/pages/calendar.dart b/lib/src/pages/calendar.dart deleted file mode 100644 index 31cfb71d4..000000000 --- a/lib/src/pages/calendar.dart +++ /dev/null @@ -1,84 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:ramaz/data.dart"; -import "package:ramaz/models.dart"; -import "package:ramaz/pages.dart"; -import "package:ramaz/widgets.dart"; - -/// A page for admins to modify the calendar in the database. -class CalendarPage extends StatelessWidget { - /// The months of the year. - /// - /// These will be the headers of all the months. - static const List months = [ - "January", "February", "March", "April", "May", - "June", "July", "August", "September", "October", - "November", "December" - ]; - - /// The days of the week. - /// - /// This will be used as the labels of the calendar. - static const List weekdays = [ - "Sun", "Mon", "Tue", "Wed", "Thur", "Fri", "Sat" - ]; - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("Calendar")), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: ModelListener( - model: () => CalendarModel(), - builder: (_, CalendarModel model, __) => ExpansionPanelList.radio( - children: [ - for (int month = 0; month < 12; month++) - ExpansionPanelRadio( - value: month, - canTapOnHeader: true, - headerBuilder: (_, __) => ListTile( - title: Text(months [month]), - trailing: Text(model.years [month].toString()) - ), - body: model.calendar [month] == null - ? const CircularProgressIndicator() - : SizedBox( - height: 400, - child: GridView.count( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - crossAxisCount: 7, - children: [ - for (final String weekday in weekdays) - Center(child: Text (weekday)), - for (int _ = 0; _ < (model.paddings [month] ?? [0]) [0]; _++) - CalendarTile.blank, - for ( - final MapEntry entry in - model.calendar [month].asMap().entries - ) GestureDetector( - onTap: () async => model.updateDay( - DateTime(model.years [month], month + 1, entry.key + 1), - await DayBuilder.getDay( - context: context, - date: DateTime(model.years [month], month + 1, entry.key + 1), - day: entry.value, - ) - ), - child: CalendarTile( - date: entry?.key, - day: entry?.value, - ) - ), - for (int _ = 0; _ < (model.paddings [month] ?? [0]) [1]; _++) - CalendarTile.blank, - ] - ) - ) - ) - ] - ) - ) - ) - ); -} diff --git a/lib/src/pages/credits.dart b/lib/src/pages/credits.dart new file mode 100644 index 000000000..40085aed5 --- /dev/null +++ b/lib/src/pages/credits.dart @@ -0,0 +1,174 @@ +import "package:flutter/material.dart"; +import "package:url_launcher/url_launcher.dart"; + +// ignore: directives_ordering +import "package:ramaz/data.dart"; +import "package:ramaz/widgets.dart"; + +import "drawer.dart"; + +/// The Credits Page with a [ResponsiveContributorCard] for each contributor. +class CreditsPage extends StatelessWidget { + /// Builds the Credits page. + const CreditsPage(); + + @override + Widget build (BuildContext context) => ResponsiveScaffold( + drawer: const NavigationDrawer(), + appBar: AppBar(title: const Text ("Credits")), + bodyBuilder: (_) => ListView( + children: [ + const SizedBox(height: 8), + Text( + "Thank You", + style: Theme.of(context).textTheme.headline4, + textAlign: TextAlign.center, + ), + Text( + "To those who made this app possible", + style: Theme.of(context).textTheme.headline5, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + for (final Contributor contributor in Contributor.contributors) + ResponsiveContributorCard(contributor) + ] + ) + ); +} + +/// A class that shows info about each contributor. +/// +/// This widget shows a [CompactContributorCard] for small screens +/// and [WideContributorCard] on larger screens. +class ResponsiveContributorCard extends StatelessWidget{ + /// The contributor this widget represents. + final Contributor contributor; + + /// Creates a widget to represent a [Contributor]. + const ResponsiveContributorCard(this.contributor); + + @override + Widget build(BuildContext context) => Card( + // vertical is 4 so that cards are separated by 8 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + elevation: 4, + child: ResponsiveBuilder( + builder: (_, LayoutInfo layout, __) => layout.isMobile + ? CompactContributorCard(contributor) + : WideContributorCard(contributor), + ) + ); +} + +/// A wide variant of the contributor card. +class WideContributorCard extends StatelessWidget { + /// The height of the card. + /// + /// This widget is made of rows and columns instead of a ListTile. As such, we + /// have to manually specify the height, or else it will be unbounded. + static const double height = 150; + + /// The contributor shown by this card. + final Contributor contributor; + + /// Creates a wide contributor card. + const WideContributorCard(this.contributor); + + @override + Widget build(BuildContext context) => Container( + height: height, + padding: const EdgeInsets.fromLTRB(16, 16, 8, 16), + child: Row( + children: [ + Flexible( + flex: 1, + child: AspectRatio( + aspectRatio: 1, + child: Image.asset(contributor.imageName), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${contributor.name} ${contributor.gradYear}", + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox(height: 4), + Text( + contributor.title, + style: Theme.of(context).textTheme.bodyText2, + textScaleFactor: 1.1, + ), + const SizedBox(height: 4), + InkWell( + onTap: () => launch(contributor.url), + child: Text( + contributor.linkName, + textScaleFactor: 1.1, + style: Theme.of(context).textTheme.caption!.copyWith( + color: Colors.blue.withAlpha(200), + ), + ), + ), + ] + ), + ), + const Spacer(), + Expanded( + flex: 3, + child: Text( + contributor.description, + style: Theme.of(context).textTheme.subtitle1 + ) + ), + const Spacer(), + ] + ) + ); +} + +/// A compact variant of the contributor card. +class CompactContributorCard extends StatelessWidget { + /// The contributor being represented. + final Contributor contributor; + + /// Creates a compact contributor card. + const CompactContributorCard(this.contributor); + + @override + Widget build(BuildContext context) => Column( + children: [ + ListTile( + visualDensity: const VisualDensity(vertical: 3), + title: Text("${contributor.name} ${contributor.gradYear}"), + leading: CircleAvatar( + radius: 48, // a standard Material radius + backgroundImage: AssetImage(contributor.imageName), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(contributor.title), + InkWell( + onTap: () => launch(contributor.url), + child: Text( + contributor.linkName, + style: TextStyle(color: Colors.blue.withAlpha(200)), + ), + ), + ] + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Text(contributor.description), + ), + ] + ); +} diff --git a/lib/src/pages/dashboard.dart b/lib/src/pages/dashboard.dart new file mode 100644 index 000000000..50094ab9c --- /dev/null +++ b/lib/src/pages/dashboard.dart @@ -0,0 +1,169 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/models.dart"; +import "package:ramaz/widgets.dart"; + +/// The names of the weekdays. +const List weekdayNames = [ + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" +]; + +/// Shows relevant info about today on the home page. +class Dashboard extends NavigationItem { + @override + DashboardModel get model => super.model!; + + /// Creates the dashboard page. + Dashboard() : super( + label: "Dashboard", + icon: const Icon(Icons.dashboard), + model: DashboardModel(), + ); + + @override + AppBar get appBar => AppBar( + title: const Text("Dashboard"), + actions: [ + ResponsiveBuilder( + builder: (_, LayoutInfo layout, __) => + !layout.hasStandardSideSheet && model.schedule.hasSchool + ? Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + child: const Icon( + Icons.schedule, + color: Colors.white + ), + ) + ) + : const SizedBox.shrink() + ) + ] + ); + + @override + Widget? get sideSheet { + if (!model.schedule.hasSchool) { + return null; + } + final ScheduleModel schedule = model.schedule; + return ClassList( + // if there is school, then: + // model!.schedule.today != null + // model!.schedule.periods != null + day: schedule.today!, + periods: schedule.nextPeriod == null + ? schedule.periods! + : schedule.periods!.getRange ( + (schedule.periodIndex ?? -1) + 1, + schedule.periods!.length + ), + headerText: schedule.period == null + ? "Today's Schedule" + : "Upcoming Classes" + ); + } + + /// Allows the user to refresh data. + /// + /// Updates the calendar and sports games. To update the user profile, + /// log out and log back in. + /// + /// This has to be a separate function since it can recursively call itself. + Future refresh(BuildContext context) => model.refresh( + () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text("No Internet"), + action: SnackBarAction( + label: "RETRY", + onPressed: () => refresh(context), + ), + ) + ) + ); + + @override + Widget build(BuildContext context) => RefreshIndicator( + onRefresh: () => refresh(context), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20), + children: [ + Text ( + model.schedule.today == null + ? "There is no school today" + : "Today is ${model.schedule.today!.name}", + style: Theme.of(context).textTheme.headline3, + textAlign: TextAlign.center + ), + const SizedBox (height: 20), + if (model.schedule.hasSchool) ...[ + ScheduleSlot(), + const SizedBox(height: 10), + ], + if (model.sports.todayGames.isNotEmpty) ...[ + Text( + "Sports games", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox(height: 10), + for (final int index in model.sports.todayGames) + SportsTile(model.sports.games [index]) + ] + ] + ) + ); +} + +/// Holds the schedule info on the home page. +class ScheduleSlot extends StatelessWidget { + /// The schedule data model. + late final ScheduleModel scheduleModel; + + /// The reminders data model. + late final Reminders remindersModel; + + /// Displays schedule info on the home page. + ScheduleSlot() { + final Models models = Models.instance; + remindersModel = models.reminders; + scheduleModel = models.schedule; + } + + /// The [NextClass] widgets to display. + List get children => [ + if (scheduleModel.hasSchool) NextClass( + reminders: remindersModel.currentReminders, + period: scheduleModel.period, + subject: scheduleModel.subjects [scheduleModel.period?.id], + ), + if (scheduleModel.nextPeriod != null) NextClass( + next: true, + reminders: remindersModel.nextReminders, + period: scheduleModel.nextPeriod, + subject: scheduleModel.subjects [scheduleModel.nextPeriod?.id], + ), + ]; + + @override + Widget build(BuildContext context) => ResponsiveBuilder( + builder: (_, LayoutInfo layout, __) => Column( + children: [ + Text( + scheduleModel.hasSchool + // if there is school, then scheduleModel.today != null + ? "Schedule: ${scheduleModel.today!.schedule.name}" + : "There is no school today", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox (height: 10), + if (layout.isDesktop && children.length > 1) GridView.count( + shrinkWrap: true, + crossAxisCount: layout.isDesktop ? children.length : 1, + children: children + ) else Column(children: children) + ] + ) + ); +} diff --git a/lib/src/pages/drawer.dart b/lib/src/pages/drawer.dart index 8a17e92fe..4a0a680f1 100644 --- a/lib/src/pages/drawer.dart +++ b/lib/src/pages/drawer.dart @@ -1,9 +1,9 @@ // ignore_for_file: prefer_const_constructors import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; // for route names +import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; -import "package:ramaz/services.dart"; +import "package:ramaz/pages.dart"; import "package:ramaz/widgets.dart"; /// A drawer to show throughout the app. @@ -12,113 +12,116 @@ class NavigationDrawer extends StatelessWidget { static Future Function() pushRoute(BuildContext context, String name) => () => Navigator.of(context).pushReplacementNamed(name); - @override Widget build (BuildContext context) => Drawer ( - child: LayoutBuilder( - builder: ( - BuildContext context, - BoxConstraints constraints - ) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Column( - children: [ - DrawerHeader (child: RamazLogos.ramSquare), - ListTile ( - title: const Text ("Home"), - leading: Icon (Icons.home), - onTap: pushRoute(context, Routes.home), - ), - ListTile ( - title: const Text ("Schedule"), - leading: Icon (Icons.schedule), - onTap: pushRoute(context, Routes.schedule), - ), - ListTile ( - title: const Text ("Reminders"), - leading: Icon (Icons.note), - onTap: pushRoute(context, Routes.reminders), - ), - ListTile ( - title: Text ("Sports"), - leading: Icon (Icons.directions_run), - onTap: pushRoute(context, Routes.sports), - ), - if (Models.admin != null) - ListTile( - title: const Text("Admin console"), - leading: Icon(Icons.verified_user), - onTap: pushRoute(context, Routes.admin), + /// Creates the drawer. + const NavigationDrawer(); + + /// Returns the current route name. + String? getRouteName(BuildContext context) => + ModalRoute.of(context)!.settings.name; + + /// Whether the user is allowed to modify the calendar. + bool get isScheduleAdmin => (Models.instance.user.adminScopes ?? []) + .contains(AdminScope.calendar); + + /// Whether the user is allowed to modify sports. + bool get isSportsAdmin => (Models.instance.user.adminScopes ?? []) + .contains(AdminScope.sports); + + @override + Widget build (BuildContext context) => ResponsiveBuilder( + builder: (_, LayoutInfo layout, __) => Drawer ( + child: LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints constraints + ) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + DrawerHeader(child: RamazLogos.ramSquare), + ListTile ( + title: const Text ("Dashboard"), + leading: Icon (Icons.dashboard), + onTap: pushRoute(context, Routes.home), + ), + ListTile ( + title: const Text ("Schedule"), + leading: Icon (Icons.schedule), + onTap: pushRoute(context, Routes.schedule), + ), + ListTile ( + title: const Text ("Reminders"), + leading: Icon (Icons.notifications), + onTap: pushRoute(context, Routes.reminders), + ), + ListTile ( + title: Text ("Sports"), + leading: Icon (Icons.sports), + onTap: pushRoute(context, Routes.sports), ), - BrightnessChanger.dropdown(prefs: Services.instance.prefs), - // ListTile ( - // title: Text ("Newspapers (coming soon)"), - // leading: Icon (Icons.new_releases), - // onTap: () => Navigator.of(context).pushReplacementNamed(NEWS), - // ), - // ListTile ( - // title: Text ("Lost and Found (coming soon)"), - // leading: Icon (Icons.help), - // onTap: pushRoute(context, LOST_AND_FOUND) - // ), - // Divider(), - ListTile ( - title: const Text ("Logout"), - leading: Icon (Icons.lock), - onTap: pushRoute(context, Routes.login) - ), - ListTile ( - title: const Text ("Send Feedback"), - leading: Icon (Icons.feedback), - onTap: () => Navigator.of(context) - ..pop() - ..pushNamed(Routes.feedback) - ), - const AboutListTile ( - icon: Icon (Icons.info), - // ignore: sort_child_properties_last - child: Text ("About"), - applicationName: "Ramaz Student Life", - applicationVersion: "0.5", - applicationIcon: Logos.ramazIcon, - aboutBoxChildren: [ - Text ( - "Created by the Ramaz Coding Club (Levi Lesches and Sophia " - "Kremer) with the support of the Ramaz administration. " - ), - SizedBox (height: 20), - Text ( - "A special thanks to Mr. Vovsha for helping us go from idea to " - "reality." - ), - ] - ), - const Spacer(), - Align ( - alignment: Alignment.bottomCenter, - child: Column ( + if (isScheduleAdmin) ExpansionTile( + leading: Icon(Icons.admin_panel_settings), + title: const Text("Admin options"), children: [ - const Divider(), - SingleChildScrollView ( - scrollDirection: Axis.horizontal, - child: Row ( - children: const [ - Logos.ramazIcon, - Logos.outlook, - Logos.schoology, - Logos.drive, - Logos.seniorSystems - ] - ) - ) + if (isScheduleAdmin) ...[ + ListTile( + title: Text("Calendar"), + leading: Icon(Icons.calendar_today), + onTap: pushRoute(context, Routes.calendar), + ), + ListTile( + title: Text("Custom schedules"), + leading: Icon(Icons.schedule), + onTap: pushRoute(context, Routes.schedules), + ), + ], ] + ), + BrightnessChanger.dropdown(), + ListTile ( + title: const Text ("Logout"), + leading: Icon (Icons.lock), + onTap: pushRoute(context, Routes.login) + ), + ListTile ( + title: const Text ("Send Feedback"), + leading: Icon (Icons.feedback), + onTap: pushRoute(context, Routes.feedback), + ), + ListTile ( + title: const Text("About Us"), + leading: Icon (Icons.info), + onTap: pushRoute(context, Routes.credits), + ), + const Spacer(), + Align ( + alignment: Alignment.bottomCenter, + child: Column ( + children: [ + const Divider(), + SingleChildScrollView ( + scrollDirection: Axis.horizontal, + child: Row ( + children: const [ + Logos.ramazIcon, + Logos.outlook, + Logos.schoology, + Logos.drive, + Logos.seniorSystems + ] + ) + ) + ] + ) ) - ) - ] - ) - ) + ] + ) + ) + ) ) ) ) diff --git a/lib/src/pages/feedback.dart b/lib/src/pages/feedback.dart index aa8a315a1..0645db0aa 100644 --- a/lib/src/pages/feedback.dart +++ b/lib/src/pages/feedback.dart @@ -1,21 +1,35 @@ // ignore_for_file: prefer_const_constructors_in_immutables import "package:flutter/material.dart"; -import "package:ramaz/widgets.dart"; import "package:ramaz/models.dart"; +import "package:ramaz/widgets.dart"; + +import "drawer.dart"; /// A page to submit feedback. -class FeedbackPage extends StatelessWidget { +class FeedbackPage extends StatefulWidget { + /// Creates the feedback page. + const FeedbackPage(); + + @override + FeedbackState createState() => FeedbackState(); +} + +/// The state for the feedback page. +class FeedbackState extends ModelListener { + @override + FeedbackModel getModel() => FeedbackModel(); @override - Widget build (BuildContext context) => Scaffold( + Widget build (BuildContext context) => ResponsiveScaffold( + drawer: const NavigationDrawer(), appBar: AppBar(title: const Text ("Send Feedback")), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 50), - child: ModelListener( - model: () => FeedbackModel(), - builder: (BuildContext context, FeedbackModel model, _) => Column( + bodyBuilder: (_) => Center( + child: SizedBox( + width: 400, + child: Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ TextField( autofocus: true, @@ -26,23 +40,21 @@ class FeedbackPage extends StatelessWidget { const SizedBox(height: 20), CheckboxListTile( value: model.anonymous, - onChanged: (bool value) => model.anonymous = value, - title: const Text("Make anonymous"), + // If tristate == false (default), value != null + onChanged: (bool? value) => model.anonymous = value!, + title: const Text("Anonymous"), subtitle: const Text( - "We won't be able to see your name or email. " - "To share them with us, keep this unchecked." + "To keep your name and email hidden, check this box." ) ), const SizedBox(height: 50), - RaisedButton.icon( + ElevatedButton.icon( label: const Text ("Submit"), icon: const Icon(Icons.send), - onPressed: !model.ready - ? null - : () { - model.send(); - Navigator.of(context).pop(); - } + onPressed: !model.ready ? null : () { + model.send(); + Navigator.of(context).pop(); + } ) ] ) diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 212740961..a0e37a774 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -1,135 +1,38 @@ -// ignore_for_file: prefer_const_constructors_in_immutables import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -import "package:ramaz/models.dart"; import "package:ramaz/pages.dart"; -import "package:ramaz/services.dart"; import "package:ramaz/widgets.dart"; +import "dashboard.dart"; -/// The homepage of the app. +/// The home page of RamLife. /// -/// It's stateful because when refreshing the schedule a loading bar is shown, -/// and needs to be dismissed. However, it can be rewritten to use a -/// [ValueNotifier] instead. -class HomePage extends StatefulWidget { - @override - HomePageState createState() => HomePageState(); -} +/// The home page combines different helpful pages. For good UI, limit this to +/// 3-5 pages. Other pages should go in the side menu. +class HomePage extends StatelessWidget { + /// Which sub-page should be shown by default. + final int pageIndex; -/// A state for the home page, to keep track of when the page loads. -class HomePageState extends State { - /// A key to access the [Scaffold]s state. - final GlobalKey scaffoldKey = GlobalKey(); + /// Creates the home page, starting at a certain sub-page if necessary. + /// + /// Use this for app-wide navigation. For example, to navigate to the reminders + /// page, pass in 2 for [pageIndex]. + const HomePage({required this.pageIndex}); - /// Whether the page is loading. - bool loading = false; - - /// Downloads the calendar again and calls [Schedule.onNewPeriod]. - Future refresh() async { - try { - await Services.instance.updateCalendar(); - await Services.instance.updateSports(); - } on PlatformException catch(error) { - if (error.code == "Error performing get") { - scaffoldKey.currentState.showSnackBar( - SnackBar( - content: const Text("No Internet"), - action: SnackBarAction( - label: "RETRY", - onPressed: () async { - setState(() => loading = true); - await refresh(); - setState(() => loading = false); - } - ), - ) - ); - } - } - } - - @override - Widget build (BuildContext context) => ModelListener( - model: () => HomeModel(), - builder: (BuildContext context, HomeModel model, _) => Scaffold ( - key: scaffoldKey, - appBar: AppBar ( - title: const Text ("Home"), - actions: [ - if (model.schedule.hasSchool) Builder ( - builder: (BuildContext context) => FlatButton( - textColor: Colors.white, - onPressed: () => Scaffold.of(context).openEndDrawer(), - child: const Text ("Tap for schedule"), - ) - ) + @override + Widget build(BuildContext context) => ResponsiveBuilder( + // Workaround for SportsPage's tab-based view that needs a controller. + builder: (_, LayoutInfo layout, __) => DefaultTabController( + length: 2, + child:ResponsiveScaffold.navBar( + navItems: [ + Dashboard(), + ResponsiveSchedule(), + ResponsiveReminders(), + SportsPage(), ], - ), - drawer: NavigationDrawer(), - endDrawer: !model.schedule.hasSchool ? null : Drawer ( - child: ClassList( - day: model.schedule.today, - periods: model.schedule.nextPeriod == null - ? model.schedule.periods - : model.schedule.periods.getRange ( - (model.schedule.periodIndex ?? -1) + 1, - model.schedule.periods.length - ), - headerText: model.schedule.period == null - ? "Today's Schedule" - : "Upcoming Classes" - ) - ), - body: RefreshIndicator ( // so you can refresh the period - onRefresh: refresh, - child: ListView ( - children: [ - if (loading) const LinearProgressIndicator(), - RamazLogos.ramRectangle, - const Divider(), - Text ( - model.schedule.hasSchool - ? "Today is a${model.schedule.today.n} " - "${model.schedule.today.name}" - : "There is no school today", - textScaleFactor: 2, - textAlign: TextAlign.center - ), - const SizedBox (height: 20), - if (model.schedule.hasSchool) NextClass( - reminders: Models.reminders.currentReminders, - period: model.schedule.period, - subject: model.schedule.subjects [model.schedule.period?.id], - modified: model.schedule.today.isModified, - ), - // if school won't be over, show the next class - if ( - model.schedule.nextPeriod != null && - !model.schedule.today.isModified - ) NextClass ( - next: true, - reminders: Models.reminders.nextReminders, - period: model.schedule.nextPeriod, - subject: model.schedule.subjects [model.schedule.nextPeriod?.id], - modified: model.schedule.today.isModified, - ), - if (model.sports.todayGames.isNotEmpty) ...[ - const SizedBox(height: 10), - const Center( - child: Text( - "Sports games", - textScaleFactor: 1.5, - style: TextStyle(fontWeight: FontWeight.w300) - ) - ), - const SizedBox(height: 10), - for (final int index in model.sports.todayGames) - SportsTile(model.sports.games [index]) - ] - ] - ) + initialNavIndex: pageIndex, + drawer: const NavigationDrawer(), + secondaryDrawer: const NavigationDrawer(), ) - ) + ), ); } diff --git a/lib/src/pages/login.dart b/lib/src/pages/login.dart index 707a41b92..02450e76d 100644 --- a/lib/src/pages/login.dart +++ b/lib/src/pages/login.dart @@ -4,7 +4,9 @@ import "package:flutter/services.dart" show PlatformException; import "package:url_launcher/url_launcher.dart"; +// ignore: directives_ordering import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; import "package:ramaz/services.dart"; import "package:ramaz/widgets.dart"; @@ -19,16 +21,21 @@ import "package:ramaz/widgets.dart"; /// This page holds methods that can safely clean the errors away before /// prompting the user to try again. class Login extends StatefulWidget { - /// Creates the login page. - Login(); + /// The page to navigate to after a successful login. + final String destination; - @override LoginState createState() => LoginState(); + /// Builds the login page + const Login({this.destination = Routes.home}); + + @override + LoginState createState() => LoginState(); } /// A state for the login page. /// /// This state keeps a reference to the [BuildContext]. class LoginState extends State { + /// Whether the page is loading. bool isLoading = false; @override @@ -36,8 +43,8 @@ class LoginState extends State { super.initState(); // "To log in, one must first log out" // -- Levi Lesches, class of '21, creator of this app, 2019 - Services.instance.reset(); - Models.reset(); + Services.instance.database.signOut(); + Models.instance.dispose(); } @override @@ -45,41 +52,29 @@ class LoginState extends State { appBar: AppBar ( title: const Text ("Login"), actions: [ - BrightnessChanger.iconButton(prefs: Services.instance.prefs), + BrightnessChanger.iconButton(), ], ), - body: ListView ( - children: [ - if (isLoading) const LinearProgressIndicator(), - Padding ( - padding: const EdgeInsets.all (20), - child: Column ( - children: [ - if (ThemeChanger.of(context).brightness == Brightness.light) ClipRRect( - borderRadius: BorderRadius.circular (20), - child: RamazLogos.teal - ) - else RamazLogos.ramSquareWords, - const SizedBox (height: 50), - Center ( - child: Container ( - decoration: BoxDecoration ( - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(20), - ), - child: Builder ( - builder: (BuildContext context) => ListTile ( - leading: Logos.google, - title: const Text ("Sign in with Google"), - onTap: () => googleLogin(context) // see func - ) - ) - ) - ) - ] - ) - ) - ] + body: Center( + child: Column( + children: [ + if (isLoading) const LinearProgressIndicator(minHeight: 8), + const Spacer(flex: 2), + const SizedBox( + height: 300, + width: 300, + child: RamazLogos.ramSquareWords + ), + // const SizedBox(height: 100), + const Spacer(flex: 1), + TextButton.icon( + icon: Logos.google, + label: const Text("Sign in with Google"), + onPressed: () => signIn(context), + ), + const Spacer(flex: 2), + ] + ) ) ); @@ -91,8 +86,12 @@ class LoginState extends State { /// the user from logging in. Future onError(dynamic error, StackTrace stack) async { setState(() => isLoading = false); - await Services.instance.reset(); - Models.reset(); + final Crashlytics crashlytics = Services.instance.crashlytics; + await crashlytics.log("Login failed"); + final String? email = Auth.email; + if (email != null) { + await crashlytics.setEmail(email); + } // ignore: unawaited_futures showDialog ( context: context, @@ -100,25 +99,25 @@ class LoginState extends State { title: const Text ("Cannot connect"), content: const Text ( "Due to technical difficulties, your account cannot be accessed.\n\n" - "If the problem persists, please contact Levi Lesches " - "(class of '21) for help" + "If the problem persists, please contact the Ramlife Dev Team " + "for help" ), actions: [ - FlatButton ( + TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text ("Cancel"), ), - RaisedButton ( - onPressed: () => launch ("mailto:leschesl@ramaz.org"), - color: Theme.of(dialogContext).primaryColorLight, - child: const Text ("leschesl@ramaz.org"), + ElevatedButton( + onPressed: () => launch("mailto:ramlife@ramaz.org"), + child: const Text ("ramlife@ramaz.org"), ) ] ) - ); - await Crashlytics.setUserEmail(Auth.email); - Crashlytics.log("Attempted to log in"); - await Crashlytics.recordError(error, stack); + ).then((_) async { + await Services.instance.database.signOut(); + Models.instance.dispose(); + }); + await crashlytics.recordError(error, stack); } /// Safely execute a function. @@ -127,66 +126,41 @@ class LoginState extends State { /// errors. If a network error occurs, a simple [SnackBar] is shown. /// Otherwise, the error pop-up is shown (see [onError]). Future safely({ - @required Future Function() function, - @required void Function() onSuccess, - @required BuildContext scaffoldContext, + required Future Function() function, + required void Function() onSuccess, + required BuildContext scaffoldContext, }) async { try {await function();} on PlatformException catch (error, stack) { if (error.code == "ERROR_NETWORK_REQUEST_FAILED") { - Scaffold.of(scaffoldContext).showSnackBar ( + ScaffoldMessenger.of(scaffoldContext).showSnackBar( const SnackBar (content: Text ("No Internet")), ); - setState(() => isLoading = false); - return; + return setState(() => isLoading = false); } else { - await onError(error, stack); - return; + return onError(error, stack); } - // ignore: avoid_catches_without_on_clauses - } catch (error, stack) { - await onError(error, stack); - return; + } on NoAccountException { + return setState(() => isLoading = false); + } catch (error, stack) { // ignore: avoid_catches_without_on_clauses + return onError(error, stack); } onSuccess(); } - /// Downloads the user data and initializes the app. - Future downloadData( - String username, - BuildContext scaffoldContext - ) => safely( - function: () async { - await Services.instance.initialize(); - await Models.init(); - }, - onSuccess: () => Navigator.of(context).pushReplacementNamed("home"), - scaffoldContext: scaffoldContext, - ); - - /// Signs the user into their Google account. - /// - /// If the user cancels the operation, cancel the loading animation. - /// Otherwise, download the user's data and start the main app. - /// See [downloadData]. + /// Signs the user in. /// - /// This function needs two contexts. The first one can locate the - /// [Scaffold]. But since that will rebuild (because of the loading bar), - /// we need another context that is higher up the tree than that. - /// The tighter context is passed in as [scaffoldContext], and the higher - /// context is [State.context]. - Future googleLogin(BuildContext scaffoldContext) async => safely( - function: Auth.signIn, - onSuccess: () async { + /// Calls [Services.signIn] and [Models.init]. + Future signIn(BuildContext scaffoldContext) => safely( + scaffoldContext: scaffoldContext, + function: () async { setState(() => isLoading = true); - final String email = Auth.email; - if (email == null) { - setState(() => isLoading = false); - return; - } - await downloadData(email.toLowerCase(), scaffoldContext); + await Services.instance.signIn(); + await Models.instance.init(); + }, + onSuccess: () { + setState(() => isLoading = false); + Navigator.of(context).pushReplacementNamed(widget.destination); }, - scaffoldContext: scaffoldContext, ); - } diff --git a/lib/src/pages/reminders.dart b/lib/src/pages/reminders.dart index d8a2237cb..5b6780d95 100644 --- a/lib/src/pages/reminders.dart +++ b/lib/src/pages/reminders.dart @@ -1,49 +1,49 @@ import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; import "package:ramaz/models.dart"; import "package:ramaz/pages.dart"; import "package:ramaz/widgets.dart"; -/// A page to display the user's reminders. -class RemindersPage extends StatelessWidget { +/// The reminders page. +/// +/// Allows CRUD operations on reminders. +class ResponsiveReminders extends NavigationItem { + @override + Reminders get model => super.model!; + + /// Creates the reminders page. + ResponsiveReminders() : super( + label: "Reminders", + icon: const Icon(Icons.notifications), + model: Models.instance.reminders, + shouldDispose: false, + ); + + @override + AppBar get appBar => AppBar(title: const Text ("Reminders")); + + @override + Widget get floatingActionButton => Builder( + builder: (BuildContext context) => FloatingActionButton( + onPressed: () async => model + .addReminder(await ReminderBuilder.buildReminder(context)), + child: const Icon (Icons.note_add), + ) + ); + @override - Widget build(BuildContext context) => ModelListener( - model: () => Models.reminders, - dispose: false, - // ignore: sort_child_properties_last - child: const Center ( + Widget build(BuildContext context) => model.reminders.isEmpty + ? const Center ( child: Text ( "You don't have any reminders yet", textScaleFactor: 1.5, textAlign: TextAlign.center, ), - ), - builder: (BuildContext context, Reminders model, Widget none) => Scaffold( - bottomNavigationBar: Footer(), - drawer: NavigationDrawer(), - appBar: AppBar( - title: const Text ("Reminders"), - actions: [ - IconButton( - icon: const Icon(Icons.home), - onPressed: () => Navigator.of(context).pushReplacementNamed(Routes.home) - ) - ] - ), - floatingActionButton: FloatingActionButton( - onPressed: () async => - model.addReminder(await ReminderBuilder.buildReminder(context)), - child: const Icon (Icons.note_add), - ), - body: model.reminders.isEmpty - ? none - : ListView.separated ( - itemCount: model.reminders.length, - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (BuildContext context, int index) => - ReminderTile(index: index), - ) ) - ); + : ListView.separated ( + itemCount: model.reminders.length, + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (BuildContext context, int index) => + ReminderTile(index: index), + ); } diff --git a/lib/src/pages/route_initializer.dart b/lib/src/pages/route_initializer.dart new file mode 100644 index 000000000..96e0e5e07 --- /dev/null +++ b/lib/src/pages/route_initializer.dart @@ -0,0 +1,93 @@ +import "package:flutter/material.dart"; + +import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; +import "package:ramaz/services.dart"; +import "package:ramaz/widgets.dart"; + +/// A route that performs initialization logic first. +class RouteInitializer extends StatefulWidget { + /// Checks to see if the user is signed in. + /// + /// This is the default logic to determine if the user can access a page. + static bool isSignedIn() => Auth.isSignedIn; + + /// Determines if the user is allowed to be on the given page. + final bool Function() isAllowed; + + /// The contents of the page. + final Widget child; + + /// The route to navigate to if the user is not authorized. + final String onFailure; + + /// The route to navigate to if there is an error. + final String? onError; + + /// Navigation with authorization and error-handling. + const RouteInitializer({ + required this.child, + this.onFailure = Routes.login, + this.onError = Routes.login, + this.isAllowed = isSignedIn, + }); + + @override + RouteInitializerState createState() => RouteInitializerState(); +} + +/// The state for a [RouteInitializer]. +class RouteInitializerState extends State { + /// The future for initializing the backend. + late Future initFuture; + + @override + void initState() { + super.initState(); + initFuture = init(); + } + + /// Initializes the app's backends. + /// + /// No-op if the backend is already initialized. + Future init() async { + try { + if (!Services.instance.isReady) { + await Services.instance.init(); + ThemeChanger.of(context).brightness = caseConverter ( + value: Services.instance.prefs.brightness, + onTrue: Brightness.light, + onFalse: Brightness.dark, + onNull: MediaQuery.of(context).platformBrightness, + ); + } + + if (Auth.isSignedIn && !Models.instance.isReady) { + await Models.instance.init(); + } + } catch (error, stack) { + await Services.instance.crashlytics.log("Error. Disposing models"); + Models.instance.dispose(); + if (widget.onError != null) { + await Navigator.of(context).pushReplacementNamed(widget.onError!); + } + await Services.instance.crashlytics.recordError(error, stack); + } + if (!widget.isAllowed()) { + await Navigator.of(context).pushReplacementNamed(widget.onFailure); + } + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: initFuture, + builder: (_, AsyncSnapshot snapshot) => + snapshot.connectionState == ConnectionState.done + ? widget.child + : ResponsiveScaffold( + appBar: AppBar(title: const Text("Loading...")), + bodyBuilder: (_) => const Center(child: CircularProgressIndicator()), + drawer: const NavigationDrawer(), + ), + ); +} diff --git a/lib/src/pages/schedule.dart b/lib/src/pages/schedule.dart index 8cb778921..bbece5fa4 100644 --- a/lib/src/pages/schedule.dart +++ b/lib/src/pages/schedule.dart @@ -1,104 +1,311 @@ // ignore_for_file: prefer_const_constructors_in_immutables import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; +import "package:link_text/link_text.dart"; + import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; -import "package:ramaz/pages.dart"; import "package:ramaz/widgets.dart"; -/// A page to allow the user to explore their schedule. -class SchedulePage extends StatelessWidget { +/// Allows users to explore their schedule. +/// +/// Users can use the calendar button to check the schedule for a given date +/// or create a custom [Day] from the drop-down menus. +class ResponsiveSchedule extends NavigationItem { @override - Widget build (BuildContext context) => ModelListener( - model: () => ScheduleModel(), - // ignore: sort_child_properties_last - builder: ( - BuildContext context, - ScheduleModel model, - Widget _ - ) => Scaffold( - appBar: AppBar ( - title: const Text ("Schedule"), - actions: [ - if (ModalRoute.of(context).isFirst) - IconButton ( - icon: const Icon(Icons.home), - onPressed: () => Navigator.of(context) - .pushReplacementNamed(Routes.home) - ) - ], - ), - bottomNavigationBar: Footer(), - floatingActionButton: Builder( - builder: (BuildContext context) => FloatingActionButton( - onPressed: () => viewDay (model, context), - child: const Icon (Icons.calendar_today), - ) - ), - drawer: ModalRoute.of(context).isFirst ? NavigationDrawer() : null, - body: Column ( - children: [ - ListTile ( - title: const Text ("Choose a letter"), - trailing: DropdownButton ( - value: model.day.letter, - onChanged: (Letters letter) => model.update(newLetter: letter), - items: [ - for (final Letters letter in Letters.values) - DropdownMenuItem( - value: letter, - child: Text(lettersToString [letter]), - ) - ] - ) - ), - ListTile ( - title: const Text ("Choose a schedule"), - trailing: DropdownButton ( - value: model.day.special, - onChanged: (Special special) => model.update(newSpecial: special), - items: [ - for (final Special special in Special.specials) - DropdownMenuItem( - value: special, - child: Text (special.name), - ), - if (!Special.specials.contains(model.day.special)) - DropdownMenuItem( - value: model.day.special, - child: Text(model.day.special.name) - ) - ] - ) - ), - const SizedBox (height: 20), - const Divider(), - const SizedBox (height: 20), - Expanded (child: ClassList(day: model.day)), - ] - ) - ) + ScheduleViewModel get model => super.model!; + + /// Creates the schedule page. + ResponsiveSchedule() : super( + label: "Schedule", + icon: const Icon(Icons.schedule), + model: ScheduleViewModel(), + shouldDispose: true, ); /// Allows the user to select a day in the calendar to view. /// /// If there is no school on that day, a [SnackBar] will be shown. - Future viewDay(ScheduleModel model, BuildContext context) async { - final DateTime selected = await pickDate ( + Future viewDay(ScheduleViewModel model, BuildContext context) async { + final DateTime? selected = await pickDate( context: context, initialDate: model.date, ); if (selected == null) { return; } - try {model.date = selected;} - on Exception { - Scaffold.of(context).showSnackBar( + try { + model.date = selected; + } on Exception { // user picked a day with no school + ScaffoldMessenger.of(context).showSnackBar( const SnackBar ( content: Text ("There is no school on this day") ) ); } } + + @override + AppBar get appBar => AppBar( + title: const Text("Schedule"), + actions: [ Builder( + builder: (BuildContext context) => IconButton( + icon: const Icon(Icons.search), + tooltip: "Search schedule", + onPressed: () => showSearch( + context: context, + delegate: CustomSearchDelegate(hintText: "Search for a class") + ), + ) + )] + ); + + @override + Widget? get floatingActionButton => Builder( + builder: (BuildContext context) => FloatingActionButton( + onPressed: () => viewDay(model, context), + child: const Icon(Icons.calendar_today), + ) + ); + + /// Lets the user know that they chose an invalid schedule combination. + void handleInvalidSchedule(BuildContext context) => ScaffoldMessenger + .of(context) + .showSnackBar(const SnackBar(content: Text("Invalid schedule"))); + + @override + Widget build(BuildContext context) => Column( + children: [ + ListTile ( + title: const Text ("Day"), + trailing: DropdownButton ( + value: model.day.name, + onChanged: (String? value) => model.update( + newName: value, + onInvalidSchedule: () => handleInvalidSchedule(context), + ), + items: [ + for (final String dayName in Models.instance.schedule.user.dayNames) + DropdownMenuItem( + value: dayName, + child: Text(dayName), + ) + ] + ) + ), + ListTile ( + title: const Text ("Schedule"), + trailing: DropdownButton ( + value: model.day.schedule, + onChanged: (Schedule? schedule) => model.update( + newSchedule: schedule, + onInvalidSchedule: () => handleInvalidSchedule(context), + ), + items: [ + for (final Schedule schedule in Schedule.schedules) + DropdownMenuItem( + value: schedule, + child: Text (schedule.name), + ), + ] + ) + ), + const Divider(height: 40), + Expanded( + child: ClassList( + day: model.day, + periods: Models.instance.user.data.getPeriods(model.day) + ), + ), + ] + ); +} + +/// A class that creates the search bar using ScheduleModel. +class CustomSearchDelegate extends SearchDelegate { + /// This model handles the searching logic. + final ScheduleSearchModel model = ScheduleSearchModel(); + + /// A constructor that constructs the search bar. + CustomSearchDelegate({ + required String hintText, + }) : super( + searchFieldLabel: hintText, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + ); + + @override + Widget buildLeading(BuildContext context) => ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Icon(Icons.arrow_back) + ); + + @override + Widget buildSuggestions(BuildContext context) { + + final List subjects = model.getMatchingClasses(query.toLowerCase()); + + return ListView( + children: [ + const SizedBox(height: 15), + if (subjects.isNotEmpty) + for (Subject suggestion in subjects) + SuggestionWidget( + suggestion: suggestion, + onTap: () { + query = suggestion.name; + showResults(context); + }, + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ Text( + "No Results Found", + style: Theme.of(context).textTheme.headline4 + ) + ]) + ] + ); + } + + @override + Widget buildResults(BuildContext context) { + + final List subjects = model.getMatchingClasses(query.toLowerCase()); + + return ListView( + children: [ + const SizedBox(height: 15), + if (subjects.isNotEmpty) + for ( + PeriodData period in + model.getPeriods(subjects.first) + ) + ResultWidget(period) + else + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ Text( + "No Results Found", + style: Theme.of(context).textTheme.headline4 + ) + ]) + ] + ); + } + + @override + List buildActions(BuildContext context) => [ + if (query != "") + IconButton( + icon: const Icon(Icons.close), + onPressed: () => query = "" + ) + ]; +} + +/// A class that creates each individual suggestion. +class SuggestionWidget extends StatelessWidget { + + /// The function to be run when the suggestion is clicked. + final VoidCallback onTap; + + /// The Subject given to the widget. + final Subject suggestion; + + /// A constructor that defines what a suggestion should have. + const SuggestionWidget({ + required this.suggestion, + required this.onTap + }); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ InkWell( + onTap: onTap, + child: Row( + children: [ Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + suggestion.name, + style: Theme.of(context).textTheme.headline4 + ), + const SizedBox(height: 5), + Text( + "${suggestion.teacher} ${suggestion.id}", + style: Theme.of(context).textTheme.headline6 + ), + // const SizedBox(height: 10), + // if (suggestion.virtualLink != null) + // LinkText( + // "Link: ${suggestion.virtualLink}", + // shouldTrimParams: true, + // linkStyle: const TextStyle(color: Colors.blue) + // ) + ] + ) + ) + ) + ]) + ), + const Divider( + height: 20, + indent: 40, + endIndent: 40 + ) + ] + ); +} + +/// A class that creates each individual result. +class ResultWidget extends StatelessWidget { + + /// The PeriodData given to the widget. + final PeriodData period; + + /// A constructor that defines what a result should have. + ResultWidget(this.period); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text( + period.dayName, + style: Theme.of(context).textTheme.headline4 + ), + subtitle: Text( + "Period ${period.name} Room ${period.room}", + style: Theme.of(context).textTheme.headline6 + ) + ), + for (int reminder in Models.instance.reminders.getReminders( + dayName: period.dayName, + period: period.name, + subject: Models.instance.user.subjects[period.id]?.name + )) + Padding( + padding: const EdgeInsets.only(left: 7), + child: Row( + children: [ + const Icon(Icons.notifications), + const SizedBox(width: 3), + Text( + Models.instance.reminders.reminders[reminder].message, + style: Theme.of(context).textTheme.subtitle1 + ) + ] + ) + ), + const Divider(height: 20), + ] + ); } diff --git a/lib/src/pages/specials.dart b/lib/src/pages/specials.dart deleted file mode 100644 index 0ec8dcb91..000000000 --- a/lib/src/pages/specials.dart +++ /dev/null @@ -1,56 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:ramaz/models.dart"; -import "package:ramaz/pages.dart"; -import "package:ramaz/widgets.dart"; - -/// A page to show the admin's custom specials. -class SpecialPage extends StatelessWidget { - @override - Widget build(BuildContext context) => ModelListener( - model: () => Models.admin.user, - dispose: false, - builder: (_, AdminUserModel model, __) => Scaffold( - appBar: AppBar( - title: const Text("Custom schedules"), - ), - floatingActionButton: FloatingActionButton( - onPressed: () async => model.addSpecial( - await SpecialBuilder.buildSpecial(context), - ), - child: const Icon(Icons.add), - ), - body: Padding( - padding: const EdgeInsets.all(20), - child: model.admin.specials.isEmpty - ? const Center ( - child: Text ( - "You don't have any schedules yet, but you can make one!", - textScaleFactor: 1.5, - textAlign: TextAlign.center, - ) - ) - : ListView( - children: [ - for (int index = 0; index < model.admin.specials.length; index++) - ListTile( - title: Text (model.admin.specials [index].name), - trailing: IconButton( - icon: const Icon(Icons.remove_circle), - onPressed: () => model.removeSpecial(index), - ), - onTap: () async => model.replaceSpecial( - index, - await SpecialBuilder.buildSpecial( - context, - model.admin.specials [index] - ), - ) - ) - ] - ) - ) - ) - ); -} - diff --git a/lib/src/pages/splash.dart b/lib/src/pages/splash.dart deleted file mode 100644 index adad8e20f..000000000 --- a/lib/src/pages/splash.dart +++ /dev/null @@ -1,29 +0,0 @@ -// ignore_for_file: prefer_const_constructors_in_immutables - -import "package:flutter/material.dart"; - -import "package:ramaz/widgets.dart"; - -/// A splash screen that discreetly loads the device's brightness. -class SplashScreen extends StatelessWidget { - /// A callback for when the device's brightness is determined. - final void Function(Brightness) setBrightness; - - /// Creates a splash screen. - const SplashScreen({this.setBrightness}); - - @override Widget build (BuildContext context) => MaterialApp ( - home: Scaffold ( - body: Builder ( - builder: (BuildContext context) { - Future ( - () => setBrightness( - MediaQuery.of(context).platformBrightness - ) - ); - return const Center(child: RamazLogos.ramSquareWords); - } - ) - ) - ); -} \ No newline at end of file diff --git a/lib/src/pages/sports.dart b/lib/src/pages/sports.dart index 0ea4932ff..32a7fdd98 100644 --- a/lib/src/pages/sports.dart +++ b/lib/src/pages/sports.dart @@ -1,14 +1,10 @@ import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; import "package:ramaz/pages.dart"; -import "package:ramaz/services.dart"; import "package:ramaz/widgets.dart"; -import "package:url_launcher/url_launcher.dart"; - /// A Swipe to Refresh list of [SportsGame]s. /// /// This is used to simplify the logic between games that are sorted @@ -25,24 +21,20 @@ class GenericSportsView extends StatelessWidget { /// /// This can be any type as long as it can be used in [builder] to build /// [SportsTile]s. - final Iterable recents; + final List recents; /// Builds a list of [SportsTile]s using [upcoming] and [recents]. final Widget Function(T) builder; - /// The function to call when the user refreshes the page. - final Future Function() onRefresh; - - /// Whether to show a loading indicator. - final bool loading; + /// Defines the sports view model. + final SportsModel model; /// Creates a list of [SportsTile]s. const GenericSportsView({ - @required this.upcoming, - @required this.recents, - @required this.builder, - @required this.onRefresh, - @required this.loading, + required this.upcoming, + required this.recents, + required this.builder, + required this.model, }); @override @@ -50,13 +42,16 @@ class GenericSportsView extends StatelessWidget { children: [ for (final List gamesList in [upcoming, recents]) RefreshIndicator( - onRefresh: onRefresh, + onRefresh: () async { + model.loading = true; + await model.refresh(); + model.loading = false; + }, child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 4), children: [ - if (loading) - const LinearProgressIndicator(), - for (final T game in gamesList) - builder(game) + if (model.loading) const LinearProgressIndicator(), + for (final T game in gamesList) builder(game) ] ) ) @@ -64,191 +59,186 @@ class GenericSportsView extends StatelessWidget { ); } -/// A page to show recent and upcoming games to the user. -class SportsPage extends StatelessWidget { - @override - Widget build(BuildContext context) => DefaultTabController( - length: 2, - child: ModelListener( - model: () => SportsModel(Models.sports), - builder: (BuildContext context, SportsModel model, Widget _) => Scaffold( - appBar: AppBar( - title: const Text("Sports"), - bottom: const TabBar( - tabs: [ - Tab(text: "Upcoming"), - Tab(text: "Recent"), - ] - ), - actions: [ - if (model.isAdmin) - IconButton( - icon: const Icon(Icons.add), - tooltip: "Add a game", - onPressed: model.adminFunc(() async => - model.data.addGame(await SportsBuilder.createGame(context)) +/// Opens a menu with options for the selected game. +/// +/// This menu can only be accessed by administrators. +void openMenu({ + required BuildContext context, + required int index, + required SportsModel model +}) => showDialog( + context: context, + builder: (BuildContext newContext) => SimpleDialog( + title: Text(model.data.games [index].description), + children: [ + SimpleDialogOption( + onPressed: () async { + Navigator.of(newContext).pop(); + final Scores? scores = await SportsScoreUpdater.updateScores( + context, model.data.games [index] + ); + if (scores == null) { + return; + } + model.loading = true; + await Models.instance.sports.replace( + index, + model.data.games [index].replaceScores(scores) + ); + model.loading = false; + }, + child: const Text("Edit scores", textScaleFactor: 1.2), + ), + const SizedBox(height: 10), + SimpleDialogOption( + onPressed: () async { + Navigator.of(newContext).pop(); + model.loading = true; + await Models.instance.sports.replace( + index, + model.data.games [index].replaceScores(null) + ); + model.loading = false; + }, + child: const Text("Remove scores", textScaleFactor: 1.2), + ), + const SizedBox(height: 10), + SimpleDialogOption( + onPressed: () async { + Navigator.of(newContext).pop(); + model.loading = true; + await Models.instance.sports.replace( + index, + await SportsBuilder.createGame(context, model.data.games [index]) + ); + model.loading = false; + }, + child: const Text("Edit game", textScaleFactor: 1.2), + ), + const SizedBox(height: 10), + SimpleDialogOption( + onPressed: () async { + Navigator.of(newContext).pop(); + final bool? confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text("Confirm"), + content: const Text("Are you sure you want to delete this game?"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), ), - ), - PopupMenuButton( - icon: const Icon(Icons.sort), - onSelected: (SortOption option) => model.sortOption = option, - tooltip: "Sort games", - itemBuilder: (_) => [ - const PopupMenuItem( - value: SortOption.chronological, - child: Text("By date"), - ), - const PopupMenuItem( - value: SortOption.sport, - child: Text("By sport"), - ) - ] - ) - ] + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Confirm"), + ) + ] + ) + ); + if (confirm ?? false) { + model.loading = true; + await Models.instance.sports.delete(index); + await model.refresh(); + model.loading = false; + } + }, + child: const Text("Remove game", textScaleFactor: 1.2), + ), + ] + ) +); + +/// A page to show recent and upcoming games to the user. +class SportsPage extends NavigationItem{ + @override + SportsModel get model => super.model!; + + /// Creates the schedule page. + SportsPage() : super( + label: "Sports", + icon: const Icon(Icons.sports), + model: SportsModel(Models.instance.sports), + shouldDispose: true, + ); + + @override + AppBar get appBar => AppBar( + title: const Text("Sports"), + bottom: const TabBar( + tabs: [ + Tab(text: "Upcoming"), + Tab(text: "Recent"), + ] + ), + actions: [ + if (model.isAdmin) Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.add), + tooltip: "Add a game", + onPressed: () async { + model.loading = true; + await model.data.addGame(await SportsBuilder.createGame(context)); + await model.refresh(); + model.loading = false; + } ), - drawer: NavigationDrawer(), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: FloatingActionButton.extended( - label: const Text("Watch livestream"), - icon: const Icon(Icons.open_in_new), - onPressed: () => launch(Urls.sportsLivestream), - ), - body: getLayout(context, model), ), - ) + PopupMenuButton( + icon: const Icon(Icons.sort), + onSelected: (SortOption option) => model.sortOption = option, + tooltip: "Sort games", + itemBuilder: (_) => [ + const PopupMenuItem( + value: SortOption.chronological, + child: Text("By date"), + ), + const PopupMenuItem( + value: SortOption.sport, + child: Text("By sport"), + ) + ] + ), + ] ); - /// Creates a [GenericSportsView] based on the sorting option. - Widget getLayout(BuildContext context, SportsModel model) { + @override + Widget build(BuildContext context) { switch(model.sortOption) { - case SortOption.chronological: - return GenericSportsView( - loading: model.loading, - onRefresh: model.adminFunc(Services.instance.updateSports), - recents: model.recents, - upcoming: model.upcoming, - builder: (int index) => SportsTile( - model.data.games [index], - onTap: !model.isAdmin ? null : () => openMenu( - context: context, - index: index, - model: model, - ) - ), - ); - case SortOption.sport: - return GenericSportsView>>( - loading: model.loading, - onRefresh: model.adminFunc(Services.instance.updateSports), - recents: model.recentBySport.entries.toList(), - upcoming: model.upcomingBySport.entries.toList(), - builder: (MapEntry> entry) => Column( - children: [ - const SizedBox(height: 15), - Text(SportsGame.capitalize(entry.key)), - for (final int index in entry.value) - SportsTile( - model.data.games [index], - onTap: !model.isAdmin ? null : () => openMenu( - context: context, - index: index, - model: model - ) - ), - const SizedBox(height: 20), - ] + case SortOption.chronological: return GenericSportsView( + model: model, + recents: model.recents, + upcoming: model.upcoming, + builder: (int index) => SportsTile( + model.data.games [index], + onTap: !model.isAdmin ? null : () => openMenu( + context: context, + index: index, + model: model, ) - ); + ), + ); + case SortOption.sport: return GenericSportsView>>( + model: model, + recents: model.recentBySport.entries.toList(), + upcoming: model.upcomingBySport.entries.toList(), + builder: (MapEntry> entry) => Column( + children: [ + const SizedBox(height: 15), + Text(SportsGame.capitalize(entry.key)), + for (final int index in entry.value) + SportsTile( + model.data.games [index], + onTap: !model.isAdmin ? null : () => openMenu( + context: context, + index: index, + model: model + ) + ), + const SizedBox(height: 20), + ] + ) + ); } - return null; } - - /// Opens a menu with options for the selected game. - /// - /// This menu can only be accessed by administrators. - static void openMenu({ - @required BuildContext context, - @required int index, - @required SportsModel model - }) => showDialog( - context: context, - builder: (BuildContext newContext) => SimpleDialog( - title: Text(model.data.games [index].description), - children: [ - SimpleDialogOption( - onPressed: () async { - Navigator.of(newContext).pop(); - final Scores scores = await SportsScoreUpdater.updateScores( - context, model.data.games [index] - ); - if (scores == null) { - return; - } - model.loading = true; - await Models.sports.replace( - index, - model.data.games [index].replaceScores(scores) - ); - model.loading = false; - }, - child: const Text("Edit scores", textScaleFactor: 1.2), - ), - const SizedBox(height: 10), - SimpleDialogOption( - onPressed: () async { - Navigator.of(newContext).pop(); - model.loading = true; - await Models.sports.replace( - index, - model.data.games [index].replaceScores(null) - ); - model.loading = false; - }, - child: const Text("Remove scores", textScaleFactor: 1.2), - ), - const SizedBox(height: 10), - SimpleDialogOption( - onPressed: () async { - Navigator.of(newContext).pop(); - model.loading = true; - await Models.sports.replace( - index, - await SportsBuilder.createGame(context, model.data.games [index]) - ); - model.loading = false; - }, - child: const Text("Edit game", textScaleFactor: 1.2), - ), - const SizedBox(height: 10), - SimpleDialogOption( - onPressed: () async { - Navigator.of(newContext).pop(); - final bool confirm = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text("Confirm"), - content: const Text("Are you sure you want to delete this game?"), - actions: [ - FlatButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text("Cancel"), - ), - RaisedButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text("Confirm"), - ) - ] - ) - ); - if (confirm) { - model.loading = true; - await Models.sports.delete(index); - model.loading = false; - } - }, - child: const Text("Remove game", textScaleFactor: 1.2), - ), - ] - ) - ); } diff --git a/lib/src/services/auth.dart b/lib/src/services/auth.dart index e9373d82b..e715f7efc 100644 --- a/lib/src/services/auth.dart +++ b/lib/src/services/auth.dart @@ -1,88 +1,95 @@ import "package:firebase_auth/firebase_auth.dart"; import "package:google_sign_in/google_sign_in.dart"; +/// An exception thrown when no account has been selected. +class NoAccountException implements Exception {} + // ignore: avoid_classes_with_only_static_members /// An abstraction around FirebaseAuth. /// -/// This class handles all authentication operations via static methods. -/// There is no need to create an instance of this class. +/// This class handles all authentication operations via static methods. Do +/// not create an instance of this class; it is not a Service. Instead, use +/// it from within other services. class Auth { /// The [FirebaseAuth] service. static final FirebaseAuth auth = FirebaseAuth.instance; /// The [GoogleSignIn] service. - static final GoogleSignIn google = GoogleSignIn(); + static final GoogleSignIn google = GoogleSignIn(hostedDomain: "ramaz.org"); - /// The scope for the calendar. + /// The scope for calendar admins. /// - /// This string should be found in the users Firebase custom claims. + /// This string should be found in the user's Firebase custom claims. static const String calendarScope = "calendar"; - /// The scope for sports games. + /// The scope for sports admins. /// - /// This string should be found in the users Firebase custom claims. + /// This string should be found in the user's Firebase custom claims. static const String sportsScope = "sports"; /// The currently logged in user. /// /// This getter returns a [User], which should not be used /// outside this library. This method should only be called by - /// methods that provide higher level functionality, such as [isReady]. - static User get currentUser => auth.currentUser; + /// methods that provide higher level functionality, such as [isSignedIn]. + static User? get _currentUser => auth.currentUser; /// The user's email. - static String get email => currentUser?.email; + /// + /// Since the database is case-sensitive, we standardize the lower case. + static String? get email => _currentUser?.email?.toLowerCase(); /// The user's full name. - static String get name => currentUser?.displayName; + static String? get name => _currentUser?.displayName; /// Determines whether the user is currently logged - static bool get isReady => currentUser != null; + static bool get isSignedIn => _currentUser != null; - /// Whether the user is an admin or not. - static Future get claims async => ( - await currentUser.getIdTokenResult() - ).claims; + /// Gets the user's custom claims. + /// + /// See the official [Firebase docs](https://firebase.google.com/docs/auth/admin/custom-claims). + static Future?> get claims async => !isSignedIn ? null + : (await _currentUser!.getIdTokenResult()).claims; /// Whether the user is an admin. - static Future get isAdmin async => (await claims) ["isAdmin"] ?? false; + /// + /// This works by checking for an "isAdmin" flag in the user's custom [claims]. + static Future get isAdmin async { + final Map? customClaims = await claims; + return customClaims != null && (customClaims ["isAdmin"] ?? false); + } /// The scopes of an admin. /// - /// No null-checks are necessary here, because the `scopes` field should be - /// present if the user is an admin, and this property will not be accessed - /// unless [isAdmin] returns true. - static Future> get adminScopes async => [ - for (final scope in (await claims) ["scopes"]) - scope.toString() - ]; + /// Returns null if the user is not an admin (ie, [isAdmin] returns false). + static Future?> get adminScopes async { + final Iterable? customClaims = (await claims) ?["scopes"]; + return customClaims == null ? null : [ + for (final String scope in customClaims) + scope.toString() + ]; + } /// Whether the user is an admin for the calendar. static Future get isCalendarAdmin async => - (await isAdmin) && (await adminScopes).contains(calendarScope); + (await adminScopes)?.contains(calendarScope) ?? false; /// Whether the user is an admin for sports games. static Future get isSportsAdmin async => - (await isAdmin) && (await adminScopes).contains(sportsScope); + (await adminScopes)?.contains(sportsScope) ?? false; /// Signs out the currently logged in user. static Future signOut() async { await google.signOut(); + await google.disconnect(); await auth.signOut(); } - /// Determines whether the provided email is a valid Ramaz account - /// - /// This does no server side validation, only checking if it ends in - /// "@ramaz.org". - static bool isValidGoogleAccount(GoogleSignInAccount account) => account - .email.endsWith("@ramaz.org"); - /// Signs the user in using Google as a provider. static Future signIn() async { - final GoogleSignInAccount googleAccount = await google.signIn(); + final GoogleSignInAccount? googleAccount = await google.signIn(); if (googleAccount == null) { - return; + throw NoAccountException(); } final GoogleSignInAuthentication googleAuth = await googleAccount.authentication; diff --git a/lib/src/services/cloud_db.dart b/lib/src/services/cloud_db.dart deleted file mode 100644 index 1a1bb7301..000000000 --- a/lib/src/services/cloud_db.dart +++ /dev/null @@ -1,236 +0,0 @@ -import "package:cloud_firestore/cloud_firestore.dart"; - -import "auth.dart"; -import "service.dart"; - -/// A wrapper around Cloud Firestore. -class CloudDatabase implements Service { - static final DateTime _now = DateTime.now(); - - /// The [FirebaseFirestore service]. - static final FirebaseFirestore firestore = FirebaseFirestore.instance; - - /// The field in the reminders document that stores the reminders. - static const String remindersKey = "reminders"; - - /// The field in the calendar document that stores the calendar. - static const String calendarKey = "calendar"; - - /// The field in the sports document that stores the games. - static const String sportsKey = "games"; - - /// The user data collection. - /// - /// Note that even though this is named students, faculty are supported too. - /// Each user has their own document to store their data. - /// - /// To access a document in this collection, use [userDocument]. - static final CollectionReference userCollection = - firestore.collection("students"); - - /// The admin profile collection. - /// - /// Each admin has their own document to store their data. Note that the - /// documents themselves do not grant admin privileges, since admins can modify - /// their own document. Rather, the scopes of their privileges are stored in - /// FirebaseAuth custom claims. See [Auth.adminScopes]. - /// - /// To access a document in this collection, use [adminDocument]. - static final CollectionReference adminCollection = - firestore.collection("admin"); - - /// The course data collection. - /// - /// Sections, not courses, are stored in the database. Each section has its - /// own document with the data of that section. - /// - /// Do not access documents in this collection directly. - /// Use either [getSections] or [getSection]. - static final CollectionReference sectionCollection = - firestore.collection("classes"); - - /// The calendar collection. - /// - /// The calendar collection consists of 12 documents, one for each month. - /// Each document has its month's data in the [calendarKey] field. - /// - /// Do not access documents in this collection directly. - /// Instead, use [calendar] or [getMonth]. - static final CollectionReference calendarCollection = - firestore.collection("calendar"); - - /// The feedback collection. - /// - /// Users can only create new documents here, not edit existing ones. - /// - /// Do not access documents in this collection. - /// Instead, create new ones. - static final CollectionReference feedbackCollection = - firestore.collection("feedback"); - - /// The reminders collection. - /// - /// Each user has their own document here that holds just their reminders. - /// The decision to separate reminders from the rest of the user data was - /// made to minimize the amount of processing time, since the student document - /// contains other irrelevant data. - /// - /// To access a document in this collection, use [remindersDocument]. - static final CollectionReference remindersCollection = - firestore.collection("reminders"); - - /// The sports collection. - /// - /// Each academic year has its own document, with all the games for that year - /// in a list. This structure is still up for redesign, but seems to hold up. - /// - /// To access a document in this collection, use [sportsDocument]. - static final CollectionReference sportsCollection = - firestore.collection("sports"); - - /// The document for this user's data. - /// - /// The collection is indexed by email. - DocumentReference get userDocument => - userCollection.doc(Auth.email); - - /// The document for this user's reminders. - /// - /// The collection is indexed by email. - DocumentReference get remindersDocument => - remindersCollection.doc(Auth.email); - - /// The document for this user's admin profile. - /// - /// The collection is indexed by email. - DocumentReference get adminDocument => - adminCollection.doc(Auth.email); - - /// The document for this academic year's sports games. - /// - /// The collection is indexed by `"$firstYear-$secondYear"` (eg, 2020-2021). - DocumentReference get sportsDocument => sportsCollection.doc(_now.month > 7 - ? "${_now.year}-${_now.year + 1}" - : "${_now.year - 1}-${_now.year}" - ); - - @override - Future get isReady async => Auth.isReady; - - @override - Future reset() => Auth.signOut(); - - /// Signs the user in, and initializes their reminders. - @override - Future initialize() async { - if (Auth.isReady) { - return; - } - await Auth.signIn(); - final DocumentSnapshot remindersSnapshot = await remindersDocument.get(); - if (!remindersSnapshot.exists) { - await remindersDocument.set({remindersKey: []}); - } - } - - @override - Future> get user async => - (await userDocument.get()).data(); - - /// No-op -- The user cannot edit their own profile. - /// - /// User profiles can only be modified by the admin SDK. - /// Admin profiles may be modified. See [setAdmin]. - @override - Future setUser(Map json) async {} - - /// Gets an individual section. - /// - /// Do not use directly. Use [getSections] instead. - Future> getSection(String id) async => - (await sectionCollection.doc(id).get()).data(); - - @override - Future>> getSections(Set ids) - async => { - for (final String id in ids) - id: await getSection(id) - }; - - /// No-op -- The user cannot edit the courses list. - /// - /// The courses list can only be modified by the admin SDK. - @override - Future setSections(Map> json) async {} - - /// Gets a month of the calendar. - /// - /// Do not use directly. Use [calendar]. - Future> getMonth(int month) async { - final DocumentReference document = calendarCollection.doc(month.toString()); - final DocumentSnapshot snapshot = await document.get(); - final Map data = snapshot.data(); - return data; - } - - @override - Future>>> get calendar async => [ - for (int month = 1; month <= 12; month++) - for (final dynamic json in (await getMonth(month)) [calendarKey]) [ - Map.from(json) - ] - ]; - - @override - Future setCalendar(int month, Map json) => - calendarCollection.doc(month.toString()).set(json); - - @override - Future>> get reminders async { - final DocumentSnapshot snapshot = await remindersDocument.get(); - final Map data = snapshot.data(); - return [ - for (final dynamic json in data [remindersKey]) - Map.from(json) - ]; - } - - @override - Future setReminders(List> json) => - remindersDocument.set({remindersKey: json}); - - @override - Future> get admin async => - (await adminDocument.get()).data(); - - @override - Future setAdmin(Map json) => adminDocument.set(json); - - @override - Future>> get sports async { - final DocumentSnapshot snapshot = await sportsDocument.get(); - final Map data = snapshot.data(); - return [ - for (final dynamic json in data [sportsKey]) - Map.from(json) - ]; - } - - @override - Future setSports(List> json) => - sportsDocument.set({sportsKey: json}); - - /// Submits feedback. - static Future sendFeedback( - Map json - ) => feedbackCollection.doc().set(json); - - /// Listens to a month for changes in the calendar. - static Stream>> getCalendarStream(int month) => - calendarCollection.doc(month.toString()).snapshots().map( - (DocumentSnapshot snapshot) => [ - for (final dynamic entry in snapshot.data() ["calendar"]) - Map.from(entry) - ] - ); -} diff --git a/lib/src/services/crashlytics.dart b/lib/src/services/crashlytics.dart index 7488cc201..11a25ae27 100644 --- a/lib/src/services/crashlytics.dart +++ b/lib/src/services/crashlytics.dart @@ -1,2 +1,57 @@ -export "crashlytics/stub.dart" +import "package:flutter/foundation.dart"; + +import "crashlytics/stub.dart" if (dart.library.io) "crashlytics/mobile.dart"; + +import "service.dart"; + +/// A wrapper around the Crashlytics SDK. +/// +/// Crashlytics is a service that helps report errors from apps already in use. +/// Crashes and errors can be found in the Firebase console. +/// +/// This class has a singleton, since there are multiple implementations. Use +/// [Crashlytics.instance]. +abstract class Crashlytics extends Service { + /// The singleton of this object. + static Crashlytics instance = getCrashlytics(); + + /// Whether the app crashed last time it ran. + /// + /// A "crash" according to Firebase is a fatal-error at the native level. So + /// most Flutter errors should not trigger this. In the future, however, it + /// may be helpful. + bool didCrashLastTime = false; + + /// Records an error to Crashlytics. + /// + /// This function is meant for Dart errors. For Flutter errors, use + /// [recordFlutterError]. + Future recordError( + dynamic exception, + StackTrace stack, + {dynamic context} + ); + + /// Records an error in the Flutter framework. + Future recordFlutterError(FlutterErrorDetails details); + + /// Sets the email of the current user. + /// + /// This is helpful when looking through error reports, and enables us to dig + /// around the database and find corrupted data. + Future setEmail(String email); + + /// Logs a message to Crashlytics. + /// + /// Put these everywhere. That way, if the app does crash, the error report + /// will be full of context. + Future log(String message); + + /// Toggles Crashlytics on or off. + /// + /// This should always be set to on (and it is by default), except for when + /// the app is running in dev mode. + // ignore: avoid_positional_boolean_parameters + Future toggle(bool value); +} diff --git a/lib/src/services/crashlytics/mobile.dart b/lib/src/services/crashlytics/mobile.dart index 5576e5746..65e939dbf 100644 --- a/lib/src/services/crashlytics/mobile.dart +++ b/lib/src/services/crashlytics/mobile.dart @@ -1,20 +1,51 @@ -import "package:firebase_crashlytics/firebase_crashlytics.dart" as fb; +import "package:firebase_crashlytics/firebase_crashlytics.dart" show FirebaseCrashlytics; import "package:flutter/foundation.dart"; -class Crashlytics { - static Future recordError ( +import "../crashlytics.dart"; + +/// Provides the correct implementation for mobile. +Crashlytics getCrashlytics() => CrashlyticsImplementation(); + +/// Connects the app to Firebase Crashlytics. +/// +/// Currently, Crashlytics is only available on mobile, so this implementation +/// is only used where `dart:io` is available. +class CrashlyticsImplementation extends Crashlytics { + /// Provides the connection to Firebase Crashlytics. + static FirebaseCrashlytics firebase = FirebaseCrashlytics.instance; + + @override + Future init() async { + final bool didCrashLastTime = await firebase.didCrashOnPreviousExecution(); + if (didCrashLastTime) { + await log("App crashed on last run"); + } + } + + @override + Future signIn() async {} + + @override + Future recordError ( dynamic exception, StackTrace stack, {dynamic context} - ) => fb.Crashlytics.instance.recordError(exception, stack, context: context); + ) => firebase.recordError(exception, stack); - static Future recordFlutterError ( + @override + Future recordFlutterError ( FlutterErrorDetails details - ) => fb.Crashlytics.instance.recordFlutterError(details); + ) => firebase.recordFlutterError(details); + + @override + Future setEmail(String email) => + firebase.setUserIdentifier(email); - static Future setUserEmail(String email) => - fb.Crashlytics.instance.setUserEmail(email); + @override + Future log(String message) => + firebase.log(message); - static void log(String message) => - fb.Crashlytics.instance.log(message); + @override + Future toggle(bool value) => + firebase.setCrashlyticsCollectionEnabled(value); } diff --git a/lib/src/services/crashlytics/stub.dart b/lib/src/services/crashlytics/stub.dart index 855c5ff99..b68530cfe 100644 --- a/lib/src/services/crashlytics/stub.dart +++ b/lib/src/services/crashlytics/stub.dart @@ -1,17 +1,40 @@ import "package:flutter/foundation.dart"; +import "../crashlytics.dart"; -class Crashlytics { - static Future recordError ( +/// Provides the correct implementation for web. +Crashlytics getCrashlytics() => CrashlyticsStub(); + +/// Provides an empty [Crashlytics] instance. +/// +/// Currently, crashlytics is only available on mobile, so this implementation +/// is used where `dart:io` is unavailable. +class CrashlyticsStub extends Crashlytics { + @override + Future init() async {} + + @override + Future signIn() async {} + + @override + Future recordError ( dynamic exception, StackTrace stack, {dynamic context} - ) => throw exception; + ) => Future.error(exception, stack); // keeps the stack trace - static Future recordFlutterError ( + @override + Future recordFlutterError ( FlutterErrorDetails details - ) => throw details.exception; + ) async => throw details.exception; // ignore: only_throw_errors + // [FlutterErrorDetails.exception] is an [Object], and can be any value. + + @override + Future setEmail(String email) async {} - static Future setUserEmail(String email) async {} + @override + // ignore: avoid_print + Future log(String message) async => print(message); - static void log(String message) {} + @override + Future toggle(bool value) async {} } diff --git a/lib/src/services/database.dart b/lib/src/services/database.dart new file mode 100644 index 000000000..0b8627905 --- /dev/null +++ b/lib/src/services/database.dart @@ -0,0 +1,82 @@ +// ignore_for_file: directives_ordering + +import "firestore.dart"; +import "idb.dart"; +import "service.dart"; + +import "databases/hybrid.dart"; + +import "databases/calendar/hybrid.dart"; +import "databases/reminders/hybrid.dart"; +import "databases/schedule/hybrid.dart"; +import "databases/sports/hybrid.dart"; +import "databases/user/hybrid.dart"; + +export "databases/calendar/hybrid.dart"; +export "databases/reminders/hybrid.dart"; +export "databases/schedule/hybrid.dart"; +export "databases/sports/hybrid.dart"; +export "databases/user/hybrid.dart"; + +/// A wrapper around all data in all database. +/// +/// The database is split into 2N parts: N types of data, with a Firestore and +/// IDB implementation for each. The local and cloud implementations are bundled +/// into [HybridDatabase]s, which we use here. +/// +/// This is the only class that brings the [Service] paradigm into the database +/// realm, so any and all initialization must be done here. Each database is +/// allowed to implement a [signIn] method, which will be called here. If data +/// needs to be downloaded and cached, that's where it will be done. +class Database extends DatabaseService { + /// The cloud database, using Firebase's Cloud Firestore. + final Firestore firestore = Firestore(); + + /// The local database. + final Idb idb = Idb(); + + // ---------------------------------------------------------------- + // The data managers for each category + // ---------------------------------------------------------------- + + /// The user data manager. + final HybridUser user = HybridUser(); + + /// The schedule data manager + final HybridSchedule schedule = HybridSchedule(); + + /// The calendar data manager. + final HybridCalendar calendar = HybridCalendar(); + + /// The reminders data manager. + final HybridReminders reminders = HybridReminders(); + + /// The sports data manager. + final HybridSports sports = HybridSports(); + + // ---------------------------------------------------------------- + + @override + Future init() async { + await firestore.init(); + await idb.init(); + } + + @override + Future signIn() async { + await firestore.signIn(); + await idb.signIn(); + + await user.signIn(); + await schedule.signIn(); + await calendar.signIn(); + await reminders.signIn(); + await sports.signIn(); + } + + @override + Future signOut() async { + await firestore.signOut(); + await idb.signOut(); + } +} diff --git a/lib/src/services/databases/calendar/hybrid.dart b/lib/src/services/databases/calendar/hybrid.dart new file mode 100644 index 000000000..284e6b08d --- /dev/null +++ b/lib/src/services/databases/calendar/hybrid.dart @@ -0,0 +1,55 @@ +import "../hybrid.dart"; + +import "implementation.dart"; +import "interface.dart"; + +/// Handles calendar data in the cloud and on the device. +/// +/// The calendar and schedules should always be downloaded at the same time, +/// because changes to the calendar may reference newly created schedules. +/// +/// For accuracy, the current month's calendar should be updated every day. +/// Make sure to call [update] once per day. +// ignore: lines_longer_than_80_chars +class HybridCalendar extends HybridDatabase implements CalendarInterface { + @override + final CloudCalendar cloud = CloudCalendar(); + + @override + final LocalCalendar local = LocalCalendar(); + + @override + Future signIn() async { + await local.setSchedules(await cloud.getSchedules()); + for (int month = 1; month <= 12; month++) { + await local.setMonth(month, await cloud.getMonth(month)); + } + } + + @override + Future> getMonth(int month) => local.getMonth(month); + + @override + Future setMonth(int month, List json) async { + await cloud.setMonth(month, json); + await local.setMonth(month, json); + } + + @override + Future> getSchedules() => local.getSchedules(); + + @override + Future setSchedules(List json) async { + await cloud.setSchedules(json); + await local.setSchedules(json); + } + + /// Downloads the parts of the calendar necessary to be up-to-date. + /// + /// Just saves the schedules and the current month. + Future update() async { + final int currentMonth = DateTime.now().month; + await local.setSchedules(await cloud.getSchedules()); + await local.setMonth(currentMonth, await cloud.getMonth(currentMonth)); + } +} diff --git a/lib/src/services/databases/calendar/implementation.dart b/lib/src/services/databases/calendar/implementation.dart new file mode 100644 index 000000000..ce7e06980 --- /dev/null +++ b/lib/src/services/databases/calendar/implementation.dart @@ -0,0 +1,79 @@ +import "../../firestore.dart"; +import "../../idb.dart"; + +import "interface.dart"; + +/// Handles calendar data in the cloud. +/// +/// Each month is in a document labeled with the month number, from 1-12. The +/// actual data itself is in `calendar` field, alongside the `month` field. +/// +/// The schedules are in a document called `schedules`, under the field +/// `schedules`. +class CloudCalendar implements CalendarInterface { + /// The calendar collection in Firestore. + static final CollectionReference> calendar = Firestore + .instance.collection("calendar"); + + /// The document inside [calendar] that holds the schedules. + static final DocumentReference> schedules = + calendar.doc("schedules"); + + @override + Future> getMonth(int month) async { + final Map json = await calendar.doc(month.toString()) + .throwIfNull("Month $month not found in cloud database"); + return List.from(json ["calendar"]); + } + + @override + Future setMonth(int month, List json) => calendar + .doc(month.toString()).set({"month": month, "calendar": json}); + + @override + Future> getSchedules() async => List.from( + (await schedules.throwIfNull("Cannot find schedules")) ["schedules"] + ); + + @override + Future setSchedules(List json) => schedules + .set({"schedules": json}); +} + +/// Handles calendar data on the device. +/// +/// The calendar is held in an object store where they key is the month numbered +/// 1-12. +/// +/// The schedules are in another object store where the names are the keys. +class LocalCalendar implements CalendarInterface { + @override + Future> getMonth(int month) async { + final Map json = await Idb.instance.throwIfNull( + storeName: Idb.calendarStoreName, + key: month, + message: "Cannot find $month in local database", + ); + return List.from(json ["calendar"]); + } + + @override + Future setMonth(int month, List json) => Idb.instance.update( + storeName: Idb.calendarStoreName, + value: {"month": month, "calendar": json}, + ); + + @override + Future> getSchedules() => Idb.instance + .getAll(Idb.scheduleStoreName); + + @override + Future setSchedules(List json) async { + for (final Map schedule in json) { + await Idb.instance.update( + storeName: Idb.scheduleStoreName, + value: schedule, + ); + } + } +} diff --git a/lib/src/services/databases/calendar/interface.dart b/lib/src/services/databases/calendar/interface.dart new file mode 100644 index 000000000..46dcd3569 --- /dev/null +++ b/lib/src/services/databases/calendar/interface.dart @@ -0,0 +1,22 @@ +/// Defines methods for the calendar data. +abstract class CalendarInterface { + /// Gets one month from the calendar. + /// + /// [month] is 1-12, not 0-11. The returned list contains a JSON form of each + /// day. Null days represent no school. + Future> getMonth(int month); + + /// Saves a month of the calendar. + /// + /// [month] is 1-12, not 0-11. See the [getMonth] for the structure of [json]. + Future setMonth(int month, List json); + + /// Gets all the schedules in the calendar. + /// + /// So far, schedules cannot be handled individually, since there is no way + /// to know which schedules need to be used and which don't. + Future> getSchedules(); + + /// Saves all the schedules. + Future setSchedules(List json); +} diff --git a/lib/src/services/databases/hybrid.dart b/lib/src/services/databases/hybrid.dart new file mode 100644 index 000000000..15a3c63df --- /dev/null +++ b/lib/src/services/databases/hybrid.dart @@ -0,0 +1,27 @@ +/// Bundles cloud and local databases together. +/// +/// The app uses an online and offline database for two reasons. +/// +/// 1. So the app can work offline +/// 2. So we don't hit quotas on our cloud database. +/// +/// [T] is an interface that describes what data a class needs to access for a +/// given category of data. [T] should be implemented twice, for online and +/// offline functionality. This class serves to tie them together. +/// +/// Simply extend this class and implement [T] and you now have a database that +/// can access both the cloud and the on-device database. Implement each method +/// of [T] by choosing which source to use for the data. +/// +/// The [signIn] method allows data to flow from the online to local database. +/// Use it to initialize any data the app expects be on-device. +abstract class HybridDatabase { + /// The interface for this data in the online database. + T get cloud; + + /// The interface for this data in the offline database. + T get local; + + /// Initializes any data necessary for sign-in + Future signIn(); +} diff --git a/lib/src/services/databases/reminders/hybrid.dart b/lib/src/services/databases/reminders/hybrid.dart new file mode 100644 index 000000000..6af0b2e12 --- /dev/null +++ b/lib/src/services/databases/reminders/hybrid.dart @@ -0,0 +1,41 @@ +import "../hybrid.dart"; + +import "implementation.dart"; +import "interface.dart"; + +/// Handles reminders in the cloud and on the device. +/// +/// Reminders are downloaded after sign-in, and are updated locally and online. +// ignore: lines_longer_than_80_chars +class HybridReminders extends HybridDatabase implements RemindersInterface { + @override + final CloudReminders cloud = CloudReminders(); + + @override + final LocalReminders local = LocalReminders(); + + @override + Future signIn() async { + for (final Map reminder in await cloud.getAll()) { + await local.set(reminder); + } + } + + @override + Future> getAll() => local.getAll(); + + @override + Future set(Map json) async { + await cloud.set(json); + await local.set(json); + } + + @override + Future delete(String id) async { + await cloud.delete(id); + await local.delete(id); + } + + /// Generates a unique ID for a new reminder. + String getId() => cloud.getId(); +} diff --git a/lib/src/services/databases/reminders/implementation.dart b/lib/src/services/databases/reminders/implementation.dart new file mode 100644 index 000000000..a24e2f8a3 --- /dev/null +++ b/lib/src/services/databases/reminders/implementation.dart @@ -0,0 +1,49 @@ +import "../../firestore.dart"; +import "../../idb.dart"; + +import "../user/implementation.dart"; + +import "interface.dart"; + +/// Handles reminder data in the cloud. +/// +/// Reminders are stored in a subcollection of the user document, where each +/// document has the reminder id as its ID. +class CloudReminders implements RemindersInterface { + /// The reminders subcollection for this user. + static CollectionReference get reminders => CloudUser.userDocument + .collection("reminders"); + + @override + Future> getAll() => reminders.getAll(); + + @override + Future set(Map json) => reminders.doc(json ["id"]) + .set(Map.from(json)); + + @override + Future delete(String id) => reminders.doc(id).delete(); + + /// Generates a random ID. + String getId() => reminders.doc().id; +} + +/// Handles reminder data in the on-device database. +/// +/// Reminders are stored in an object store where the keypath is `id`. +class LocalReminders implements RemindersInterface { + @override + Future> getAll() => Idb.instance.getAll(Idb.reminderStoreName); + + @override + Future set(Map json) => Idb.instance.update( + storeName: Idb.reminderStoreName, + value: json, + ); + + @override + Future delete(String id) => Idb.instance.delete( + storeName: Idb.reminderStoreName, + key: id, + ); +} diff --git a/lib/src/services/databases/reminders/interface.dart b/lib/src/services/databases/reminders/interface.dart new file mode 100644 index 000000000..942d42240 --- /dev/null +++ b/lib/src/services/databases/reminders/interface.dart @@ -0,0 +1,16 @@ +/// Defines methods for the reminder data. +/// +/// Reminders are indexed by a unique ID that doesn't change. This ID should be +/// placed in `json ["id"]`. +abstract class RemindersInterface { + /// Gets all the user's reminders. + Future> getAll(); + + /// Updates a single reminder. + /// + /// If the reminder already exists, it will be overwritten. + Future set(Map json); + + /// Deletes the reminder at the given id. + Future delete(String id); +} diff --git a/lib/src/services/databases/schedule/hybrid.dart b/lib/src/services/databases/schedule/hybrid.dart new file mode 100644 index 000000000..75015cd39 --- /dev/null +++ b/lib/src/services/databases/schedule/hybrid.dart @@ -0,0 +1,38 @@ +import "../hybrid.dart"; + +import "implementation.dart"; +import "interface.dart"; + +/// Handles user data in the cloud and on the device. +/// +/// Courses are downloaded one-by-one after sign-in. However, which courses to +/// download is unknown until the user profile is parsed. Therefore, [signIn] +/// is left empty, and the logic should instead go to middleware. +// ignore: lines_longer_than_80_chars +class HybridSchedule extends HybridDatabase implements ScheduleInterface { + @override + final ScheduleInterface cloud = CloudSchedule(); + + @override + final ScheduleInterface local = LocalSchedule(); + + @override + Future signIn() async { } + + @override + Future getCourse(String id) async { + Map? result = await local.getCourse(id); + if (result == null) { + final Map course = (await cloud.getCourse(id))!; + result = course; + await local.setCourse(id, result); + } + return result; + } + + @override + Future setCourse(String id, Map json) async { + await cloud.setCourse(id, json); + await local.setCourse(id, json); + } +} diff --git a/lib/src/services/databases/schedule/implementation.dart b/lib/src/services/databases/schedule/implementation.dart new file mode 100644 index 000000000..c7df28d2a --- /dev/null +++ b/lib/src/services/databases/schedule/implementation.dart @@ -0,0 +1,35 @@ +import "../../firestore.dart"; +import "../../idb.dart"; + +import "interface.dart"; + +/// Handles schedule date in the cloud. +/// +/// Each course is its own document in the `classes` collection. The keys are +/// the courses section-IDs, so they're really "sections", not courses. +class CloudSchedule implements ScheduleInterface { + /// The courses collection in Firestore. + static final CollectionReference courses = Firestore.instance + .collection("classes"); + + @override + Future getCourse(String id) => courses.doc(id) + .throwIfNull("Course $id not found"); + + @override + Future setCourse(String id, Map json) async { } +} + +/// Handles schedule data on the device. +/// +/// Each course is its own record in the `sections` object store, whose +/// keypath is the `id` field (the section-IDs). +class LocalSchedule implements ScheduleInterface { + @override + Future getCourse(String id) => Idb.instance + .get(Idb.sectionStoreName, id); + + @override + Future setCourse(String id, Map json) => Idb.instance + .update(storeName: Idb.sectionStoreName, value: json); +} diff --git a/lib/src/services/databases/schedule/interface.dart b/lib/src/services/databases/schedule/interface.dart new file mode 100644 index 000000000..afc124269 --- /dev/null +++ b/lib/src/services/databases/schedule/interface.dart @@ -0,0 +1,8 @@ +/// Defines methods for the schedule data. +abstract class ScheduleInterface { + /// Gets a course from the database. + Future getCourse(String id); + + /// Saves a course into the database. + Future setCourse(String id, Map json); +} diff --git a/lib/src/services/databases/sports/hybrid.dart b/lib/src/services/databases/sports/hybrid.dart new file mode 100644 index 000000000..6c1d7db34 --- /dev/null +++ b/lib/src/services/databases/sports/hybrid.dart @@ -0,0 +1,29 @@ +import "../hybrid.dart"; + +import "implementation.dart"; +import "interface.dart"; + +/// Handles sports in the cloud and on the device. +/// +/// Sports are downloaded after sign-in, and should be updated on a weekly +/// basis. They should also be manually refreshable. +// ignore: lines_longer_than_80_chars +class HybridSports extends HybridDatabase implements SportsInterface { + @override + final SportsInterface cloud = CloudSports(); + + @override + final SportsInterface local = LocalSports(); + + @override + Future signIn() async => local.setAll(await cloud.getAll()); + + @override + Future> getAll() => local.getAll(); + + @override + Future setAll(List json) async { + await cloud.setAll(json); + await local.setAll(json); + } +} diff --git a/lib/src/services/databases/sports/implementation.dart b/lib/src/services/databases/sports/implementation.dart new file mode 100644 index 000000000..1f5c47182 --- /dev/null +++ b/lib/src/services/databases/sports/implementation.dart @@ -0,0 +1,55 @@ +import "../../firestore.dart"; +import "../../idb.dart"; + +import "interface.dart"; + +/// Handles sports data in the cloud. +/// +/// Sports games are in documents by school year. See [schoolYear] for how that +/// is determined. Each document has a `games` field with a list of games. +class CloudSports implements SportsInterface { + /// The collection for sports games. + static final CollectionReference> sports = Firestore + .instance.collection("sports2"); + + /// The current school year. + /// + /// For example, in the '20-'21 school year, this returns `2020`. + static int get schoolYear { + final DateTime now = DateTime.now(); + final int currentYear = now.year; + final int currentMonth = now.month; + return currentMonth > 7 ? currentYear : currentYear - 1; + } + + /// The document for the current year. + static DocumentReference> get gamesDocument => + sports.doc(schoolYear.toString()); + + @override + Future> getAll() async { + final Map json = await gamesDocument + .throwIfNull("Could not find sports games for this year"); + return List.from(json ["games"]); + } + + @override + Future setAll(List json) => gamesDocument.set({"games": json}); +} + +/// Handles sports data in the on-device database. +/// +/// Each sports game is another entry in the sports table. For compatibility +/// with the cloud database, sports are updated in batch. +class LocalSports implements SportsInterface { + @override + Future> getAll() => Idb.instance.getAll(Idb.sportsStoreName); + + @override + Future setAll(List json) async { + await Idb.instance.clearObjectStore(Idb.sportsStoreName); + for (final Map game in json) { + await Idb.instance.update(storeName: Idb.sportsStoreName, value: game); + } + } +} diff --git a/lib/src/services/databases/sports/interface.dart b/lib/src/services/databases/sports/interface.dart new file mode 100644 index 000000000..7e6ef1a70 --- /dev/null +++ b/lib/src/services/databases/sports/interface.dart @@ -0,0 +1,10 @@ +/// Defines methods for the sports data. +/// +/// Sports are split by school year. +abstract class SportsInterface { + /// Gets all the sports games for this year. + Future> getAll(); + + /// Sets the sports games for this year. + Future setAll(List json); +} diff --git a/lib/src/services/databases/user/hybrid.dart b/lib/src/services/databases/user/hybrid.dart new file mode 100644 index 000000000..497162a05 --- /dev/null +++ b/lib/src/services/databases/user/hybrid.dart @@ -0,0 +1,31 @@ +import "../hybrid.dart"; + +import "implementation.dart"; +import "interface.dart"; + +/// Handles user data in the cloud and on the device. +/// +/// User profile is loaded once, on sign-in. Once this is complete, +/// the full profile is always assumed to be on-device. +class HybridUser extends HybridDatabase implements UserInterface { + @override + final UserInterface cloud = CloudUser(); + + @override + final UserInterface local = LocalUser(); + + @override + Future signIn() async { + final Map userData = await cloud.getProfile(); + await local.setProfile(userData); + } + + @override + Future getProfile() => local.getProfile(); + + @override + Future setProfile(Map json) async { + await cloud.setProfile(json); + await local.setProfile(json); + } +} diff --git a/lib/src/services/databases/user/implementation.dart b/lib/src/services/databases/user/implementation.dart new file mode 100644 index 000000000..173aab033 --- /dev/null +++ b/lib/src/services/databases/user/implementation.dart @@ -0,0 +1,44 @@ +import "../../auth.dart"; +import "../../firestore.dart"; +import "../../idb.dart"; + +import "interface.dart"; + +/// Handles user data in the cloud database. +/// +/// User profiles are saved under the "students" collection where document +/// keys are the user's email, guaranteed to be unique. +/// +/// Users cannot currently modify their own profiles. Use the Admin SDK instead. +class CloudUser implements UserInterface { + /// The users collection in firestore. + static final CollectionReference users = Firestore.instance + .collection("students"); + + /// The document for this user. + static DocumentReference get userDocument => users.doc(Auth.email!); + + @override + Future getProfile() => userDocument + .throwIfNull("User not in the database"); + + // Users cannot currently edit their own profiles. + @override + Future setProfile(Map json) async { } +} + +/// Handles user data in the local database. +/// +/// The user is stored as the only record in the user's table. +class LocalUser implements UserInterface { + @override + Future getProfile() => Idb.instance.throwIfNull( + storeName: Idb.userStoreName, + key: Auth.email!, + message: "User email innaccessible", + ); + + @override + Future setProfile(Map json) => Idb.instance + .update(storeName: Idb.userStoreName, value: json); +} diff --git a/lib/src/services/databases/user/interface.dart b/lib/src/services/databases/user/interface.dart new file mode 100644 index 000000000..f891a1d1f --- /dev/null +++ b/lib/src/services/databases/user/interface.dart @@ -0,0 +1,8 @@ +/// Defines methods for the user profile. +abstract class UserInterface { + /// The user profile. + Future getProfile(); + + /// Sets the user profile. + Future setProfile(Map json); +} diff --git a/lib/src/services/fcm.dart b/lib/src/services/fcm.dart deleted file mode 100644 index 3b807bf39..000000000 --- a/lib/src/services/fcm.dart +++ /dev/null @@ -1,2 +0,0 @@ -export "fcm/stub.dart" - if (dart.library.io) "fcm/mobile.dart"; \ No newline at end of file diff --git a/lib/src/services/fcm/mobile.dart b/lib/src/services/fcm/mobile.dart deleted file mode 100644 index 2516a5381..000000000 --- a/lib/src/services/fcm/mobile.dart +++ /dev/null @@ -1,98 +0,0 @@ -import "dart:convert" show JsonUnsupportedObjectError; -import "package:firebase_messaging/firebase_messaging.dart"; - -/// Callback that expects no arguments and returns no data. -typedef VoidCallback = Future Function(); - -// ignore: avoid_classes_with_only_static_members -/// An abstraction around Firebase Cloud Messaging. -/// -/// The app can receive a notification from Firebase at any time. -/// What it does with the notification depends on when it was received: -/// -/// - If the app is in the foreground, `onMessage` is called. -/// - If the app is in the background: -/// - If it's a notification, `onResume` is called when the app starts. -/// - Otherwise, `onMessage` is called. -/// - If the app is terminated, `onLaunch` will be called when the app is -/// opened. -/// -/// In any case, notification configuration is handled by -/// [registerNotifications], which assigns the same callback to all cases. -/// The callbacks can be registered by passing in a map to -/// [registerNotifications]. The value of the `command` field will be used as -/// the key to the map parameter. See [registerNotifications] for more details. -class FCM { - static final FirebaseMessaging _firebase = FirebaseMessaging(); - - /// A list of topics to subscribe to. - /// - /// Notifications sent with these topics will be received by the app. - static const List topics = ["calendar", "sports"]; - - /// Returns the device's FCM token - static Future get token => _firebase.getToken(); - - /// Registers a group of callbacks with Firebase Cloud Messaging. - /// - /// The callbacks should be a map of command keys and functions. - /// The value of the `command` field of the data message will be passed - /// as the key to the [commands]. This function should be called from a scope - /// with access to data models and services. - static Future registerNotifications( - Map commands - ) async { - // First, get permission on iOS: - _firebase.requestNotificationPermissions(); - - /// Calls the correct function based on the data message. - /// - /// This function handles validation of the notification and looks - /// up the correct callback function based on the command in the - /// data payload of the notification. - Future callback(Map message) async { - // DO NOT TRY TO GIVE THIS TYPE ARGUMENTS - // For some reason adding Map won't let the code - // continue, not even throwing an error. I think I spent like an - // hour debugging this with 0 progress whatsoever. Attempt at - // your own risk, but you've been warned. - final Map data = message["data"] ?? message; - - final String command = data ["command"]; - if (command == null) { - throw JsonUnsupportedObjectError( - message, - cause: "Data payload doesn't contain a 'command' field'", - partialResult: data.toString(), - ); - } - - // Same warning about types applies here. - final function = commands [command]; - if (function == null) { - throw ArgumentError.value( - command, - "Command", - "The 'command' field of the Firebase Cloud Message must be one of: " - "${commands.keys.toList().join(", ")}" - ); - } else { - await function(); - } - } - - // Register the callback - _firebase.configure( - onMessage: callback, - onResume: callback, - onLaunch: callback, - ); - } - - /// Subscribes to all the topics in [topics]. - static Future subscribeToTopics() async { - for (final String topic in topics) { - await _firebase.subscribeToTopic(topic); - } - } -} diff --git a/lib/src/services/fcm/stub.dart b/lib/src/services/fcm/stub.dart deleted file mode 100644 index a1a1d2300..000000000 --- a/lib/src/services/fcm/stub.dart +++ /dev/null @@ -1,7 +0,0 @@ -class FCM { - static Future registerNotifications( - Map commands - ) async {} - - static Future subscribeToTopics() async {} -} diff --git a/lib/src/services/firebase_core.dart b/lib/src/services/firebase_core.dart new file mode 100644 index 000000000..a54198807 --- /dev/null +++ b/lib/src/services/firebase_core.dart @@ -0,0 +1,38 @@ +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_auth/firebase_auth.dart"; +import "package:firebase_core/firebase_core.dart"; + +import "package:ramaz/firebase_options.dart"; + +/// A wrapper around [Firebase]. +/// +/// Firebase needs to be initialized before any Firebase products can be used. +/// However, it is an error to initialize Firebase more than once. To simplify +/// the process, we register Firebase as a separate service that can keep track +/// of whether it has been initialized. +class FirebaseCore { + /// Whether the Firebase Local Emulator Suite should be used. + static bool shouldUseEmulator = false; + + /// Whether Firebase has already been initialized. + static bool initialized = false; + + /// Initializes Firebase as configured by flutterfire_cli + static Future init() async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + if (shouldUseEmulator) { + await FirebaseAuth.instance.useAuthEmulator("localhost", 9099); + // Setting the emulator after a hot restart breaks Firestore. + // See: https://github.com/FirebaseExtended/flutterfire/issues/6216 + try { FirebaseFirestore.instance.useFirestoreEmulator("localhost", 8080); } + catch (error) { // throws a JavaScript object instead of a FirebaseException + final String code = (error as dynamic).code; + if (code != "failed-precondition") { + rethrow; + } + } + } + } +} diff --git a/lib/src/services/firestore.dart b/lib/src/services/firestore.dart new file mode 100644 index 000000000..6b8f557eb --- /dev/null +++ b/lib/src/services/firestore.dart @@ -0,0 +1,74 @@ +import "package:cloud_firestore/cloud_firestore.dart"; + +import "auth.dart"; +import "service.dart"; + +export "package:cloud_firestore/cloud_firestore.dart"; + +/// Convenience methods on [CollectionReference]. +extension CollectionHelpers on CollectionReference { + /// Returns a [DocumentReference] by querying a field. + Future findDocument(String field, String value) async { + final Query query = where(field, isEqualTo: value); + final QuerySnapshot querySnapshot = await query.get(); + final QueryDocumentSnapshot snapshot = querySnapshot.docs.first; + final DocumentReference document = snapshot.reference; + return document; + } + + /// Gets all the documents in a collection. + Future> getAll() async => [ + for (final QueryDocumentSnapshot doc in (await get()).docs) + doc.data() + ]; +} + +/// Convenience methods on [DocumentReference]. +extension DocumentHelpers on DocumentReference { + /// Gets data from a document, throwing if null. + Future throwIfNull(String message) async { + final Map? value = (await get()).data(); + if (value == null) { + throw StateError(message); + } else { + return value; + } + } +} + +/// The Firestore database service. +/// +/// This service does not provide any helper methods for data. That is reserved +/// for another service that will specify which data it is responsible for. +/// This service just manages the connection to Firestore. +/// +/// This class provides the basic initialization behind Firestore, and +/// doesn't expose any methods that will help retreive data. Any methods that +/// are provided don't have an offline equivalent. +class Firestore extends DatabaseService { + /// The singleton instance of this service. + static final FirebaseFirestore instance = FirebaseFirestore.instance; + + @override + Future init() async {} + + @override + Future signIn() => Auth.signIn(); + + @override + Future signOut() => Auth.signOut(); + + /// Submits feedback to the feedback collection. + Future sendFeedback(Map json) => instance.collection("feedback").doc() + .set(Map.from(json)); + + /// Listens to a month for changes in the calendar. + Stream> getCalendarStream(int month) => instance + .collection("calendar").doc(month.toString()).snapshots().map( + (DocumentSnapshot> snapshot) => [ + for (final dynamic entry in snapshot.data()! ["calendar"]) + if (entry == null) null + else Map.from(entry) + ] + ); +} diff --git a/lib/src/services/idb.dart b/lib/src/services/idb.dart new file mode 100644 index 000000000..89bd0876d --- /dev/null +++ b/lib/src/services/idb.dart @@ -0,0 +1,172 @@ +import "package:idb_shim/idb_shim.dart"; + +import "local_db/idb_factory_stub.dart" + if (dart.library.io) "local_db/idb_factory_io.dart" + if (dart.library.html) "local_db/idb_factory_web.dart"; + +import "service.dart"; + +/// Helper functions for [ObjectStore]. +extension ObjectStoreHelpers on ObjectStore { + /// Gets the data at the key in this object store. + Future get(Object key) async { + final dynamic result = await getObject(key); + return result == null ? null : + Map.from(result); + } +} + +/// Provides convenience methods on a [Database]. +/// +/// This extension mostly abstracts details of IDB and handles type-safety. +extension DatabaseHelpers on Database { + /// Gets data at a key in an object store. + Future get(String storeName, Object key) => + transaction(storeName, idbModeReadOnly) + .objectStore(storeName) + .get(key); + + /// Gets data from an object store, throwing if it doesn't exist. + Future throwIfNull({ + required String storeName, + required Object key, + required String message, + }) async { + final Map? result = await get(storeName, key); + if (result == null) { + throw StateError(message); + } else { + return result; + } + } + + /// Sets data in an object store, overwriting if necessary. + Future update({ + required String storeName, + required Object value, + Object? key + }) => transaction(storeName, idbModeReadWrite) + .objectStore(storeName) + .put(value, key); + + /// Deletes an object with the given key from an object store. + Future delete({ + required String storeName, + required Object key, + }) => transaction(storeName, idbModeReadWrite) + .objectStore(storeName) + .delete(key); + + /// Deletes all the data in an object store + Future clearObjectStore(String storeName) => + transaction(storeName, idbModeReadWrite) + .objectStore(storeName) + .clear(); + + /// Gets all the data in an object store. + Future> getAll(String storeName) async => [ + for ( + final dynamic entry in + await transaction(storeName, idbModeReadOnly) + .objectStore(storeName).getAll() + ) Map.from(entry) + ]; +} + +/// A database that's hosted on the user's device. +/// +/// On mobile, the database is based on a complex JSON file. On web, the browser +/// has a built-in database called IndexedDb (idb for short). The mobile +/// implementation is built to match the idb schema. +/// +/// In idb, a table is called an "object store". There are two ways of +/// identifying rows: either by a key (a unique column), or an auto-incrementing +/// value. The choice should be made based on the data in that object store. +/// +/// Reading and writing data is done with transactions. This process is +/// abstracted by extensions like [DatabaseHelpers] and [ObjectStoreHelpers]. +/// +/// Another quirk of idb is that object stores can only be created on startup. +/// One way this is relevant is sign-in. If it turns out that the user +/// is not signed in, it would be too late to create new object stores. That's +/// why [init] creates new object stores, so that it runs right away. +/// +/// Another consequence of having to consolidate object store creation in the +/// very beginning is that there is a strict way of migrating from one database +/// schema to another. Each database schema has a version number. When the app +/// starts, the [init] function checks to see what version the database is on. +/// If the code demands a new version, there must be code to create and destroy +/// object stores until the schemas match. The simplest way to do that is by +/// using a switch statement. A switch statement cascades, meaning the changes +/// from one version to another will follow each other, which should always +/// lead to an up-to-date schema. +class Idb extends DatabaseService { + /// The idb Database object + static late final Database instance; + + /// The name for the users object store. + static const String userStoreName = "users"; + + /// The name for the sections object store. + static const String sectionStoreName = "sections"; + + /// The name for the calendar object store. + static const String calendarStoreName = "calendar"; + + /// The name for the reminders object store. + static const String reminderStoreName = "reminders"; + + /// The name for the sports object store. + static const String sportsStoreName = "sports"; + + /// The name for the schedules object store. + static const String scheduleStoreName = "schedules"; + + /// The names of all the object stores. + static const List storeNames = [ + userStoreName, + sectionStoreName, + calendarStoreName, + reminderStoreName, + sportsStoreName, + scheduleStoreName, + ]; + + @override + Future init() async { + final IdbFactory _factory = await idbFactory; + try { + instance = await _factory.open( + "ramlife.db", + version: 1, + onUpgradeNeeded: (VersionChangeEvent event) { + switch(event.oldVersion) { + case 0: event.database + ..createObjectStore(userStoreName, keyPath: "email") + ..createObjectStore(sectionStoreName, keyPath: "id") + ..createObjectStore(calendarStoreName, keyPath: "month") + ..createObjectStore(reminderStoreName, keyPath: "id") + ..createObjectStore(sportsStoreName, autoIncrement: true) + ..createObjectStore(scheduleStoreName, keyPath: "name"); + } + }, + ); + } on StateError { + await _factory.deleteDatabase("ramlife.db"); + return init(); + } + } + + @override + Future signIn() async { } + + @override + Future signOut() async { + final Transaction transaction = instance + .transactionList(storeNames, idbModeReadWrite); + + for (final String storeName in storeNames) { + await transaction.objectStore(storeName).clear(); + } + } +} diff --git a/lib/src/services/local_db.dart b/lib/src/services/local_db.dart deleted file mode 100644 index 06130ff58..000000000 --- a/lib/src/services/local_db.dart +++ /dev/null @@ -1,175 +0,0 @@ -import "package:idb_shim/idb_shim.dart"; - -import "auth.dart"; // for user email -import "local_db/idb_factory.dart"; // for platform-specific database -import "service.dart"; - -/// Provides convenience methods around an [ObjectStore]. -extension ObjectExtension on ObjectStore { - Future get(dynamic key) async => await getObject(key) as T; -} - -/// Provides convenience methods on a [Database]. -extension DatabaseExtension on Database { - Future get(String storeName, dynamic key) => - transaction(storeName, idbModeReadOnly) - .objectStore(storeName) - .get(key); - - Future add(String storeName, T value) => - transaction(storeName, idbModeReadWrite) - .objectStore(storeName) - .add(value); - - Future update(String storeName, T value) => - transaction(storeName, idbModeReadWrite) - .objectStore(storeName) - .put(value); - - Future>> getAll(String storeName) async => [ - for ( - final dynamic entry in - await transaction(storeName, idbModeReadOnly) - .objectStore(storeName).getAll() - ) Map.from(entry) - ]; -} - -class LocalDatabase implements Service { - static const String userStoreName = "users"; - static const String sectionStoreName = "sections"; - static const String calendarStoreName = "calendar"; - static const String reminderStoreName = "reminders"; - static const String adminStoreName = "admin"; - static const String sportsStoreName = "sports"; - - static const List storeNames = [ - userStoreName, sectionStoreName, calendarStoreName, reminderStoreName, - adminStoreName, sportsStoreName, - ]; - - Database database; - - @override - Future get isReady async { - database = await (await idbFactory).open( - "ramaz.db", - version: 1, - onUpgradeNeeded: (VersionChangeEvent event) { - database = event.database; // for access within [initialize]. - switch (event.oldVersion) { - case 0: // fresh install - initialize(); - break; - } - } - ); - return true; // if it weren't ready there would be an error - } - - /// Initializes the database. - /// - /// Simply opening the database is async, so that is done in [isReady], since - /// it is needed to initialize the service. However, modifying the database - /// is only allowed within [IdbFactory.open], so it cannot be held off until - /// login. Hence, this function should be omitted from the global service - /// manager's [initialize] function, and should instead be called from - /// within [isReady] (which is allowed, since it is async). - /// - /// Additionally, since the data itself is only provided in the service - /// manager's [initialize] function, this function doesn't have to worry about - /// retrieving data so much as structuring it. - @override - Future initialize() async => database - ..createObjectStore(userStoreName, keyPath: "email") - ..createObjectStore(sectionStoreName, keyPath: "id") - ..createObjectStore(calendarStoreName, keyPath: "month") - ..createObjectStore(reminderStoreName, autoIncrement: true) - ..createObjectStore(adminStoreName, keyPath: "email") - ..createObjectStore(sportsStoreName, autoIncrement: true); - - @override - Future reset() async { - final Transaction transaction = - database.transactionList(storeNames, idbModeReadWrite); - for (final String storeName in storeNames) { - await transaction.objectStore(storeName).clear(); - } - } - - @override - Future> get user async => - Map.from(await database.get(userStoreName, Auth.email)); - - @override - Future setUser(Map json) => - database.add(userStoreName, json); - - Future> getSection(String id) => - database.get(sectionStoreName, id); - - @override - Future>> getSections( - Set ids - ) async => { - for (final String id in ids) - id: await getSection(id) - }; - - @override - Future setSections(Map> json) async { - for (final Map entry in json.values) { - await database.add(sectionStoreName, entry); - } - } - - Future>> getMonth(int month) async { - final Map json = - Map.from(await database.get(calendarStoreName, month)); - return [ - for (final dynamic entry in json ["calendar"]) - Map.from(entry) - ]; - } - - @override - Future>>> get calendar async => [ - for (int month = 1; month <= 12; month++) - await getMonth(month) - ]; - - @override - Future setCalendar(int month, Map json) async { - await database.update(calendarStoreName, json); - } - - @override - Future>> get reminders => - database.getAll(reminderStoreName); - - @override - Future setReminders(List> json) async { - for (final Map entry in json) { - await database.update(reminderStoreName, entry); - } - } - - @override - Future> get admin async => - Map.from(await database.get(adminStoreName, Auth.email)); - - @override - Future setAdmin(Map json) => - database.update(adminStoreName, json); - - @override - Future>> get sports => - database.getAll(sportsStoreName); - - @override - Future setSports(List> json) async { - for (final Map entry in json) { - await database.update(sportsStoreName, entry); - } - } -} \ No newline at end of file diff --git a/lib/src/services/local_db/idb_factory.dart b/lib/src/services/local_db/idb_factory.dart deleted file mode 100644 index b2243ac7e..000000000 --- a/lib/src/services/local_db/idb_factory.dart +++ /dev/null @@ -1,3 +0,0 @@ -export "idb_factory_stub.dart" - if (dart.library.io) "idb_factory_io.dart" - if (dart.library.html) "idb_factory_web.dart"; \ No newline at end of file diff --git a/lib/src/services/local_db/idb_factory_io.dart b/lib/src/services/local_db/idb_factory_io.dart index 2ced90866..9f7b5c837 100644 --- a/lib/src/services/local_db/idb_factory_io.dart +++ b/lib/src/services/local_db/idb_factory_io.dart @@ -1,7 +1,10 @@ -import "package:idb_shim/idb_shim.dart"; import "package:idb_shim/idb_io.dart"; +import "package:idb_shim/idb_shim.dart"; import "package:path_provider/path_provider.dart"; +/// Provides access to an IndexedDB implementation. +/// +/// The mobile implementation is based on a .json file. Future get idbFactory async => getIdbFactoryPersistent( (await getApplicationDocumentsDirectory()).path ); diff --git a/lib/src/services/local_db/idb_factory_stub.dart b/lib/src/services/local_db/idb_factory_stub.dart index 27f2c1348..655608e2b 100644 --- a/lib/src/services/local_db/idb_factory_stub.dart +++ b/lib/src/services/local_db/idb_factory_stub.dart @@ -1,3 +1,6 @@ import "package:idb_shim/idb_shim.dart"; +/// Provides access to an IndexedDB implementation. +/// +/// Throws an error on an unrecognized platform. Future get idbFactory => throw UnsupportedError("Unknown platform"); diff --git a/lib/src/services/local_db/idb_factory_web.dart b/lib/src/services/local_db/idb_factory_web.dart index 486bf35f5..95719d14a 100644 --- a/lib/src/services/local_db/idb_factory_web.dart +++ b/lib/src/services/local_db/idb_factory_web.dart @@ -1,4 +1,7 @@ -import "package:idb_shim/idb_shim.dart"; import "package:idb_shim/idb_browser.dart"; +import "package:idb_shim/idb_shim.dart"; +/// Provides access to an IndexedDB implementation. +/// +/// The browser has an implementation built-in. Future get idbFactory async => idbFactoryBrowser; diff --git a/lib/src/services/notifications.dart b/lib/src/services/notifications.dart index 9eed17103..28fa70258 100644 --- a/lib/src/services/notifications.dart +++ b/lib/src/services/notifications.dart @@ -1,214 +1,18 @@ -import "package:flutter_local_notifications/flutter_local_notifications.dart"; -import "package:flutter/material.dart" show Color, required, immutable; +import "package:meta/meta.dart"; -import "package:ramaz/constants.dart"; +import "notifications/stub.dart" + if (dart.library.io) "notifications/mobile.dart"; +import "service.dart"; -/// The style of the notification. +/// A notification. /// -/// Only applies to Android. -enum AndroidNotificationType { - /// A message notification from another person. - /// - /// This can be used for lost and found notifications. - message, - - /// A notification that contains an image. - /// - /// This can be used for the first post of lost and found notifications - /// that contain an image of the lost object. - image, - - /// A default notification with just text. - normal, -} - -/// Maps the abstract [AndroidNotificationType] to [AndroidNotificationStyle]s. -/// -/// This is done so other libraries that import this module do not need to -/// import `flutter_local_notifications`. -const Map< - AndroidNotificationType, - AndroidNotificationStyle -> androidNotificationTypes = { - AndroidNotificationType.message: AndroidNotificationStyle.Messaging, - AndroidNotificationType.image: AndroidNotificationStyle.BigPicture, - AndroidNotificationType.normal: AndroidNotificationStyle.Default, -}; - -/// A notification configuration for Android. -/// -/// This should be used instead of [AndroidNotificationDetails] so that other -/// other libraries can import this module without importing the plugin. -@immutable -class AndroidNotification { - /// The importance of this notification. - /// - /// An importance level for Android 8+ - final Importance importance; - - /// The priority of this notification. - /// - /// An importance level for Android 7.1 and lower. - final Priority priority; - - /// The type of this notification. - final AndroidNotificationType style; - - /// The style of this notification. - /// - /// This can be used to customize notifications about messages - /// and notifications that contain images. - final StyleInformation styleInfo; - - /// The color of this notification. - /// - /// Should relate to the app's branding colors. - final Color color; - - /// The ID of this notification's channel. - final String channelId; - - /// The name of this notification's channel. - final String channelName; - - /// The description of this notification's channel. - final String channelDescription; - - /// The icon for this notification. - /// - /// Defaults to the default app icon, but can be customized. - final String icon; - - /// The group ID for this notification. - /// - /// All notifications with the same group ID are bundled together. - final String groupId; - - /// Whether this notification should play sound. - final bool playSound; - - /// Whether this notification should vibrate the device. - final bool shouldVibrate; - - /// Whether this is the header of a group. - final bool isGroupSummary; - - /// Whether this notification's channel should cause the home screen - /// to show a badge on the app's icon. - final bool showChannelBadge; - - /// Whether this notification should cause the device's LED to blink. - final bool showLight; - - /// A const constructor for this class. - const AndroidNotification({ - @required this.importance, - @required this.priority, - @required this.style, - @required this.color, - @required this.channelId, - @required this.channelName, - @required this.channelDescription, - @required this.groupId, - @required this.playSound, - @required this.shouldVibrate, - @required this.isGroupSummary, - @required this.showChannelBadge, - @required this.showLight, - this.icon, - this.styleInfo, - }); - - /// An optimal Android notification configuration for reminders. - /// - /// If [root] is true, the notification is considered the group - /// "summary", which is like a header for notifications. - const AndroidNotification.reminder({bool root = false}) : - importance = Importance.High, - priority = Priority.High, - style = AndroidNotificationType.normal, - color = RamazColors.blue, - channelId = "reminders", - channelName = "Reminders", - channelDescription = "When reminders are due.", - groupId = "reminders", - playSound = true, - shouldVibrate = true, - isGroupSummary = root, - showChannelBadge = true, - icon = null, - styleInfo = null, - showLight = true; - - /// Exposes the AndroidNotificationDetails for this notification. - AndroidNotificationDetails get details => AndroidNotificationDetails( - channelId, - channelName, - channelDescription, - icon: icon, - importance: importance, - priority: priority, - style: androidNotificationTypes [style], - styleInformation: styleInfo, - playSound: playSound, - enableVibration: shouldVibrate, - groupKey: groupId, - setAsGroupSummary: isGroupSummary, - groupAlertBehavior: GroupAlertBehavior.All, - autoCancel: true, - ongoing: false, - color: color, - onlyAlertOnce: true, - channelShowBadge: showChannelBadge, - enableLights: showLight, - ledColor: color, - ledOnMs: 1000, - ledOffMs: 5000, - ); -} - -/// A notification configuration for iOS. -/// -/// This should be used instead of [IOSNotificationDetails] so that other -/// other libraries can import this module without importing the plugin. -@immutable -class IOSNotification { - /// An optimal [IOSNotification] for reminders. - static const IOSNotification reminder = IOSNotification( - showBadge: true, - playSound: true - ); - - /// Whether this notification should cause the device's home screen - /// to show a badge on this notification's icon. - final bool showBadge; - - /// Whether this notification should cause the device to play a sound. - final bool playSound; - - /// A const constructor for this class. - const IOSNotification({ - @required this.showBadge, - @required this.playSound - }); - - /// The [IOSNotificationDetails] for this notification. - IOSNotificationDetails get details => IOSNotificationDetails( - presentAlert: true, - presentSound: playSound, - presentBadge: showBadge, - ); -} - -/// A platform-agnostic notification. -/// -/// An [AndroidNotification] and an [IOSNotification] needs to be provided. +/// The notification has a [title] and a [message]. @immutable class Notification { /// The ID of this notification. /// - /// The ID is used for cancelling the notifications. - final int id = 0; + /// The ID is used for canceling the notifications. + static const int id = 0; /// The title of this notification. final String title; @@ -216,73 +20,48 @@ class Notification { /// The body of this notification. final String message; - /// The platform-agnostic [NotificationDetails] for this class. - final NotificationDetails details; - - /// Returns a new [Notification]. - /// - /// [android] and [ios] are used to make [details]. - Notification({ - @required this.title, - @required this.message, - @required AndroidNotification android, - @required IOSNotification ios, - }) : details = NotificationDetails( - android.details, ios.details, - ); + /// Creates a notification. + const Notification({ + required this.title, + required this.message, + }); - /// The optimal configuration for a reminder notification. - Notification.reminder({ - @required this.title, - @required this.message, - bool root = false - }) : - details = NotificationDetails( - AndroidNotification.reminder(root: root).details, - IOSNotification.reminder.details, - ); + /// Creates a notification for a reminder. + factory Notification.reminder({ + required String title, + required String message, + }) => getReminderNotification(title: title, message: message); } -// ignore: avoid_classes_with_only_static_members -/// An abstract wrapper around the notifications plugin. +/// The notifications service. +/// +/// There are two types of notifications: local notifications, and push +/// notifications. Local notifications are sent by the app itself, and +/// push notifications are sent by the server. These are local notifications. /// -/// This class uses static methods to send and schedule -/// notifications. -class Notifications { - static final _plugin = FlutterLocalNotificationsPlugin() - ..initialize( - const InitializationSettings( - AndroidInitializationSettings( - "@mipmap/bright_yellow" // default icon of app - ), - IOSInitializationSettings(), // defaults are good - ) - ); +/// Local notifications can be customized to appear differently depending on +/// the type of notification and platform. They can also be scheduled to appear +/// at certain times. +/// +/// Currently, Web is not supported. +abstract class Notifications extends Service { + /// The singleton instance for this service. + static Notifications instance = notifications; /// Sends a notification immediately. - static void sendNotification(Notification notification) => _plugin.show( - notification.id, - notification.title, - notification.message, - notification.details, - ); + void sendNotification(covariant Notification notification); /// Schedules a notification for [date]. /// - /// If [date] is in the past, the notification will go off immediately. - static void scheduleNotification({ - @required Notification notification, - @required DateTime date, - }) => _plugin.schedule( - notification.id, - notification.title, - notification.message, - date, - notification.details, - androidAllowWhileIdle: true, - ); + /// [date] must not be in the past. + void scheduleNotification({ + required covariant Notification notification, + required DateTime date + }); + + /// Cancels all notifications. + void cancelAll(); - /// Cancels all scheduled notifications, as well as - /// dismissing all present notifications. - static void cancelAll() => _plugin.cancelAll(); + /// Notifications that are pending delivery. + Future> get pendingNotifications; } diff --git a/lib/src/services/notifications/mobile.dart b/lib/src/services/notifications/mobile.dart new file mode 100644 index 000000000..3f3a29a4d --- /dev/null +++ b/lib/src/services/notifications/mobile.dart @@ -0,0 +1,131 @@ +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +import "package:flutter_native_timezone/flutter_native_timezone.dart"; +import "package:timezone/data/latest.dart"; +import "package:timezone/timezone.dart"; + +// ignore: directives_ordering +import "package:ramaz/constants.dart"; + +import "../notifications.dart"; + +/// Creates a reminders notification using [MobileNotification.reminder]. +Notification getReminderNotification({ + required String title, + required String message, +}) => MobileNotification.reminder(title: title, message: message); + +/// The mobile implementation of the [Notifications] service. +Notifications get notifications => MobileNotifications(); + +/// The mobile version of a notification. +/// +/// A [MobileNotification] has a [NotificationDetails] to control how the +/// notification appears on the device. +class MobileNotification extends Notification { + /// Describes how a reminders notification should look. + static NotificationDetails reminderDetails = const NotificationDetails( + android: AndroidNotificationDetails( + "reminders", + "Reminders", + "When reminders are due.", + importance: Importance.high, + priority: Priority.high, + color: RamazColors.blue, + groupKey: "reminders", + playSound: true, + enableVibration: true, + setAsGroupSummary: false, + channelShowBadge: true, + icon: null, + styleInformation: null, + enableLights: true, + ), + iOS: IOSNotificationDetails( + presentBadge: true, + presentSound: true + ) + ); + + /// The platform-agnostic [NotificationDetails] for this class. + final NotificationDetails details; + + /// Creates a new [Notification]. + const MobileNotification({ + required String title, + required String message, + required this.details, + }) : super(title: title, message: message); + + /// The optimal configuration for a reminder notification. + MobileNotification.reminder({ + required String title, + required String message, + }) : + details = reminderDetails, + super(title: title, message: message); +} + +/// The mobile implementation of the notifications service. +class MobileNotifications extends Notifications { + /// The plugin on mobile. + final plugin = FlutterLocalNotificationsPlugin(); + + /// The location this device is in. + late String timezoneName; + + /// The location (and timezones) this device is in. + late Location location; + + @override + Future init() async { + await plugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings( + "@mipmap/bright_yellow" // default icon of app + ), + iOS: IOSInitializationSettings(), // defaults are good + ) + ); + initializeTimeZones(); + timezoneName = await FlutterNativeTimezone.getLocalTimezone(); + location = getLocation(timezoneName); + } + + @override + Future signIn() async {} + + @override + void sendNotification(MobileNotification notification) => plugin.show( + Notification.id, + notification.title, + notification.message, + notification.details, + ); + + @override + void scheduleNotification({ + required MobileNotification notification, + required DateTime date, + }) => plugin.zonedSchedule( + Notification.id, + notification.title, + notification.message, + TZDateTime.from(date, location), + notification.details, + androidAllowWhileIdle: true, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.wallClockTime, + ); + + @override + void cancelAll() => plugin.cancelAll(); + + @override + Future> get pendingNotifications async => [ + for ( + final PendingNotificationRequest request in + await plugin.pendingNotificationRequests() + ) + request.title! + ]; +} diff --git a/lib/src/services/notifications/stub.dart b/lib/src/services/notifications/stub.dart new file mode 100644 index 000000000..9760c95a3 --- /dev/null +++ b/lib/src/services/notifications/stub.dart @@ -0,0 +1,40 @@ +import "../notifications.dart"; + +/// The web implementation of a reminders notification. +/// +/// Notifications are not yet supported on web. +Notification getReminderNotification({ + required String title, + required String message, +}) => Notification(title: title, message: message); + +/// The web implementation of the [Notifications] service. +/// +/// Notifications are not yet supported on web. +Notifications get notifications => StubNotifications(); + +/// The notifications service for the web. +/// +/// Notifications are not yet supported on web. +class StubNotifications extends Notifications { + @override + Future init() async {} + + @override + Future signIn() async {} + + @override + void sendNotification(Notification notification) {} + + @override + void scheduleNotification({ + required Notification notification, + required DateTime date + }) {} + + @override + void cancelAll() {} + + @override + Future> get pendingNotifications async => []; +} diff --git a/lib/src/services/preferences.dart b/lib/src/services/preferences.dart index d6fe69165..60b731ec6 100644 --- a/lib/src/services/preferences.dart +++ b/lib/src/services/preferences.dart @@ -1,21 +1,28 @@ import "package:shared_preferences/shared_preferences.dart"; +import "service.dart"; + /// An abstraction wrapper around the SharedPreferences plugin. /// /// The SharedPreferences plugin allows for quick and small key-value based /// storage, which can be very useful. -class Preferences { - final SharedPreferences _prefs; - - /// Const constructor for this class. - const Preferences(this._prefs); - +class Preferences extends Service { /// The key for if this is the first time or not. static const String firstTimeKey = "firstTime"; /// The key for the user brightness preference. static const String lightMode = "lightMode"; + late SharedPreferences _prefs; + + @override + Future init() async { + _prefs = await SharedPreferences.getInstance(); + } + + @override + Future signIn() async {} + /// Determines whether this is the first time opening the app. bool get firstTime { final bool result = _prefs.getBool(firstTimeKey) ?? true; @@ -27,6 +34,8 @@ class Preferences { /// /// `true` means light mode, `false` means dark mode, and `null` gets the /// system preferences (if not supported -- light mode). - bool get brightness => _prefs.getBool(lightMode); - set brightness (bool value) => _prefs.setBool(lightMode, value); + bool? get brightness => _prefs.getBool(lightMode); + set brightness (bool? value) => value == null + ? _prefs.remove(lightMode) + : _prefs.setBool(lightMode, value); } diff --git a/lib/src/services/push_notifications.dart b/lib/src/services/push_notifications.dart new file mode 100644 index 000000000..f24f4f4ab --- /dev/null +++ b/lib/src/services/push_notifications.dart @@ -0,0 +1,33 @@ +import "push_notifications/stub.dart" + if (dart.library.io) "push_notifications/mobile.dart"; + +import "service.dart"; + +/// Callback that expects no arguments and returns no data. +typedef AsyncCallback = Future Function(); + +/// An abstract wrapper around the notifications plugin. +/// +/// There are two types of notifications: local notifications, and push +/// notifications. Local notifications are sent by the app itself, and +/// push notifications are sent by the server. These are push notifications. +/// +/// Push notifications can trigger certain callbacks based on the content of +/// the notification. Use [registerForNotifications] to pass in callbacks and +/// use [subscribeToTopics] to specify what types of notifications the app +/// should be listening for. +abstract class PushNotifications extends Service { + /// The default implementation of [PushNotifications]. + static PushNotifications instance = getPushNotifications(); + + /// Registers async callbacks with push notifications. + /// + /// Each push notification has a `command` field. This function uses the value + /// of that field to find the correct function to run. + Future registerForNotifications(Map callbacks); + + /// Subscribes to certain topics. + /// + /// The server will notify the app when a notification is served to that topic. + Future subscribeToTopics(); +} diff --git a/lib/src/services/push_notifications/mobile.dart b/lib/src/services/push_notifications/mobile.dart new file mode 100644 index 000000000..c529eb1ce --- /dev/null +++ b/lib/src/services/push_notifications/mobile.dart @@ -0,0 +1,106 @@ +import "dart:convert" show JsonUnsupportedObjectError; +import "package:firebase_messaging/firebase_messaging.dart"; + +import "../push_notifications.dart"; + +/// Provides the correct implementation for push notifications. +/// +/// On mobile, uses Firebase Messaging. +PushNotifications getPushNotifications() => FCM(); + +/// Receives push notifications using Firebase Messaging. +/// +/// The app can receive a notification from Firebase at any time. +/// What it does with the notification depends on when it was received: +/// +/// - If the app is in the foreground, `onMessage` is called. +/// - If the app is in the background: +/// - If it's a notification, `onResume` is called when the app starts. +/// - Otherwise, `onMessage` is called. +/// - If the app is terminated, `onLaunch` will be called when the app is +/// opened. +/// +/// In any case, notification configuration is handled by +/// [registerForNotifications], which assigns the same callback to all cases. +/// The callbacks can be registered by passing in a map to +/// [registerForNotifications]. +/// +/// The value of the `command` field of the notification will be used as +/// the key to the map parameter. See [callback] for details. +class FCM extends PushNotifications { + /// A list of topics to subscribe to. + /// + /// Notifications sent with these topics will be received by the app. + static const List topics = ["calendar", "sports"]; + + /// Provides the connection to Firebase Messaging. + static final FirebaseMessaging firebase = FirebaseMessaging.instance; + + /// Maps command payloads to async functions. + late Map callbacks; + + @override + Future init() async { + await registerForNotifications( + { + // "refresh": initialize, + // "updateCalendar": updateCalendar, + // "updateSports": updateSports, + } + ); + await subscribeToTopics(); + } + + @override + Future signIn() async => firebase.requestPermission(); + + /// A callback to handle any notification. + /// + /// This function uses the `command` field of the notification to find the + /// right [AsyncCallback], and calls it. + Future callback( + Map message, + ) async { + final String? command = (message["data"] ?? message) ["command"]; + if (command == null) { + throw JsonUnsupportedObjectError( + message, + cause: "Data payload doesn't contain a 'command' field'", + partialResult: message.toString(), + ); + } + final AsyncCallback? function = callbacks [command]; + if (function == null) { + throw ArgumentError.value( + command, + "Command", + "The 'command' field of the Firebase Cloud Message must be one of: " + "${callbacks.keys.toList().join(", ")}" + ); + } + await function(); + } + + @override + Future registerForNotifications( + Map callbacks + ) async { + this.callbacks = callbacks; + + FirebaseMessaging.onBackgroundMessage((message) => callback(message.data)); + // replace with FirebaseMessaging.onMessage.listen(callback) + // firebase.configure( + // onMessage: callback, + // onLaunch: callback, + // onResume: callback, + // ); + } + + /// Subscribes to all the topics in [topics]. + @override + Future subscribeToTopics() async { + for (final String topic in topics) { + await firebase.subscribeToTopic(topic); + } + } +} diff --git a/lib/src/services/push_notifications/stub.dart b/lib/src/services/push_notifications/stub.dart new file mode 100644 index 000000000..842074568 --- /dev/null +++ b/lib/src/services/push_notifications/stub.dart @@ -0,0 +1,26 @@ +import "../push_notifications.dart"; + +/// Provides the correct implementation for push notifications. +/// +/// Currently, Firebase Messaging is not supported on web, so this function +/// provides a blank implementation. +PushNotifications getPushNotifications() => PushNotificationsStub(); + +/// Receives push notifications using Firebase Messaging. +/// +/// Currently, Firebase Messaging does not support web. +class PushNotificationsStub extends PushNotifications { + @override + Future init() async {} + + @override + Future signIn() async {} + + @override + Future registerForNotifications( + Map callbacks + ) async {} + + @override + Future subscribeToTopics() async {} +} diff --git a/lib/src/services/reader.dart b/lib/src/services/reader.dart deleted file mode 100644 index fbfa155c6..000000000 --- a/lib/src/services/reader.dart +++ /dev/null @@ -1,149 +0,0 @@ -import "dart:convert"; -import "dart:io"; - -import "service.dart"; - -/// An abstraction around the file system. -/// -/// This class handles reading and writing JSON to and from files. -/// Note that only raw data should be used with this class. Using -/// dataclasses will create a dependency on the data library. -class LocalDatabase implements Service { - /// The path for this app's file directory. - /// - /// Every app is provided a unique path in the file system by the OS. - /// Performing operations on files in this directory does not require - /// extra permissions. In other words, data belonging exclusively to - /// an app should reside in its given directory. - final String dir; - - /// The file containing the user's schedule. - final File userFile; - - /// The file containing data for all the classes in the user's schedule. - final File subjectFile; - - /// The file containing the calendar. - final Directory calendarDir; - - /// The file containing the user's reminders. - final File remindersFile; - - /// The file containing the admin profile. - final File adminFile; - - /// The file containing the sports games. - final File sportsFile; - - /// Initializes the files based on the path ([dir]) provided to it. - LocalDatabase(this.dir) : - userFile = File("$dir/student.json"), - subjectFile = File("$dir/subjects.json"), - calendarDir = Directory("$dir/calendar"), - adminFile = File("$dir/admin.json"), - sportsFile = File("$dir/sports.json"), - remindersFile = File("$dir/reminders.json"); - - @override - Future get isReady async => userFile.existsSync() - && subjectFile.existsSync() - && calendarDir.existsSync() - && remindersFile.existsSync() - && adminFile.existsSync() - && sportsFile.existsSync(); - - @override - Future reset() async { - if (userFile.existsSync()) { - userFile.deleteSync(); - } - if (subjectFile.existsSync()) { - subjectFile.deleteSync(); - } - if (calendarDir.existsSync()) { - calendarDir.deleteSync(recursive: true); - } - if (remindersFile.existsSync()) { - remindersFile.deleteSync(); - } - if (adminFile.existsSync()) { - adminFile.deleteSync(); - } - if (sportsFile.existsSync()) { - sportsFile.deleteSync(); - } - } - - @override - Future initialize() async { - if (!calendarDir.existsSync()) { - calendarDir.createSync(); - } - } - - @override - Future> get user async => jsonDecode( - await userFile.readAsString() - ); - - @override - Future setUser(Map json) => - userFile.writeAsString(jsonEncode(json)); - - @override - Future>> getSections(_) async => - jsonDecode(await subjectFile.readAsString()) - .map>( - (String id, dynamic json) => - MapEntry(id, Map.from(jsonDecode(json))) - ); - - @override - Future setSections(Map> json) => - subjectFile.writeAsString(jsonEncode(json)); - - @override - Future>>> get calendar async => [ - for (int month = 1; month <= 12; month++) [ - for (final dynamic json in - jsonDecode( - await File("${calendarDir.path}/${month.toString()}.json").readAsString() - ) - ) Map.from(json) - ] - ]; - - @override - Future setCalendar(int month, Map json) async { - final File file = File("${calendarDir.path}/${month.toString()}.json"); - await file.writeAsString(jsonEncode(json)); - } - - @override - Future>> get reminders async => [ - for (final dynamic json in jsonDecode(await remindersFile.readAsString())) - Map.from(json) - ]; - - @override - Future setReminders(List> json) => - remindersFile.writeAsString(jsonEncode(json)); - - @override - Future> get admin async => - jsonDecode(await adminFile.readAsString()); - - @override - Future setAdmin(Map json) => - adminFile.writeAsString(jsonEncode(json)); - - @override - Future>> get sports async => [ - for (final dynamic json in jsonDecode(await sportsFile.readAsString())) - Map.from(json) - ]; - - @override - Future setSports(List> json) => - sportsFile.writeAsString(jsonEncode(json)); -} diff --git a/lib/src/services/service.dart b/lib/src/services/service.dart index 4492e00f4..2399df688 100644 --- a/lib/src/services/service.dart +++ b/lib/src/services/service.dart @@ -1,83 +1,43 @@ -/// A data model for services. +/// A Service that interacts with third-party code. /// -/// All services must implement this class. It serves two main functions: +/// A service can be defined as a plugin which needs special code to interact +/// with, independent of the functionality of the service. For example, +/// authentication needs a lot of extra code, before you even get into the +/// details like sign-in providers and UI flow. /// -/// - Basic setup: the [isReady], [reset], and [initialize] methods. -/// - Data model: specifies which data is expected from the service. +/// This class is abstract and provides a structure to all services, which +/// should inherit from it. There are two functions that provide convenient +/// hooks for managing the life cycle of the service. +/// +/// The first one is the [init] function. The init function should contain code +/// that needs to be run when the app starts, whether or not the user is signed +/// in. +/// +/// The other function is the [signIn] function. The signIn function should +/// contain code that needs to be run when the user signs into the app. One +/// example can be notifications that need to request permissions when the +/// user logs in. +/// +/// Other than the [signIn] function, services should not care (or know) whether +/// the user is signed in or not. abstract class Service { - /// Whether this service is ready. - /// - /// If it's not ready, it was either never set up or misbehaving. - /// Call [reset] just in case. - Future get isReady; - - /// Resets the service as if the app were just installed. - /// - /// While this may delete data from local storage, it should not wipe data - /// from off-device sources, such as the database. It's sole purpose is to - /// help the service respond again. - /// - /// [reset] may be called when [isReady] is false, so it should have built-in - /// error handling. - Future reset(); - /// Initializes the service. /// - /// After calling this method, [isReady] should return true. - /// - /// Additionally, there may be other setup needed, that while may not be needed - /// for the service as a whole, may be done here as well. - /// - /// Note that this method will be called even when [isReady] is true, so make - /// make sure this function does not delete user data. - Future initialize(); - - /// The user object as JSON - Future> get user; - - /// Changes the user JSON object. - Future setUser(Map json); - - /// The different classes (sections, not courses) for a schedule. - Future>> getSections(Set ids); - - /// Changes the user's classes. - Future setSections(Map> json); + /// Override this function with code that needs to be run when the app starts. + /// A good use for this is registering with plugins that return a Future. + Future init(); - /// The calendar in JSON form. + /// A callback that runs when the user signs in. /// - /// Admins can change this with [setCalendar]. - Future>>> get calendar; - - /// Changes the calendar in the database. - /// - /// The fact that this method takes a [month] parameter while [calendar] does - /// not is an indicator that the calendar schema needs to be rewritten. - /// - /// [month] must be 1-12, not 0-11. - /// - /// Only admins can change this. - Future setCalendar(int month, Map json); - - /// The user's reminders. - Future>> get reminders; - - /// Changes the user's reminders. - Future setReminders(List> json); - - /// The admin object (or null). - Future> get admin; - - /// Sets the admin object for this user. - Future setAdmin(Map json); - - /// The sports games. - /// - /// Admins can change this with [setSports]. - Future>> get sports; + /// Override this function with code that facilitates the sign-in process. + Future signIn(); +} - /// Changes the sports games (for all users). - /// - /// Only admins can change this. - Future setSports(List> json); +/// A service specific to databases. +/// +/// Databases need to know when the user signs out, as they should unlink the +/// user from all the data. +abstract class DatabaseService extends Service { + /// Provides the database a chance to unlink all data from the user. + Future signOut(); } diff --git a/lib/src/widgets/ambient/brightness_changer.dart b/lib/src/widgets/ambient/brightness_changer.dart index 1b0089512..7632cf7c4 100644 --- a/lib/src/widgets/ambient/brightness_changer.dart +++ b/lib/src/widgets/ambient/brightness_changer.dart @@ -4,6 +4,15 @@ import "package:ramaz/services.dart"; import "theme_changer.dart" show ThemeChanger; +/// Returns a custom value if [value] is null, true, or false. +T caseConverter({ + required bool? value, + required T onNull, + required T onTrue, + required T onFalse, +}) => value == null ? onNull + : value ? onTrue : onFalse; + /// The form the [BrightnessChanger] widget should take. enum BrightnessChangerForm { /// The widget should appear as a toggle button. @@ -14,81 +23,78 @@ enum BrightnessChangerForm { } /// A widget to toggle the app between light mode and dark mode. -class BrightnessChanger extends StatelessWidget { - /// Returns a custom value if [value] is null, true, or false. - static T caseConverter({ - @required bool value, - @required T onNull, - @required T onTrue, - @required T onFalse, - }) => value == null ? onNull - : value ? onTrue : onFalse; - - /// The service to retrieve the user's preferences. - /// - /// This is used to save the brightness on next launch. - final Preferences prefs; - +class BrightnessChanger extends StatefulWidget { /// The form this widget should take. final BrightnessChangerForm form; - /// The icon for this widget. - final Icon icon; - /// Creates a widget to toggle the app brightness. - /// - /// This constructor determines the icon. - BrightnessChanger({@required this.prefs, @required this.form}) : - icon = Icon ( - caseConverter( - value: prefs.brightness, - onNull: Icons.brightness_auto, - onTrue: Icons.brightness_high, - onFalse: Icons.brightness_low, - ) - ), - assert (prefs != null, "Cannot load user preference"), - assert (form != null, "Cannot build widget without selected appearance"); + const BrightnessChanger({required this.form}); /// Creates a [BrightnessChanger] as a toggle button. - factory BrightnessChanger.iconButton({@required Preferences prefs}) => - BrightnessChanger (prefs: prefs, form: BrightnessChangerForm.button); + factory BrightnessChanger.iconButton() => + const BrightnessChanger(form: BrightnessChangerForm.button); /// Creates a [BrightnessChanger] as a drop-down menu. - factory BrightnessChanger.dropdown({@required Preferences prefs}) => - BrightnessChanger (prefs: prefs, form: BrightnessChangerForm.dropdown); + factory BrightnessChanger.dropdown() => + const BrightnessChanger(form: BrightnessChangerForm.dropdown); - @override Widget build (BuildContext context) { - switch (form) { - case BrightnessChangerForm.button: return IconButton ( + @override + BrightnessChangerState createState() => BrightnessChangerState(); +} + +/// The state for a [BrightnessChanger]. +class BrightnessChangerState extends State { + bool? _brightness; + + /// The icon for this widget. + Icon get icon => Icon ( + caseConverter( + value: _brightness, + onNull: Icons.brightness_auto, + onTrue: Icons.brightness_high, + onFalse: Icons.brightness_low, + ) + ); + + @override + void initState() { + super.initState(); + _brightness = Services.instance.prefs.brightness; + } + + @override + Widget build (BuildContext context) { + switch (widget.form) { + case BrightnessChangerForm.button: return IconButton( icon: icon, onPressed: () => buttonToggle(context), ); - - case BrightnessChangerForm.dropdown: return ListTile ( - title: const Text ("Change theme"), + case BrightnessChangerForm.dropdown: return ListTile( + title: const Text ("Theme"), leading: icon, - trailing: DropdownButton( - onChanged: (bool value) => setBrightness(context, value: value), - value: prefs.brightness, + trailing: DropdownButton( + onChanged: (bool? value) => setBrightness(context, value: value), + value: _brightness, + // Workaround until https://github.com/flutter/flutter/pull/77666 is released + // DropdownButton with null value don't display the menu item. + // Using a hint works too + hint: const Text("Auto"), items: const [ - DropdownMenuItem ( + DropdownMenuItem ( value: null, - child: Text ("Automatic") + child: Text ("Auto") ), - DropdownMenuItem ( + DropdownMenuItem ( value: true, - child: Text ("Light theme") + child: Text ("Light") ), - DropdownMenuItem ( + DropdownMenuItem ( value: false, - child: Text ("Dark theme"), + child: Text ("Dark"), ), ], ) ); - - default: return null; // somehow got null } } @@ -99,23 +105,25 @@ class BrightnessChanger extends StatelessWidget { /// If the brightness is auto, it will be set to light. void buttonToggle(BuildContext context) => setBrightness( context, - value: caseConverter( - value: prefs.brightness, + value: caseConverter( + value: _brightness, onTrue: false, onFalse: null, onNull: true, ), ); + /// Sets the brightness of the app. /// - void setBrightness (BuildContext context, {bool value}) { + /// Also saves it to [Preferences]. + void setBrightness (BuildContext context, {required bool? value}) { ThemeChanger.of(context).brightness = caseConverter ( value: value, onTrue: Brightness.light, onFalse: Brightness.dark, onNull: MediaQuery.of(context).platformBrightness, ); - prefs.brightness = value; - // brightnessNotifier.value = value; // trigger rebuild + Services.instance.prefs.brightness = value; + setState(() => _brightness = value); } } diff --git a/lib/src/widgets/ambient/theme_changer.dart b/lib/src/widgets/ambient/theme_changer.dart index df9fd835f..e80995f70 100644 --- a/lib/src/widgets/ambient/theme_changer.dart +++ b/lib/src/widgets/ambient/theme_changer.dart @@ -1,6 +1,7 @@ import "package:flutter/material.dart"; -typedef ThemeBuilder = Widget Function (BuildContext, ThemeData); +/// Builds a widget with the given theme. +typedef ThemeBuilder = Widget Function(BuildContext, ThemeData); /// A widget to change the theme. /// @@ -43,37 +44,45 @@ class ThemeChanger extends StatefulWidget { /// Creates a widget to change the theme. const ThemeChanger ({ - @required this.builder, - @required this.defaultBrightness, - @required this.light, - @required this.dark, - this.themes, + required this.builder, + required this.defaultBrightness, + required this.light, + required this.dark, + this.themes = const {}, }); - @override ThemeChangerState createState() => ThemeChangerState(); + @override + ThemeChangerState createState() => ThemeChangerState(); /// Gets the [ThemeChangerState] from a [BuildContext]. /// /// Use this function to switch the theme. - static ThemeChangerState of(BuildContext context) => - context.findAncestorStateOfType(); + static ThemeChangerState of(BuildContext context) { + final state = context.findAncestorStateOfType(); + if (state == null) { + throw StateError("No theme changer found in the widget tree"); + } else { + return state; + } + } } /// The state for a [ThemeChanger]. /// /// This class has properties that control the theme. class ThemeChangerState extends State { - ThemeData _theme; - Brightness _brightness; - String _key; + late ThemeData _theme; + late Brightness _brightness; + String? _key; - @override void initState() { + @override + void initState() { super.initState(); brightness = widget.defaultBrightness; } - @override Widget build (BuildContext context) => - widget.builder (context, _theme); + @override + Widget build (BuildContext context) => widget.builder (context, _theme); /// The current brightness. /// @@ -81,17 +90,22 @@ class ThemeChangerState extends State { /// [ThemeChanger.light] and [ThemeChanger.dark]). Brightness get brightness => _brightness; set brightness(Brightness value) { - setState(() => _theme = value == Brightness.light + _key = null; + _brightness = value; + setState(() => _theme = value == Brightness.light ? widget.light : widget.dark ); - _brightness = value; } /// The current theme. /// /// Changing this will rebuild the widget tree. ThemeData get theme => _theme; - set theme(ThemeData data) => setState(() => _theme = data); + set theme(ThemeData data) { + _brightness = data.brightness; + _key = null; + setState(() => _theme = data); + } /// The name of the theme. /// @@ -101,9 +115,10 @@ class ThemeChangerState extends State { /// /// When [brightness] or [theme] are changed, [theme] may not exist in /// [ThemeChanger.themes], in which case `themeName` will equal null. - String get themeName => _key; - set themeName (String key) => setState(() { - _theme = (widget.themes ?? {}) [key]; + String? get themeName => _key; + set themeName (String? key) { + setState(() => _theme = (widget.themes) [key] ?? _theme); _key = key; - }); + _brightness = _theme.brightness; + } } diff --git a/lib/src/widgets/atomic/activity_tile.dart b/lib/src/widgets/atomic/activity_tile.dart index 604d420a0..cb06e5494 100644 --- a/lib/src/widgets/atomic/activity_tile.dart +++ b/lib/src/widgets/atomic/activity_tile.dart @@ -18,9 +18,7 @@ class ActivityTile extends StatelessWidget { final Activity activity; /// Creates an ActivityTile widget. - const ActivityTile( - this.activity, - ); + const ActivityTile(this.activity); @override Widget build(BuildContext context) => SizedBox( @@ -37,7 +35,7 @@ class ActivityTile extends StatelessWidget { ? "Tap to see details" : activity.toString(), ), - onTap: activity.message == null ? null : () => showDialog( + onTap: () => showDialog( context: context, builder: (_) => AlertDialog( title: const Text("Activity"), diff --git a/lib/src/widgets/atomic/admin/calendar_tile.dart b/lib/src/widgets/atomic/admin/calendar_tile.dart index b2edfee0b..aee904e09 100644 --- a/lib/src/widgets/atomic/admin/calendar_tile.dart +++ b/lib/src/widgets/atomic/admin/calendar_tile.dart @@ -10,47 +10,40 @@ class CalendarTile extends StatelessWidget{ /// A blank calendar tile. /// /// This should not be wrapped in a [GestureDetector]. - static const CalendarTile blank = CalendarTile(date: null, day: null); + static const CalendarTile blank = CalendarTile(day: null, date: null); - /// The date for this tile. - final int date; - - /// The [Day] represented by this tile. - final Day day; + /// The [Day] this cell represents. + final Day? day; + + /// The date this cell represents. + final DateTime? date; /// Creates a widget to update a day in the calendar - const CalendarTile({ - @required this.date, - @required this.day, - }); + const CalendarTile({required this.day, required this.date}); @override Widget build(BuildContext context) => Container( decoration: BoxDecoration(border: Border.all()), - child: Stack ( - children: [ - if (date != null) ...[ - Align ( - alignment: Alignment.topLeft, - child: Text ((date + 1).toString()), - ), - if (day?.letter != null) - Center ( - child: Text ( - lettersToString [day.letter], - textScaleFactor: 1.5 - ), + child: date == null ? Container() : LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double textSize = constraints.biggest.width > 120 ? 1.5 : 1; + return Column( + children: [ + Align ( + alignment: Alignment.topLeft, + child: Text (date!.day.toString(), textScaleFactor: 1), ), - if ( - day?.letter != null && - ![Special.rotate.name, Special.regular.name] - .contains(day.special?.name) - ) const Align( - alignment: Alignment.bottomCenter, - child: Text ("•", textScaleFactor: 0.8), - ) - ] - ] + const Spacer(), + if (day == null) + Expanded(child: Text("No school", textScaleFactor: textSize)) + else ...[ + Expanded(child: Text (day!.name, textScaleFactor: textSize)), + Expanded(child: Text (day!.schedule.name, textScaleFactor: 0.8)), + ], + const Spacer(), + ] + ); + } ) ); } diff --git a/lib/src/widgets/atomic/admin/period_tile.dart b/lib/src/widgets/atomic/admin/period_tile.dart deleted file mode 100644 index 78bb649ae..000000000 --- a/lib/src/widgets/atomic/admin/period_tile.dart +++ /dev/null @@ -1,113 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:ramaz/constants.dart"; -import "package:ramaz/data.dart"; -import "package:ramaz/models.dart"; - -/// A widget to represent a [Period] when creating a [Special]. -class PeriodTile extends StatelessWidget { - /// The view model to decide the properties of this period. - final SpecialBuilderModel model; - - /// The times for this period. - final Range range; - - /// Allows [range] to be formatted according to the user's locale. - final TimeOfDay start, end; - - final Activity activity; - - /// Whether this period is skipped. - final bool skipped; - - /// The index of this period in [SpecialBuilderModel.times]. - final int index; - - /// Creates a widget to edit a period in a [Special]. - PeriodTile({ - @required this.model, - @required this.range, - @required this.index, - }) : - skipped = model.skips.contains(index), - activity = model.activities [index], - start = range.start.asTimeOfDay, - end = range.end.asTimeOfDay; - - @override - Widget build(BuildContext context) => SizedBox( - height: 55, - child: Stack ( - children: [ - if (skipped) Center( - child: Container( - height: 5, - color: Colors.black, - ), - ), - ListTile( - subtitle: Text(model.periods [index]), - leading: IconButton( - icon: Icon( - model.skips.contains(index) - ? Icons.add_circle_outline - : Icons.remove_circle_outline - ), - onPressed: () => model.toggleSkip(index), - ), - title: Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: InkWell( - onTap: () async => model.replaceTime( - index, - getRange( - await showTimePicker( - context: context, - initialTime: start, - ), - start: true, - ) - ), - child: Text( - start.format(context), - style: const TextStyle(color: Colors.blue) - ), - ), - ), - const TextSpan(text: " -- "), - WidgetSpan( - child: InkWell( - onTap: () async => model.replaceTime( - index, - getRange( - await showTimePicker( - context: context, - initialTime: end, - ), - start: false, - ) - ), - child: Text( - end.format(context), - style: const TextStyle(color: Colors.blue) - ), - ), - ), - ] - ) - ), - ) - ] - ) - ); - - /// Creates a [Range] from a [TimeOfDay]. - /// - /// [start] determines if the range starts with [time] or not. - Range getRange(TimeOfDay time, {bool start}) => Range( - start ? time.asTime : range.start, - start ? range.end : time.asTime, - ); -} diff --git a/lib/src/widgets/atomic/info_card.dart b/lib/src/widgets/atomic/info_card.dart index c9ab1a503..f648f3b22 100644 --- a/lib/src/widgets/atomic/info_card.dart +++ b/lib/src/widgets/atomic/info_card.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:link_text/link_text.dart"; /// A tile to represent some info. /// @@ -14,7 +15,7 @@ class InfoCard extends StatelessWidget { /// The text passed to [ListTile.subtitle]. /// /// Every string in the iterable is put on it's own line. - final Iterable children; + final Iterable? children; /// The heading of the tile. final String title; @@ -24,10 +25,10 @@ class InfoCard extends StatelessWidget { /// Creates an info tile. const InfoCard ({ - @required this.title, - @required this.icon, - @required this.children, - this.page, + required this.title, + required this.icon, + required this.page, + this.children, }); @override Widget build (BuildContext context) => Card ( @@ -42,9 +43,13 @@ class InfoCard extends StatelessWidget { children: [ const SizedBox (height: 5), ...[ - for (final String text in children) ...[ + for (final String text in children!) ...[ const SizedBox(height: 2.5), - Text(text, textScaleFactor: 1.25), + LinkText( + text, + shouldTrimParams: true, + linkStyle: const TextStyle(color: Color(0xff0000EE)) + ), const SizedBox(height: 2.5), ] ] diff --git a/lib/src/widgets/atomic/next_class.dart b/lib/src/widgets/atomic/next_class.dart index 3097dc166..985b44754 100644 --- a/lib/src/widgets/atomic/next_class.dart +++ b/lib/src/widgets/atomic/next_class.dart @@ -1,11 +1,11 @@ import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; import "package:ramaz/data.dart"; +import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; import "package:ramaz/widgets.dart"; import "info_card.dart"; -import "reminder_tile.dart"; /// A decorative border around a special addition to [NextClass]. class SpecialTile extends StatelessWidget { @@ -13,7 +13,7 @@ class SpecialTile extends StatelessWidget { final Widget child; /// Creates a decorative border. - const SpecialTile({this.child}); + const SpecialTile({required this.child}); @override Widget build (BuildContext context) => Padding ( @@ -32,21 +32,16 @@ class SpecialTile extends StatelessWidget { /// A widget to represent the next class. class NextClass extends StatelessWidget { - /// Whether today has a modified schedule. - /// - /// This determines whether the times should be shown. - final bool modified; - /// Whether this is the next period or not. /// /// This changes the text from "Right now" to "Up next". final bool next; /// The period to represent. - final Period period; + final Period? period; /// The subject associated with [period]. - final Subject subject; + final Subject? subject; /// The reminders that apply for this period. /// @@ -55,10 +50,9 @@ class NextClass extends StatelessWidget { /// Creates an info tile to represent a period. const NextClass({ - @required this.period, - @required this.subject, - @required this.reminders, - @required this.modified, + required this.reminders, + this.period, + this.subject, this.next = false, }); @@ -67,17 +61,20 @@ class NextClass extends StatelessWidget { children: [ InfoCard( icon: next ? Icons.restore : Icons.school, - children: modified - ? const ["See side panel or click for schedule"] - : period?.getInfo(subject), + children: period?.getInfo(subject), page: Routes.schedule, - title: modified ? "Times unavailable" : - period == null - ? "School is over" - : "${next ? 'Up next' : 'Right now'}: ${subject?.name ?? period.period}", + title: period == null + ? "School is over" + : "${next ? (Models.instance.schedule.periods) != null ? ( + (Time.fromDateTime(DateTime.now()) < + Models.instance.schedule.periods![0].time.start) ? + 'Second Period': 'Up Next'): "Up Next" : (Models.instance.schedule.periods) + != null ? ((Time.fromDateTime(DateTime.now()) < + Models.instance.schedule.periods![0].time.start) ? + 'First Period': 'Right Now'):'Right Now'}: ${period!.getName(subject)}" ), if (period?.activity != null) - SpecialTile(child: ActivityTile(period.activity)), + SpecialTile(child: ActivityTile(period!.activity!)), for (final int index in reminders) SpecialTile(child: ReminderTile(index: index)) ] diff --git a/lib/src/widgets/atomic/reminder_tile.dart b/lib/src/widgets/atomic/reminder_tile.dart index 4606479e4..976dd1028 100644 --- a/lib/src/widgets/atomic/reminder_tile.dart +++ b/lib/src/widgets/atomic/reminder_tile.dart @@ -16,13 +16,16 @@ class ReminderTile extends StatelessWidget { /// Creates a reminder tile. const ReminderTile({ - @required this.index, + required this.index, this.height = 65, }); @override Widget build (BuildContext context) { - final Reminders reminders = Models.reminders; + // The data model. + final Reminders reminders = Models.instance.reminders; + + // The reminder being represented final Reminder reminder = reminders.reminders [index]; return SizedBox ( @@ -30,11 +33,20 @@ class ReminderTile extends StatelessWidget { child: Center ( child: ListTile( title: Text (reminder.message), - subtitle: Text (reminder.time.toString() ?? ""), - onTap: () async => reminders.replaceReminder( - index, - await ReminderBuilder.buildReminder(context, reminder), - ), + subtitle: Text(reminder.time.toString()), + onTap: () async { + if (!Models.instance.schedule.isValidReminder(reminder)) { + await reminders.deleteReminder(index); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Deleted outdated reminder")) + ); + return; + } + reminders.replaceReminder( + index, + await ReminderBuilder.buildReminder(context, reminder), + ); + }, trailing: IconButton ( icon: Icon ( Icons.remove_circle, diff --git a/lib/src/widgets/atomic/sports_tile.dart b/lib/src/widgets/atomic/sports_tile.dart index 2a44935a8..b12006622 100644 --- a/lib/src/widgets/atomic/sports_tile.dart +++ b/lib/src/widgets/atomic/sports_tile.dart @@ -1,7 +1,7 @@ import "package:flutter/material.dart"; import "package:ramaz/data.dart"; -import "package:ramaz/widgets.dart"; +import "package:url_launcher/url_launcher.dart"; /// A row in a [SportsTile] that displays a team, their score, /// and a part of the date. @@ -18,25 +18,22 @@ class SportsStats extends StatelessWidget { final String dateTime; /// The score for [team]. - final int score; + final int? score; /// Creates a row to represent some stats in a [SportsTile]. const SportsStats({ - @required this.team, - @required this.dateTime, - @required this.score, + required this.team, + required this.dateTime, + this.score, }); @override Widget build(BuildContext context) => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(team), - const Spacer(flex: 2), - Text(score?.toString() ?? ""), - const Spacer(flex: 3), - Text(dateTime), - const Spacer(), + Expanded(flex: 1, child: Text(team)), + Expanded(flex: 1, child: Text(score?.toString() ?? "")), + Expanded(flex: 2, child: Center(child: Text(dateTime))), ] ); } @@ -48,7 +45,7 @@ class SportsScoreUpdater extends StatefulWidget { /// Opens a dialog to prompt the user for the scores of the game. /// /// Returns the scores as inputted. - static Future updateScores( + static Future updateScores( BuildContext context, SportsGame game ) => showDialog( @@ -58,7 +55,7 @@ class SportsScoreUpdater extends StatefulWidget { /// The game being edited. /// - /// [SportsGame.home] is used to fill [Scores.isHome]. + /// [SportsGame.isHome] is used to fill [Scores.isHome]. final SportsGame game; /// Creates a widget to get the scores for [game] from the user. @@ -79,13 +76,17 @@ class ScoreUpdaterState extends State { final TextEditingController otherController = TextEditingController(); /// The value of [ramazController] as a number. - int ramazScore; + int? ramazScore; /// The value of [otherController] as a number. - int otherScore; + int? otherScore; /// The [Scores] object represented by this widget. - Scores get scores => Scores(ramazScore, otherScore, isHome: widget.game.home); + Scores get scores => Scores( + ramazScore: ramazScore!, // only called if [ready] == true + otherScore: otherScore!, // only called if [ready] == true + isHome: widget.game.isHome + ); /// Whether [scores] is valid and ready to submit. bool get ready => ramazController.text.isNotEmpty && @@ -96,8 +97,8 @@ class ScoreUpdaterState extends State { @override void initState() { super.initState(); - ramazController.text = widget.game.scores?.ramazScore?.toString(); - otherController.text = widget.game.scores?.otherScore?.toString(); + ramazController.text = widget.game.scores?.ramazScore.toString() ?? ""; + otherController.text = widget.game.scores?.otherScore.toString() ?? ""; ramazScore = int.tryParse(ramazController.text); otherScore = int.tryParse(otherController.text); } @@ -146,11 +147,11 @@ class ScoreUpdaterState extends State { ] ), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text("Cancel"), ), - RaisedButton( + ElevatedButton( onPressed: !ready ? null : () => Navigator.of(context).pop(scores), child: const Text("Save"), ) @@ -168,11 +169,9 @@ class ScoreUpdaterState extends State { /// Instead, a pass [onTap] to [SportsTile()]. class SportsTile extends StatelessWidget { /// Formats [date] into month-day-year form. - static String formatDate(DateTime date, {bool noNull = false}) => - noNull && date == null ? null : - "${date?.month ?? ' '}-${date?.day ?? ' '}-${date?.year ?? ' '}"; + static String formatDate(DateTime? date) => + "${date?.month ?? ' '}-${date?.day ?? ' '}-${date?.year ?? ' '}"; - // TODO(All): Decide on widget or letter, #1 (see note) /// The game for this widget to represent. final SportsGame game; @@ -183,7 +182,7 @@ class SportsTile extends StatelessWidget { /// [game] depends on the context, so is left to the parent widget. /// /// If this is non-null, an edit icon will be shown on this widget. - final VoidCallback onTap; + final VoidCallback? onTap; /// Creates a widget to display a [SportsGame]. const SportsTile(this.game, {this.onTap}); @@ -195,16 +194,15 @@ class SportsTile extends StatelessWidget { /// cases. This use case is especially important (as opposed to parts of the /// data library which are Maps) because any error here will show up on the /// screen, instead of simply sending a bug report. - ImageProvider get icon { + IconData get icon { switch (game.sport) { - case Sport.baseball: return SportsIcons.baseball; - case Sport.basketball: return SportsIcons.basketball; - case Sport.soccer: return SportsIcons.soccer; - case Sport.hockey: return SportsIcons.hockey; - case Sport.tennis: return SportsIcons.tennis; - case Sport.volleyball: return SportsIcons.volleyball; + case Sport.baseball: return Icons.sports_baseball; + case Sport.basketball: return Icons.sports_basketball; + case Sport.soccer: return Icons.sports_soccer; + case Sport.hockey: return Icons.sports_hockey; + case Sport.tennis: return Icons.sports_tennis; + case Sport.volleyball: return Icons.sports_volleyball; } - return null; } /// The color of this widget. @@ -214,19 +212,14 @@ class SportsTile extends StatelessWidget { /// If the game was tied, it's a light gray. /// /// This is a great example of why the helper class [Scores] exists. - Color get cardColor => game.scores != null - ? (game.scores.didDraw - ? Colors.blueGrey - : (game.scores.didWin ? Colors.lightGreen : Colors.red [400]) - ) : null; - - /// Determines how long to pad the team names so they align. - int get padLength => game.opponent.length > "Ramaz".length - ? game.opponent.length : "Ramaz".length; + Color? get cardColor => game.scores == null ? null : + game.scores!.didDraw + ? const Color(0x4502b4fc) + : (game.scores!.didWin ? const Color(0xad00ff48) : const Color(0xd8d32f2f)); @override - Widget build(BuildContext context) => SizedBox( - height: 160, + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 160), child: Card( color: cardColor, child: InkWell( @@ -236,28 +229,40 @@ class SportsTile extends StatelessWidget { child: Column( children: [ ListTile( - leading: CircleAvatar ( - backgroundImage: icon, - backgroundColor: cardColor ?? Theme.of(context).cardColor, + leading: Icon( + icon, + size: 36, + color: Theme.of(context).colorScheme.onBackground ), - title: Text(game?.team ?? ""), - subtitle: Text(game.home + title: Text(game.team,textScaleFactor: 1.3), + subtitle: Text(game.isHome ? "${game.opponent} @ Ramaz" - : "Ramaz @ ${game.opponent}" + : "Ramaz @ ${game.opponent}", + textScaleFactor: 1.2, ), - trailing: onTap == null ? null : const Icon(Icons.edit), + trailing: onTap == null ? ((game.livestreamUrl != null + && game.scores == null) ? + IconButton( + icon: const Icon(Icons.live_tv), + onPressed: () { + if(game.livestreamUrl != null){ + launch(game.livestreamUrl!); + }}, + tooltip: "Watch livestream", + ) : null) : const Icon(Icons.edit), ), const SizedBox(height: 20), SportsStats( - team: game.awayTeam.padRight(padLength), + team: game.awayTeam, score: game.scores?.getScore(home: false), - dateTime: formatDate(game.date), + dateTime: MaterialLocalizations.of(context) + .formatShortDate(game.date), ), const SizedBox(height: 10), SportsStats( - team: game.homeTeam.padRight(padLength), + team: game.homeTeam, score: game.scores?.getScore(home: true), - dateTime: (game.times).toString(), + dateTime: formatTimeRange(game.times,context), ), ] ) @@ -265,4 +270,18 @@ class SportsTile extends StatelessWidget { ) ) ); + + /// Formats a [Range] according to the user's locale. + String formatTimeRange(Range times,BuildContext context) { + final locale = MaterialLocalizations.of(context); + final TimeOfDay start = TimeOfDay( + hour: times.start.hour, + minute: times.start.minutes + ); + final TimeOfDay end = TimeOfDay( + hour: times.end.hour, + minute: times.end.minutes + ); + return "${locale.formatTimeOfDay(start)} - ${locale.formatTimeOfDay(end)}"; + } } diff --git a/lib/src/widgets/generic/class_list.dart b/lib/src/widgets/generic/class_list.dart index 23de75cd6..82013339c 100644 --- a/lib/src/widgets/generic/class_list.dart +++ b/lib/src/widgets/generic/class_list.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:link_text/link_text.dart"; import "package:ramaz/data.dart"; import "package:ramaz/models.dart"; @@ -19,22 +20,23 @@ class ClassPanel extends StatelessWidget { /// A list of reminders for this period. /// - /// This list holds th indices of the reminders for this period in + /// This list holds the indices of the reminders for this period in /// [Reminders.reminders]. final List reminders; /// An activity for this period. - final Activity activity; + final Activity? activity; /// Creates a widget to represent a period. const ClassPanel ({ - @required this.title, - @required this.children, - @required this.reminders, - @required this.activity, + required this.title, + required this.children, + required this.reminders, + this.activity, }); - @override Widget build (BuildContext context) => ExpansionTile ( + @override + Widget build (BuildContext context) => ExpansionTile ( title: SizedBox ( height: 50, child: Center ( @@ -57,10 +59,14 @@ class ClassPanel extends StatelessWidget { for (final String label in children) Padding ( padding: const EdgeInsets.symmetric(vertical: 5), - child: Text (label) + child: LinkText( + label, + shouldTrimParams: true, + linkStyle: const TextStyle(color: Color(0xff0000EE)) + ) ), if (activity != null) - ActivityTile(activity), + ActivityTile(activity!), // already checked for null for (final int index in reminders) ReminderTile ( index: index, @@ -77,55 +83,60 @@ class ClassPanel extends StatelessWidget { /// A list of all the classes for a given day. /// /// The list is composed of [ClassPanel]s, one for each period in the day. -class ClassList extends StatelessWidget { +class ClassList extends StatefulWidget { /// The day whose periods should be represented. final Day day; /// A list of periods for today. /// - /// Comes from using [day] with [Student.getPeriods]. + /// Comes from using [day] with [User.getPeriods]. final Iterable periods; /// The header for this list. May be null. - final String headerText; + final String? headerText; /// Creates a list of [ClassPanel] widgets to represent periods in a day. - const ClassList ({ - @required this.day, + const ClassList({ + required this.day, + required this.periods, this.headerText, - this.periods, }); - @override Widget build(BuildContext context) => ModelListener( - model: () => Models.reminders, - dispose: false, - // ignore: sort_child_properties_last - child: DrawerHeader ( - child: Center ( - child: Text ( - headerText ?? "", - textScaleFactor: 2, - textAlign: TextAlign.center, + @override + ClassListState createState() => ClassListState(); +} + +/// A state for the class list. +class ClassListState extends ModelListener { + /// Creates a state that can read reminders + ClassListState() : super(shouldDispose: false); + + @override + Reminders getModel() => Models.instance.reminders; + + @override + Widget build(BuildContext context) => ListView( + shrinkWrap: true, + children: [ + if (widget.headerText != null) DrawerHeader( + child: Center ( + child: Text ( + widget.headerText ?? "", + textScaleFactor: 2, + textAlign: TextAlign.center, + ) ) - ) - ), - builder: (_, __, Widget header) => ListView( - shrinkWrap: true, - children: [ - if (headerText != null) header, - ...[ - for ( - final Period period in - periods ?? Models.schedule.student.getPeriods(day) - ) getPanel(period) - ], - ] - ) + ), + ...[ + for (final Period period in widget.periods) + getPanel(period) + ], + ] ); /// Creates a [ClassPanel] for a given period. Widget getPanel(Period period) { - final Subject subject = Models.schedule.subjects[period.id]; + final Subject? subject = Models.instance.schedule.subjects[period.id]; return ClassPanel ( children: [ for (final String description in period.getInfo(subject)) @@ -134,15 +145,15 @@ class ClassList extends StatelessWidget { if (period.id != null) "ID: ${period.id}", ], - title: int.tryParse(period.period) == null + title: int.tryParse(period.name) == null ? period.getName(subject) - : "${period.period}: ${period.getName(subject)}", - reminders: Models.reminders.getReminders( - period: period.period, - letter: day.letter, + : "${period.name}: ${period.getName(subject)}", + reminders: Models.instance.reminders.getReminders( + period: period.name, + dayName: widget.day.name, subject: subject?.name, ), - activity: period?.activity, + activity: period.activity, ); } } diff --git a/lib/src/widgets/generic/date_picker.dart b/lib/src/widgets/generic/date_picker.dart index 0b2333dae..1fbf38184 100644 --- a/lib/src/widgets/generic/date_picker.dart +++ b/lib/src/widgets/generic/date_picker.dart @@ -3,9 +3,9 @@ import "package:flutter/material.dart"; /// Prompts the user to pick a date from the calendar. /// /// The calendar will show the days of the school year. -Future pickDate({ - @required BuildContext context, - @required DateTime initialDate +Future pickDate({ + required BuildContext context, + required DateTime initialDate }) async { final DateTime now = DateTime.now(); final DateTime beginningOfYear = DateTime( diff --git a/lib/src/widgets/generic/footer.dart b/lib/src/widgets/generic/footer.dart index 1eec6c4b9..8913a4e90 100644 --- a/lib/src/widgets/generic/footer.dart +++ b/lib/src/widgets/generic/footer.dart @@ -1,7 +1,7 @@ import "package:flutter/material.dart"; -import "package:ramaz/constants.dart"; import "package:ramaz/models.dart"; +import "package:ramaz/pages.dart"; import "package:ramaz/widgets.dart"; /// A footer to display all around the app. @@ -11,56 +11,67 @@ import "package:ramaz/widgets.dart"; /// /// The footer should be displayed on every page where the current schedule is /// not being shown. -class Footer extends StatelessWidget { +class Footer extends StatefulWidget { /// A scale factor for the footer text. static const double textScale = 1.25; - @override Widget build (BuildContext context) => ModelListener( - model: () => Models.schedule, - dispose: false, - // ignore: sort_child_properties_last - child: Container(height: 0, width: 0), - builder: (BuildContext context, Schedule schedule, Widget blank) => - schedule.nextPeriod == null ? blank : BottomSheet ( - enableDrag: false, - onClosing: () {}, - builder: (BuildContext context) => GestureDetector( - onTap: !Models.reminders.hasNextReminder ? null : - () { - final NavigatorState nav = Navigator.of(context); - if (nav.canPop()) { - nav.pop(); - } - nav.pushReplacementNamed(Routes.home); - }, - child: SizedBox ( - height: 70, - child: Align ( - child: Column ( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text ( - "Next: ${schedule.nextPeriod.getName(schedule.nextSubject)}", - textScaleFactor: textScale - ), - Text ( - (schedule.nextPeriod - .getInfo(schedule.nextSubject) - ..removeWhere( - (String str) => ( - str.startsWith("Period: ") || - str.startsWith("Teacher: ") - ) + @override + FooterState createState() => FooterState(); +} + +/// The state of the footer. +/// +/// The footer refreshes whenever the schedule or reminders change. +class FooterState extends ModelListener { + /// Doesn't dispose the data model since it's used elsewhere. + FooterState() : super(shouldDispose: false); + + @override + ScheduleModel getModel() => Models.instance.schedule; + + @override + Widget build (BuildContext context) => model.nextPeriod == null ? Container() + : BottomSheet ( + enableDrag: false, + onClosing: () {}, + builder: (BuildContext context) => GestureDetector( + onTap: !Models.instance.reminders.hasNextReminder ? null : + () { + final NavigatorState nav = Navigator.of(context); + if (nav.canPop()) { + nav.pop(); + } + nav.pushReplacementNamed(Routes.home); + }, + child: SizedBox ( + height: 70, + child: Align ( + child: Column ( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text ( + // ternary already checked for schedule.nextPeriod == null + "Next: ${model.nextPeriod!.getName(model.nextSubject)}", + textScaleFactor: Footer.textScale + ), + Text ( + // ternary already checked for schedule.nextPeriod == null + (model.nextPeriod! + .getInfo(model.nextSubject) + ..removeWhere( + (String str) => ( + str.startsWith("Period: ") || + str.startsWith("Teacher: ") ) - ).join (". "), - textScaleFactor: textScale, - ), - if (schedule.nextPeriod?.activity != null) - const Text("There is an activity"), - if (Models.reminders.hasNextReminder) - const Text ("Click to see reminder"), - ] - ) + ) + ).join (". "), + textScaleFactor: Footer.textScale, + ), + if (model.nextPeriod?.activity != null) + const Text("There is an activity"), + if (Models.instance.reminders.hasNextReminder) + const Text ("Click to see reminder"), + ] ) ) ) diff --git a/lib/src/widgets/generic/icons.dart b/lib/src/widgets/generic/icons.dart index 3369b8611..5620ad87f 100644 --- a/lib/src/widgets/generic/icons.dart +++ b/lib/src/widgets/generic/icons.dart @@ -5,30 +5,6 @@ import "package:ramaz/constants.dart"; import "../images/link_icon.dart"; import "../images/loading_image.dart"; -/// A collection of icons for sports. -/// -/// These icons are Strings so that the background color of the icon can be -/// determined in the `build` method. -class SportsIcons { - /// A baseball icon. - static const ImageProvider baseball = AssetImage("images/icons/baseball.png"); - - /// A basketball icon. - static const ImageProvider basketball = AssetImage("images/icons/basketball.png"); - - /// A hockey icon. - static const ImageProvider hockey = AssetImage("images/icons/hockey.png"); - - /// A soccer icon. - static const ImageProvider soccer = AssetImage("images/icons/soccer.png"); - - /// A tennis icon. - static const ImageProvider tennis = AssetImage("images/icons/tennis.png"); - - /// A volleyball icon. - static const ImageProvider volleyball = AssetImage("images/icons/volleyball.png"); -} - /// Brand logos used throughout the app. /// /// Ram Life does not claim ownership of any brand @@ -36,8 +12,10 @@ class SportsIcons { class Logos { /// The Google logo. static const Widget google = CircleAvatar ( - backgroundImage: AssetImage ("images/logos/google.png"), - radius: 18, + backgroundColor: Colors.transparent, + backgroundImage: AssetImage( + "images/logos/google_sign_in.png", + ), ); /// The Google Drive logo. @@ -76,8 +54,8 @@ class RamazLogos { /// The light blue, square Ramaz logo. /// /// https://pbs.twimg.com/profile_images/378800000152983492/5724a8d14e67b53234ed96e3235fe526.jpeg - static const Widget teal = LoadingImage ( - "images/logos/ramaz/teal.jpg", + static const Widget teal = LoadingImage( + image: AssetImage("images/logos/ramaz/teal.jpg"), aspectRatio: 1 ); @@ -85,8 +63,8 @@ class RamazLogos { /// /// Like https://www.the-rampage.org/wp-content/uploads/2019/08/IMG_0432.png, /// but with the word Ramaz underneath. - static const Widget ramSquareWords = LoadingImage ( - "images/logos/ramaz/ram_square_words.png", + static const Widget ramSquareWords = LoadingImage( + image: AssetImage("images/logos/ramaz/ram_square_words.png"), aspectRatio: 0.9276218611521418 ); @@ -94,7 +72,7 @@ class RamazLogos { /// /// https://www.the-rampage.org/wp-content/uploads/2019/08/IMG_0432.png static const Widget ramSquare = LoadingImage( - "images/logos/ramaz/ram_square.png", + image: AssetImage("images/logos/ramaz/ram_square.png"), aspectRatio: 1.0666666666666667 ); @@ -103,8 +81,8 @@ class RamazLogos { /// https://www.the-rampage.org/wp-content/uploads/2019/08/IMG_0432.png /// with https://upload.wikimedia.org/wikipedia/commons/a/aa/RamazNewLogo_BLUE_RGB_Large72dpi.jpg /// next to it. - static const Widget ramRectangle = LoadingImage ( - "images/logos/ramaz/ram_rectangle.png", + static const Widget ramRectangle = LoadingImage( + image: AssetImage("images/logos/ramaz/ram_rectangle.png"), aspectRatio: 2.8915864378401004 ); } diff --git a/lib/src/widgets/generic/model_listener.dart b/lib/src/widgets/generic/model_listener.dart index 860d92200..b5de6a69d 100644 --- a/lib/src/widgets/generic/model_listener.dart +++ b/lib/src/widgets/generic/model_listener.dart @@ -1,76 +1,41 @@ import "package:flutter/material.dart"; -/// A function to build a widget with a [ChangeNotifier] subclass. -typedef ModelBuilder = - Widget Function(BuildContext context, T model, Widget child); - -/// A widget that listens to a [ChangeNotifier] and rebuilds the widget tree -/// when [ChangeNotifier.notifyListeners]. -class ModelListener extends StatefulWidget { - /// A function to create the model to listen to. - /// - /// It is important that this be a function and not an instance, because - /// otherwise the model will be recreated every time the widget tree - /// is updated. With a function, this widget can choose when to create - /// the model. - final Model Function() model; - - /// The function to build the widget tree underneath this. - final ModelBuilder builder; - - /// An optional child to cache. - /// - /// This child is never re-built, so if there is an expensive widget that - /// does not depend on [model], it would go here and can be - /// re-used in [builder]. - final Widget child; - - /// Whether or not to dispose the [model]. +/// A widget that rebuilds when the underlying data changes. +abstract class ModelListener< + M extends ChangeNotifier, + T extends StatefulWidget +> extends State { + /// The data model to listen to. + late final M model; + + /// Whether we should dispose the model after use. /// - /// Some models are used elsewhere in the app (like data models) while other - /// models (like view models) are only used in one screen. - final bool dispose; + /// Some models are used multiple times, so they should not be disposed. + final bool shouldDispose; - /// Creates a widget that listens to a [ChangeNotifier]. - const ModelListener ({ - @required this.model, - @required this.builder, - this.child, - this.dispose = true - }); + /// Creates a widget that updates when the underlying data changes + ModelListener({this.shouldDispose = true}); - @override - ModelListenerState createState() => ModelListenerState(); -} - -/// A state for a [ModelListener]. -class ModelListenerState - extends State> -{ - /// The model to listen to. + /// The function to create the data model /// - /// This is different than [ModelListener.model], which is a function that is - /// called to create the model. Here is where the result of that function is - /// actually stored. - Model model; - - @override void initState() { + /// This has to be overriden in a `State` + M getModel(); + + void _listener() => setState(() {}); + + @override + void initState() { super.initState(); - model = widget.model()..addListener(listener); + model = getModel(); + model.addListener(_listener); } - - @override void dispose() { - model.removeListener(listener); - if (widget.dispose) { + + @override + void dispose() { + model.removeListener(_listener); + if (shouldDispose) { model.dispose(); } super.dispose(); } - - @override Widget build (BuildContext context) => widget.builder ( - context, model, widget.child - ); - - /// Rebuilds the widget tree whenever [model] updates. - void listener() => setState(() {}); } diff --git a/lib/src/widgets/generic/responsive_scaffold.dart b/lib/src/widgets/generic/responsive_scaffold.dart new file mode 100644 index 000000000..6affca1ab --- /dev/null +++ b/lib/src/widgets/generic/responsive_scaffold.dart @@ -0,0 +1,3 @@ +export "../responsive_scaffold/navigation_item.dart"; +export "../responsive_scaffold/responsive_builder.dart"; +export "../responsive_scaffold/scaffold.dart"; diff --git a/lib/src/widgets/images/link_icon.dart b/lib/src/widgets/images/link_icon.dart index d4725b469..9d2f02971 100644 --- a/lib/src/widgets/images/link_icon.dart +++ b/lib/src/widgets/images/link_icon.dart @@ -11,7 +11,7 @@ class LinkIcon extends StatelessWidget { final String url; /// Creates an icon that opens a web page. - const LinkIcon ({@required this.path, @required this.url}); + const LinkIcon ({required this.path, required this.url}); @override Widget build(BuildContext context) => IconButton ( iconSize: 45, diff --git a/lib/src/widgets/images/loading_image.dart b/lib/src/widgets/images/loading_image.dart index 64a0146ba..47a7cb097 100644 --- a/lib/src/widgets/images/loading_image.dart +++ b/lib/src/widgets/images/loading_image.dart @@ -27,18 +27,16 @@ class LoadingImage extends StatefulWidget { /// /// This is used to size the [CircularProgressIndicator] so that it is /// roughly the same size as the image will be when it loads. - final double aspectRatio; + final double? aspectRatio; - /// The path of the image. - /// - /// All [LoadingImage]s use [AssetImage]s. - final String path; + /// The image being loaded. + final ImageProvider image; /// Creates an image with a placeholder while it loads. - const LoadingImage( - this.path, - {this.aspectRatio} - ); + const LoadingImage({ + required this.image, + required this.aspectRatio + }); @override LoadingImageState createState() => LoadingImageState(); @@ -49,25 +47,21 @@ class LoadingImage extends StatefulWidget { /// This state handles loading the image in the background and switching /// out the placeholder animation with the actual image when it loads. class LoadingImageState extends State { - /// The image to be loaded. - ImageProvider image; - /// A listener that will notify when the image has loaded. - ImageStreamListener listener; + late ImageStreamListener listener; /// The stream of bytes in the image. - ImageStream stream; + late ImageStream stream; /// Whether the image is still loading. bool loading = true; /// The aspect ratio of the image. - double aspectRatio; + late double aspectRatio; @override void initState() { super.initState(); - image = AssetImage(widget.path); - stream = image.resolve(const ImageConfiguration()); + stream = widget.image.resolve(const ImageConfiguration()); listener = ImageStreamListener(onLoad); stream.addListener(listener); } @@ -79,14 +73,14 @@ class LoadingImageState extends State { // BUG: Check if this actually works. // ignore: avoid_positional_boolean_parameters void onLoad (ImageInfo info, bool _) { - setState(() => loading = false); aspectRatio = Size ( info.image.width.toDouble(), info.image.height.toDouble() ).aspectRatio; if (widget.aspectRatio == null) { - debugPrint("LoadingImage: Aspect ratio for ${widget.path} is $aspectRatio"); + debugPrint("LoadingImage: Aspect ratio for ${widget.image} is $aspectRatio"); } + setState(() => loading = false); } @override Widget build(BuildContext context) => loading @@ -96,7 +90,7 @@ class LoadingImageState extends State { ) : AspectRatio ( aspectRatio: aspectRatio, - child: Image (image: image) + child: Image (image: widget.image) ); @override void dispose () { diff --git a/lib/src/widgets/responsive_scaffold/layout_info.dart b/lib/src/widgets/responsive_scaffold/layout_info.dart new file mode 100644 index 000000000..daf637257 --- /dev/null +++ b/lib/src/widgets/responsive_scaffold/layout_info.dart @@ -0,0 +1,42 @@ +import "package:adaptive_breakpoints/adaptive_breakpoints.dart"; +import "package:flutter/material.dart"; + +/// Provides info about how this scaffold should be laid out. +/// +/// Uses [getWindowType] from Material's `adaptive_breakpoints` package to +/// determine which layout should be built and exposes getters such as +/// [hasNavRail] to define how the layout should look. +@immutable +class LayoutInfo { + /// The breakpoint as defined by [material.io](https://material.io/design/layout/responsive-layout-grid.html#breakpoints) + final AdaptiveWindowType windowType; + + /// Stores info about the layout based on Material Design breakpoints. + LayoutInfo(BuildContext context) : + windowType = getWindowType(context); + + /// Whether the app is running on a phone. + bool get isMobile => windowType == AdaptiveWindowType.xsmall; + + /// Whether the app is running on a tablet in portrait mode (or a large phone). + bool get isTabletPortrait => windowType == AdaptiveWindowType.small; + + /// Whether the app is running on a tablet in landscape mode. + bool get isTabletLandscape => windowType == AdaptiveWindowType.medium; + + /// Whether the app is running on a desktop. + bool get isDesktop => windowType == AdaptiveWindowType.large + || windowType == AdaptiveWindowType.xlarge; + + /// Whether the app should use a [BottomNavigationBar]. + bool get hasBottomNavBar => isMobile; + + /// Whether the app should use a [NavigationRail]. + bool get hasNavRail => isTabletPortrait || isTabletLandscape; + + /// Whether the app should have a persistent [Scaffold.endDrawer]. + bool get hasStandardSideSheet => isTabletLandscape || isDesktop; + + /// Whether the app should have a persistent [Drawer]. + bool get hasStandardDrawer => isDesktop; +} diff --git a/lib/src/widgets/responsive_scaffold/navigation_item.dart b/lib/src/widgets/responsive_scaffold/navigation_item.dart new file mode 100644 index 000000000..c3c21d770 --- /dev/null +++ b/lib/src/widgets/responsive_scaffold/navigation_item.dart @@ -0,0 +1,52 @@ +import "package:flutter/material.dart"; + +/// Defines a common interface for [BottomNavigationBar] and [NavigationRail]. +/// +/// This class maps to both [BottomNavigationBarItem] and +/// [NavigationRailDestination] with an [icon] and [label] property. +abstract class NavigationItem { + /// The data model for this page, if any. + M? model; + + /// Whether this item's data model should be disposed. + bool shouldDispose; + + /// The icon for this item. + final Widget icon; + + /// The label for this item. + /// + /// May also be used as semantics and tooltips. + final String label; + + /// Creates an abstraction for a navigation item. + NavigationItem({ + required this.icon, + required this.label, + this.model, + this.shouldDispose = true, + }); + + /// Generates an item for [BottomNavigationBar]. + BottomNavigationBarItem get bottomNavBar => + BottomNavigationBarItem(icon: icon, label: label); + + /// Generates an item for [NavigationRail]. + NavigationRailDestination get navRail => + NavigationRailDestination(icon: icon, label: Text(label)); + + /// The app bar for this page. + AppBar get appBar; + + /// The side sheet for this page. + Widget? get sideSheet => null; + + /// The FAB for this page. + Widget? get floatingActionButton => null; + + /// The FAB location for this page. + FloatingActionButtonLocation? get floatingActionButtonLocation => null; + + /// The main content on the page. + Widget build(BuildContext context); +} diff --git a/lib/src/widgets/responsive_scaffold/responsive_builder.dart b/lib/src/widgets/responsive_scaffold/responsive_builder.dart new file mode 100644 index 000000000..721b0cedd --- /dev/null +++ b/lib/src/widgets/responsive_scaffold/responsive_builder.dart @@ -0,0 +1,32 @@ +import "package:flutter/material.dart"; + +import "layout_info.dart"; +export "layout_info.dart"; + +/// A function that returns a widget that depends on a [LayoutInfo]. +/// +/// Used by [ResponsiveBuilder]. +typedef ResponsiveWidgetBuilder = + Widget Function(BuildContext, LayoutInfo, Widget?); + +/// Builds a widget tree according to a [LayoutInfo]. +class ResponsiveBuilder extends StatelessWidget { + /// An optional widget that doesn't depend on the layout info. + /// + /// Use this field to cache large portions of the widget tree so they don't + /// rebuild every frame when a window resizes. + final Widget? child; + + /// A function to build the widget tree. + final ResponsiveWidgetBuilder builder; + + /// A builder to layout the widget tree based on the device size. + const ResponsiveBuilder({ + required this.builder, + this.child, + }); + + @override + Widget build(BuildContext context) => + builder(context, LayoutInfo(context), child); +} diff --git a/lib/src/widgets/responsive_scaffold/scaffold.dart b/lib/src/widgets/responsive_scaffold/scaffold.dart new file mode 100644 index 000000000..4f9e5cc60 --- /dev/null +++ b/lib/src/widgets/responsive_scaffold/scaffold.dart @@ -0,0 +1,202 @@ +import "package:flutter/material.dart"; + +import "navigation_item.dart"; +import "responsive_builder.dart"; + +/// A Scaffold that rearranges itself according to the device size. +/// +/// Uses [LayoutInfo] to decide key elements of the layout. This class has +/// two uses: with and without primary navigation items. These are items that +/// would normally be placed in a [BottomNavigationBar]. When primary navigation +/// items are provided, two different drawers are used: with and without the +/// primary navigation items (to avoid duplication in the UI). +/// +/// See the package documentation for how the layout is determined. +class ResponsiveScaffold extends StatefulWidget { + /// The app bar. + /// + /// This does not change with the layout, except for showing a drawer menu. + final PreferredSizeWidget? appBar; + + /// The main body of the scaffold. + final WidgetBuilder? bodyBuilder; + + /// The full drawer to show. + /// + /// When there are primary navigation items, it is recommended not to include + /// them in the drawer, as that may confuse your users. Instead, provide two + /// drawers, one with them and the other, without. + /// + /// This field should include all navigation items, whereas [secondaryDrawer] + /// should exclude all navigation items that are in [navItems]. + final Widget drawer; + + /// The secondary, more compact, navigation drawer. + /// + /// When there are primary navigation items, it is recommended not to include + /// them in the drawer, as that may confuse your users. Instead, provide two + /// drawers, one with them and the other, without. + /// + /// This field should exclude all navigation items that are in [navItems], + /// whereas [drawer] should include them. + final Widget? secondaryDrawer; + + /// The [side sheet](https://material.io/components/sheets-side). + /// + /// On larger screens (tablets and desktops), this will be a standard + /// (persistent) side sheet. On smaller screens, this will be modal. + /// + /// See [LayoutInfo.hasStandardSideSheet]. + final Widget? sideSheet; + + /// The [Floating Action Button](https://material.io/components/buttons-floating-action-button). + /// + /// Currently, the position does not change based on layout. + final Widget? floatingActionButton; + + /// The location of the floating action button. + final FloatingActionButtonLocation? floatingActionButtonLocation; + + /// The navigation items. + /// + /// On phones, these will be in a [BottomNavigationBar]. On tablets, these + /// will be in a [NavigationRail]. On desktops, these should be included in + /// [drawer] instead. + /// + /// On phones and tablets, so that the items do not appear twice, provide a + /// [secondaryDrawer] that does not include these items. + final List? navItems; + + /// The initial index of the current navigation item in [navItems]. + final int? initialNavIndex; + + /// Creates a scaffold that responds to the screen size. + const ResponsiveScaffold({ + required this.drawer, + required this.bodyBuilder, + required this.appBar, + this.floatingActionButton, + this.sideSheet, + this.floatingActionButtonLocation, + }) : + secondaryDrawer = null, + navItems = null, + initialNavIndex = null; + + /// Creates a responsive layout with primary navigation items. + const ResponsiveScaffold.navBar({ + required this.drawer, + required this.secondaryDrawer, + required List this.navItems, + required int this.initialNavIndex, + }) : + appBar = null, + sideSheet = null, + bodyBuilder = null, + floatingActionButton = null, + floatingActionButtonLocation = null; + + /// Whether this widget is being used with a navigation bar. + bool get hasNavBar => navItems != null; + + @override + ResponsiveScaffoldState createState() => ResponsiveScaffoldState(); +} + +/// The state of the scaffold. +class ResponsiveScaffoldState extends State { + late int _navIndex; + + /// The index of the current navigation item in [ResponsiveScaffold.navItems]. + int get navIndex => _navIndex; + set navIndex(int value) { + _dispose(navItem); + _navIndex = value; + _listen(navItem); + setState(() {}); + } + + /// The currently selected navigation item, if any. + NavigationItem? get navItem => !widget.hasNavBar ? null + : widget.navItems! [navIndex]; + + /// Refreshes the page when the underlying data changes. + void listener() => setState(() {}); + + void _listen(NavigationItem? item) => item?.model?.addListener(listener); + void _dispose(NavigationItem? item) => item?.model?.removeListener(listener); + + @override + void initState() { + super.initState(); + _navIndex = widget.initialNavIndex ?? 0; + _listen(navItem); + } + + @override + void dispose() { + _dispose(navItem); + for (final NavigationItem item in widget.navItems ?? []) { + if (item.shouldDispose) { + item.model?.dispose(); + } + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => ResponsiveBuilder( + // ignore: sort_child_properties_last + child: navItem?.build(context) ?? widget.bodyBuilder?.call(context), + builder: (BuildContext context, LayoutInfo info, Widget? child) => Scaffold( + appBar: navItem?.appBar ?? widget.appBar, + drawer: info.hasStandardDrawer ? null + : Drawer(child: widget.hasNavBar ? widget.secondaryDrawer : widget.drawer), + endDrawer: info.hasStandardSideSheet + || (navItem?.sideSheet ?? widget.sideSheet) == null + ? null : Drawer(child: navItem?.sideSheet ?? widget.sideSheet), + floatingActionButton: navItem?.floatingActionButton + ?? widget.floatingActionButton, + floatingActionButtonLocation: navItem?.floatingActionButtonLocation + ?? widget.floatingActionButtonLocation, + bottomNavigationBar: !widget.hasNavBar || !info.hasBottomNavBar ? null + : BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: [ + for (final NavigationItem item in widget.navItems!) + item.bottomNavBar, + ], + currentIndex: navIndex, + onTap: (int index) => navIndex = index, + ), + body: Row( + children: [ + if (widget.hasNavBar && info.hasNavRail) NavigationRail( + labelType: NavigationRailLabelType.all, + destinations: [ + for (final NavigationItem item in widget.navItems!) + item.navRail, + ], + selectedIndex: navIndex, + onDestinationSelected: (int index) => navIndex = index, + ) + else if (info.hasStandardDrawer) widget.drawer, + Expanded(child: child!), + if ( + (navItem?.sideSheet ?? widget.sideSheet) != null + && info.hasStandardSideSheet + ) ...[ + const VerticalDivider(), + SizedBox( + width: 320, + child: Drawer( + elevation: 0, + child: navItem?.sideSheet ?? widget.sideSheet + ), + ) + ] + ] + ) + ), + ); +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 2986e1bf3..387b50f91 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -19,7 +19,6 @@ export "src/widgets/ambient/theme_changer.dart"; // Atomic widgets represent a single data object. export "src/widgets/atomic/activity_tile.dart"; export "src/widgets/atomic/admin/calendar_tile.dart"; -export "src/widgets/atomic/admin/period_tile.dart"; export "src/widgets/atomic/next_class.dart"; export "src/widgets/atomic/reminder_tile.dart"; export "src/widgets/atomic/sports_tile.dart"; @@ -30,6 +29,7 @@ export "src/widgets/generic/date_picker.dart"; export "src/widgets/generic/footer.dart"; export "src/widgets/generic/icons.dart"; export "src/widgets/generic/model_listener.dart"; +export "src/widgets/generic/responsive_scaffold.dart"; // Widgets that help represent images. export "src/widgets/images/link_icon.dart"; diff --git a/pubspec.lock b/pubspec.lock index b25dbe3da..6b99b1a5a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,174 +1,223 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "47.0.0" + adaptive_breakpoints: + dependency: "direct main" + description: + name: adaptive_breakpoints + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.5" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" archive: dependency: transitive description: name: archive url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "3.1.11" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "2.3.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.5.0-nullsafety" + version: "2.9.0" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety" + version: "2.1.0" characters: dependency: transitive description: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.2" + version: "1.2.1" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety" + version: "1.3.1" clock: dependency: transitive description: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety" + version: "1.1.1" cloud_firestore: dependency: "direct main" description: name: cloud_firestore url: "https://pub.dartlang.org" source: hosted - version: "0.14.0+2" + version: "3.1.7" cloud_firestore_platform_interface: dependency: transitive description: name: cloud_firestore_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "5.4.12" cloud_firestore_web: dependency: transitive description: name: cloud_firestore_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.0+1" + version: "2.6.7" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0-nullsafety.2" + version: "1.16.0" convert: dependency: transitive description: name: convert url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "3.0.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" crypto: dependency: transitive description: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.5" + version: "3.0.1" fake_async: dependency: transitive description: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety" - file: + version: "1.3.1" + ffi: dependency: transitive description: - name: file + name: ffi url: "https://pub.dartlang.org" source: hosted - version: "5.2.1" - firebase: + version: "1.1.2" + file: dependency: transitive description: - name: firebase + name: file url: "https://pub.dartlang.org" source: hosted - version: "7.3.0" + version: "6.1.2" firebase_auth: dependency: "direct main" description: name: firebase_auth url: "https://pub.dartlang.org" source: hosted - version: "0.18.0+1" + version: "3.3.6" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "6.1.11" firebase_auth_web: dependency: transitive description: name: firebase_auth_web url: "https://pub.dartlang.org" source: hosted - version: "0.3.0+1" + version: "3.3.7" firebase_core: - dependency: transitive + dependency: "direct main" description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "0.5.0" + version: "1.12.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "4.2.4" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "1.5.4" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "0.1.4+1" + version: "2.5.1" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.13" firebase_messaging: dependency: "direct main" description: name: firebase_messaging url: "https://pub.dartlang.org" source: hosted - version: "6.0.16" + version: "11.2.6" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.6" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.7" flutter: dependency: "direct main" description: flutter @@ -180,14 +229,28 @@ packages: name: flutter_launcher_icons url: "https://pub.dartlang.org" source: hosted - version: "0.7.5" + version: "0.9.2" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications url: "https://pub.dartlang.org" source: hosted - version: "0.8.4+3" + version: "5.0.0+4" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + flutter_native_timezone: + dependency: "direct main" + description: + name: flutter_native_timezone + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -198,340 +261,571 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" google_sign_in: dependency: "direct main" description: name: google_sign_in url: "https://pub.dartlang.org" source: hosted - version: "4.5.3" + version: "5.2.3" google_sign_in_platform_interface: dependency: transitive description: name: google_sign_in_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "2.1.1" google_sign_in_web: dependency: transitive description: name: google_sign_in_web url: "https://pub.dartlang.org" source: hosted - version: "0.9.1+1" - http: + version: "0.10.0+4" + http_multi_server: dependency: transitive description: - name: http + name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "0.12.2" + version: "3.2.0" http_parser: dependency: transitive description: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "3.1.4" + version: "4.0.0" idb_shim: dependency: "direct main" description: name: idb_shim url: "https://pub.dartlang.org" source: hosted - version: "1.12.0" + version: "2.0.1" image: dependency: transitive description: name: image url: "https://pub.dartlang.org" source: hosted - version: "2.1.14" + version: "3.1.1" intl: dependency: transitive description: name: intl url: "https://pub.dartlang.org" source: hosted - version: "0.16.1" + version: "0.17.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" js: dependency: transitive description: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.2" + version: "0.6.4" + link_text: + dependency: "direct main" + description: + name: link_text + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" matcher: dependency: "direct dev" description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10-nullsafety" + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.2" + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" path: dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety" + version: "1.8.2" path_provider: dependency: "direct main" description: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "1.6.14" + version: "2.0.8" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+2" + version: "2.1.5" path_provider_macos: dependency: transitive description: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.4+3" + version: "2.0.5" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "2.0.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" pedantic: dependency: transitive description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.9.0" + version: "1.11.1" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "3.0.4" + version: "4.4.0" platform: dependency: transitive description: name: platform url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" - platform_detect: + version: "3.1.0" + plugin_platform_interface: dependency: transitive description: - name: platform_detect + name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" - plugin_platform_interface: + version: "2.1.2" + pool: dependency: transitive description: - name: plugin_platform_interface + name: pool url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.5.0" process: dependency: transitive description: name: process url: "https://pub.dartlang.org" source: hosted - version: "3.0.13" + version: "4.2.4" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "1.4.4" + version: "2.1.0" quiver: dependency: transitive description: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "2.1.3" + version: "3.0.1+1" sembast: dependency: transitive description: name: sembast url: "https://pub.dartlang.org" source: hosted - version: "2.4.7+6" + version: "3.1.2" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "0.5.10" + version: "2.0.13" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.2+2" + version: "2.1.0" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+10" + version: "2.0.3" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.0" shared_preferences_web: dependency: transitive description: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.2+7" + version: "2.0.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" source_span: dependency: transitive description: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety" + version: "1.9.0" stack_trace: dependency: transitive description: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.10.0-nullsafety" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety" + version: "2.1.0" + string_extensions: + dependency: "direct main" + description: + name: string_extensions + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0" string_scanner: dependency: transitive description: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety" + version: "1.1.1" synchronized: dependency: transitive description: name: synchronized url: "https://pub.dartlang.org" source: hosted - version: "2.2.0+2" + version: "3.0.0" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety" + version: "1.2.1" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.4" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.19-nullsafety" + version: "0.4.12" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.16" + timezone: + dependency: transitive + description: + name: timezone + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" typed_data: dependency: transitive description: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.2" + version: "1.3.0" url_launcher: dependency: "direct main" description: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "5.5.2" + version: "6.0.18" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+1" + version: "2.0.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+7" + version: "2.0.3" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.8" + version: "2.0.5" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.3+1" + version: "2.0.8" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" vector_math: dependency: transitive description: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.2" + version: "2.1.2" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "7.5.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.11" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.2.0+1" xml: dependency: transitive description: name: xml url: "https://pub.dartlang.org" source: hosted - version: "4.2.0" + version: "5.3.1" yaml: dependency: transitive description: name: yaml url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "3.1.0" sdks: - dart: ">=2.10.0-0.0.dev <2.10.0" - flutter: ">=1.12.13+hotfix.5 <2.0.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 41f99df29..bb205388b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,25 +7,35 @@ description: A new Flutter project. # Both the version and the builder number may be overridden in flutter # build by specifying --build-name and --build-number, respectively. # Read more about versioning at semver.org. -version: 2.0.1+1 +version: 1.1.1+15 environment: - sdk: ">=2.7.0 <=3.0.0" + sdk: ">=2.12.0 <=3.0.0" dependencies: flutter: sdk: flutter - url_launcher: ^5.2.5 - path_provider: ^1.4.4 - shared_preferences: ^0.5.4 - cloud_firestore: ^0.14.0 - firebase_auth: ^0.18.0+1 - google_sign_in: ^4.5.1 - firebase_messaging: ^6.0.1 - flutter_local_notifications: ^0.8.4+3 - firebase_crashlytics: ^0.1.1+2 - idb_shim: ^1.12.0 - + + # ------------- Services ------------- + cloud_firestore: ^3.1.6 + firebase_auth: ^3.3.5 + firebase_core: ^1.11.0 + firebase_crashlytics: ^2.4.5 + firebase_messaging: ^11.2.5 + google_sign_in: ^5.2.3 + + flutter_local_notifications: ^5.0.0 + idb_shim: ^2.0.0 + path_provider: ^2.0.1 + shared_preferences: ^2.0.5 + + # ------------- Misc ------------- + flutter_native_timezone: ^2.0.0 + url_launcher: ^6.0.3 + adaptive_breakpoints: ^0.0.4 + link_text: ^0.2.0 + string_extensions: ^0.4.0 + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. #cupertino_icons: ^0.1.2 @@ -34,8 +44,8 @@ dev_dependencies: flutter_test: sdk: flutter matcher: any + flutter_launcher_icons: ^0.9.0 # pedantic: ^1.4.0 - flutter_launcher_icons: ^0.7.0 flutter_icons: android: false # overwrite @@ -64,14 +74,9 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - images/icons/baseball.png - - images/icons/basketball.png - - images/icons/hockey.png - - images/icons/soccer.png - - images/icons/tennis.png - - images/icons/volleyball.png - images/logos/drive.png - images/logos/google.png + - images/logos/google_sign_in.png - images/logos/outlook.jpg - images/logos/schoology.png - images/logos/senior_systems.png @@ -79,6 +84,11 @@ flutter: - images/logos/ramaz/ram_square_words.png - images/logos/ramaz/ram_square.png - images/logos/ramaz/teal.jpg + - images/contributors/brayden-kohler.jpg + - images/contributors/david-tarrab.jpg + - images/contributors/eli-vovsha.jpg + - images/contributors/josh-todes.jpg + - images/contributors/levi-lesches.jpg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware. diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 33eb74505..000000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 38049cc52..fd2787f45 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 20a09199b..2ef46b8bc 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/index.html b/web/index.html index 0d295bf0e..149351803 100644 --- a/web/index.html +++ b/web/index.html @@ -11,6 +11,7 @@ name="google-signin-client_id" content="267381428578-s9bsc6bqvfebqimv3th6cjp7qildrpsc.apps.googleusercontent.com" > + @@ -25,44 +26,17 @@ - - - - - - - - - - - - - diff --git a/web/manifest.json b/web/manifest.json index fce1bbe82..0013270ec 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -12,12 +12,20 @@ { "src": "icons/Icon-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" }, { "src": "icons/Icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "maskable" } ] }