diff --git a/experimental/web_dashboard/README.md b/experimental/web_dashboard/README.md index d8f307650..0d9ce84d6 100644 --- a/experimental/web_dashboard/README.md +++ b/experimental/web_dashboard/README.md @@ -30,3 +30,94 @@ Skia / CanvasKit mode: flutter run -d chrome --release --dart-define=FLUTTER_WEB_USE_SKIA=true ``` +## Running JSON code generator + +``` +flutter pub run grinder generate +``` + +## Add Firebase + +### Step 1: Create a new Firebase project + +Go to [console.firebase.google.com](https://console.firebase.google.com/) and +create a new Firebase project. + +### Step 2: Enable Google Sign In for your project + +In the Firebase console, go to "Authentication" and enable Google sign in. Click +on "Web SDK Configuration" and copy down your Web client ID. + +### Step 3: Add Client ID to `index.html` + +Uncomment this line in `index.html` and replace `` with the +client ID from Step 2: + +```html + + +``` + +### Step 4: Create a web app + +In the Firebase console, under "Project overview", click "Add app", select Web, +and replace the contents of `web/firebase_init.js`. + +```javascript +// web/firebase_init.js +var firebaseConfig = { + apiKey: "", + authDomain: "", + databaseURL: "", + projectId: "", + storageBucket: "", + messagingSenderId: "", + appId: "" +}; + +// Initialize Firebase +firebase.initializeApp(firebaseConfig); +``` + +### Step 4: Create Cloud Firestore + +Create a new Cloud Firestore database and add the following rules to disallow +users from reading/writing other users' data: + +``` +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // Make sure the uid of the requesting user matches name of the user + // document. The wildcard expression {userId} makes the userId variable + // available in rules. + match /users/{userId}/{document=**} { + allow read, update, delete: if request.auth.uid == userId; + allow create: if request.auth.uid != null; + } + } +} +``` + +### Step 5: Run the app + +Run the app on port 5000: + +```bash +flutter run -d chrome --web-port=5000 +``` + +If you see CORS errors in your browser's console, go to the [Services +section][cloud-console-apis] in the Google Cloud console, go to Credentials, and +verify that `localhost:5000` is whitelisted. + +### (optional) Step 7: Set up iOS and Android +If you would like to run the app on iOS or Android, make sure you've installed +the appropriate configuration files described at +[firebase.google.com/docs/flutter/setup][flutter-setup] from step 1, and follow +the instructions detailed in the [google_sign_in README][google-sign-in] + +[flutter-setup]: https://firebase.google.com/docs/flutter/setup +[cloud-console-apis]: https://console.developers.google.com/apis/dashboard +[google-sign-in]: https://pub.dev/packages/google_sign_in diff --git a/experimental/web_dashboard/analysis_options.yaml b/experimental/web_dashboard/analysis_options.yaml new file mode 100644 index 000000000..dda88dcb3 --- /dev/null +++ b/experimental/web_dashboard/analysis_options.yaml @@ -0,0 +1,31 @@ +include: package:pedantic/analysis_options.1.8.0.yaml + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false + +linter: + rules: + - avoid_types_on_closure_parameters + - avoid_void_async + - await_only_futures + - camel_case_types + - cancel_subscriptions + - close_sinks + - constant_identifier_names + - control_flow_in_finally + - directives_ordering + - empty_statements + - hash_and_equals + - implementation_imports + - non_constant_identifier_names + - package_api_docs + - package_names + - package_prefixed_library_names + - test_types_in_equals + - throw_in_finally + - unnecessary_brace_in_string_interps + - unnecessary_getters_setters + - unnecessary_new + - unnecessary_statements \ No newline at end of file diff --git a/experimental/web_dashboard/android/app/build.gradle b/experimental/web_dashboard/android/app/build.gradle index f6151e0b0..2d20f623a 100644 --- a/experimental/web_dashboard/android/app/build.gradle +++ b/experimental/web_dashboard/android/app/build.gradle @@ -39,7 +39,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "dev.flutter.web_dashboard" - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 28 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/experimental/web_dashboard/ios/Flutter/Debug.xcconfig b/experimental/web_dashboard/ios/Flutter/Debug.xcconfig index 592ceee85..e8efba114 100644 --- a/experimental/web_dashboard/ios/Flutter/Debug.xcconfig +++ b/experimental/web_dashboard/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/experimental/web_dashboard/ios/Flutter/Release.xcconfig b/experimental/web_dashboard/ios/Flutter/Release.xcconfig index 592ceee85..399e9340e 100644 --- a/experimental/web_dashboard/ios/Flutter/Release.xcconfig +++ b/experimental/web_dashboard/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/experimental/web_dashboard/ios/Podfile b/experimental/web_dashboard/ios/Podfile new file mode 100644 index 000000000..6697f0a53 --- /dev/null +++ b/experimental/web_dashboard/ios/Podfile @@ -0,0 +1,87 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + generated_key_values = {} + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) do |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + generated_key_values[podname] = podpath + else + puts "Invalid plugin specification: #{line}" + end + end + generated_key_values +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Flutter Pod + + copied_flutter_dir = File.join(__dir__, 'Flutter') + copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') + copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') + unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) + # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. + # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. + # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. + + generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') + unless File.exist?(generated_xcode_build_settings_path) + raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) + cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; + + unless File.exist?(copied_framework_path) + FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) + end + unless File.exist?(copied_podspec_path) + FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) + end + end + + # Keep pod path relative so it can be checked into Podfile.lock. + pod 'Flutter', :path => 'Flutter' + + # Plugin Pods + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + system('rm -rf .symlinks') + system('mkdir -p .symlinks/plugins') + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.each do |name, path| + symlink = File.join('.symlinks', 'plugins', name) + File.symlink(path, symlink) + pod name, :path => File.join(symlink, 'ios') + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end +end diff --git a/experimental/web_dashboard/ios/Podfile.lock b/experimental/web_dashboard/ios/Podfile.lock new file mode 100644 index 000000000..f05e59026 --- /dev/null +++ b/experimental/web_dashboard/ios/Podfile.lock @@ -0,0 +1,410 @@ +PODS: + - abseil/algorithm (0.20190808): + - abseil/algorithm/algorithm (= 0.20190808) + - abseil/algorithm/container (= 0.20190808) + - abseil/algorithm/algorithm (0.20190808) + - abseil/algorithm/container (0.20190808): + - abseil/algorithm/algorithm + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/base (0.20190808): + - abseil/base/atomic_hook (= 0.20190808) + - abseil/base/base (= 0.20190808) + - abseil/base/base_internal (= 0.20190808) + - abseil/base/bits (= 0.20190808) + - abseil/base/config (= 0.20190808) + - abseil/base/core_headers (= 0.20190808) + - abseil/base/dynamic_annotations (= 0.20190808) + - abseil/base/endian (= 0.20190808) + - abseil/base/log_severity (= 0.20190808) + - abseil/base/malloc_internal (= 0.20190808) + - abseil/base/pretty_function (= 0.20190808) + - abseil/base/spinlock_wait (= 0.20190808) + - abseil/base/throw_delegate (= 0.20190808) + - abseil/base/atomic_hook (0.20190808) + - abseil/base/base (0.20190808): + - 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/spinlock_wait + - abseil/meta/type_traits + - abseil/base/base_internal (0.20190808): + - abseil/meta/type_traits + - abseil/base/bits (0.20190808): + - abseil/base/core_headers + - abseil/base/config (0.20190808) + - abseil/base/core_headers (0.20190808): + - abseil/base/config + - abseil/base/dynamic_annotations (0.20190808) + - abseil/base/endian (0.20190808): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity (0.20190808): + - abseil/base/core_headers + - abseil/base/malloc_internal (0.20190808): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/spinlock_wait + - abseil/base/pretty_function (0.20190808) + - abseil/base/spinlock_wait (0.20190808): + - abseil/base/core_headers + - abseil/base/throw_delegate (0.20190808): + - abseil/base/base + - abseil/base/config + - abseil/memory (0.20190808): + - abseil/memory/memory (= 0.20190808) + - abseil/memory/memory (0.20190808): + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/meta (0.20190808): + - abseil/meta/type_traits (= 0.20190808) + - abseil/meta/type_traits (0.20190808): + - abseil/base/config + - abseil/numeric/int128 (0.20190808): + - abseil/base/config + - abseil/base/core_headers + - abseil/strings/internal (0.20190808): + - abseil/base/core_headers + - abseil/base/endian + - abseil/meta/type_traits + - abseil/strings/strings (0.20190808): + - abseil/base/base + - abseil/base/bits + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/throw_delegate + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/numeric/int128 + - abseil/strings/internal + - abseil/time (0.20190808): + - abseil/time/internal (= 0.20190808) + - abseil/time/time (= 0.20190808) + - abseil/time/internal (0.20190808): + - abseil/time/internal/cctz (= 0.20190808) + - abseil/time/internal/cctz (0.20190808): + - abseil/time/internal/cctz/civil_time (= 0.20190808) + - abseil/time/internal/cctz/includes (= 0.20190808) + - abseil/time/internal/cctz/time_zone (= 0.20190808) + - abseil/time/internal/cctz/civil_time (0.20190808) + - abseil/time/internal/cctz/includes (0.20190808) + - abseil/time/internal/cctz/time_zone (0.20190808): + - abseil/time/internal/cctz/civil_time + - abseil/time/time (0.20190808): + - abseil/base/base + - abseil/base/core_headers + - abseil/numeric/int128 + - abseil/strings/strings + - abseil/time/internal/cctz/civil_time + - abseil/time/internal/cctz/time_zone + - abseil/types (0.20190808): + - abseil/types/any (= 0.20190808) + - abseil/types/bad_any_cast (= 0.20190808) + - abseil/types/bad_any_cast_impl (= 0.20190808) + - abseil/types/bad_optional_access (= 0.20190808) + - abseil/types/bad_variant_access (= 0.20190808) + - abseil/types/compare (= 0.20190808) + - abseil/types/optional (= 0.20190808) + - abseil/types/span (= 0.20190808) + - abseil/types/variant (= 0.20190808) + - abseil/types/any (0.20190808): + - 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.20190808): + - abseil/base/config + - abseil/types/bad_any_cast_impl + - abseil/types/bad_any_cast_impl (0.20190808): + - abseil/base/base + - abseil/base/config + - abseil/types/bad_optional_access (0.20190808): + - abseil/base/base + - abseil/base/config + - abseil/types/bad_variant_access (0.20190808): + - abseil/base/base + - abseil/base/config + - abseil/types/compare (0.20190808): + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/types/optional (0.20190808): + - 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.20190808): + - abseil/algorithm/algorithm + - abseil/base/core_headers + - abseil/base/throw_delegate + - abseil/meta/type_traits + - abseil/types/variant (0.20190808): + - 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.20190808): + - abseil/base/base_internal + - abseil/base/config + - abseil/meta/type_traits + - AppAuth (1.3.0): + - AppAuth/Core (= 1.3.0) + - AppAuth/ExternalUserAgent (= 1.3.0) + - AppAuth/Core (1.3.0) + - AppAuth/ExternalUserAgent (1.3.0) + - BoringSSL-GRPC (0.0.3): + - BoringSSL-GRPC/Implementation (= 0.0.3) + - BoringSSL-GRPC/Interface (= 0.0.3) + - BoringSSL-GRPC/Implementation (0.0.3): + - BoringSSL-GRPC/Interface (= 0.0.3) + - BoringSSL-GRPC/Interface (0.0.3) + - cloud_firestore (0.0.1): + - Firebase/Core + - Firebase/Firestore (~> 6.0) + - Flutter + - cloud_firestore_web (0.1.0): + - Flutter + - Firebase/Auth (6.18.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 6.4.3) + - Firebase/Core (6.18.0): + - Firebase/CoreOnly + - FirebaseAnalytics (= 6.3.0) + - Firebase/CoreOnly (6.18.0): + - FirebaseCore (= 6.6.3) + - Firebase/Firestore (6.18.0): + - Firebase/CoreOnly + - FirebaseFirestore (~> 1.11.0) + - firebase_auth (0.0.1): + - Firebase/Auth (~> 6.3) + - Firebase/Core + - Flutter + - firebase_auth_web (0.1.0): + - Flutter + - firebase_core (0.0.1): + - Firebase/Core + - Flutter + - firebase_core_web (0.1.0): + - Flutter + - FirebaseAnalytics (6.3.0): + - FirebaseCore (~> 6.6) + - FirebaseInstallations (~> 1.1) + - GoogleAppMeasurement (= 6.3.0) + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (= 0.3.9011) + - FirebaseAuth (6.4.3): + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.6) + - GoogleUtilities/AppDelegateSwizzler (~> 6.5) + - GoogleUtilities/Environment (~> 6.5) + - GTMSessionFetcher/Core (~> 1.1) + - FirebaseAuthInterop (1.0.0) + - FirebaseCore (6.6.3): + - FirebaseCoreDiagnostics (~> 1.2) + - FirebaseCoreDiagnosticsInterop (~> 1.2) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/Logger (~> 6.5) + - FirebaseCoreDiagnostics (1.2.1): + - FirebaseCoreDiagnosticsInterop (~> 1.2) + - GoogleDataTransportCCTSupport (~> 1.3) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/Logger (~> 6.5) + - nanopb (~> 0.3.901) + - FirebaseCoreDiagnosticsInterop (1.2.0) + - FirebaseFirestore (1.11.0): + - abseil/algorithm (= 0.20190808) + - abseil/base (= 0.20190808) + - abseil/memory (= 0.20190808) + - abseil/meta (= 0.20190808) + - abseil/strings/strings (= 0.20190808) + - abseil/time (= 0.20190808) + - abseil/types (= 0.20190808) + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.2) + - "gRPC-C++ (= 0.0.9)" + - leveldb-library (~> 1.22) + - nanopb (~> 0.3.901) + - FirebaseInstallations (1.1.0): + - FirebaseCore (~> 6.6) + - GoogleUtilities/UserDefaults (~> 6.5) + - PromisesObjC (~> 1.2) + - Flutter (1.0.0) + - google_sign_in (0.0.1): + - Flutter + - GoogleSignIn (~> 5.0) + - google_sign_in_web (0.8.1): + - Flutter + - GoogleAppMeasurement (6.3.0): + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (= 0.3.9011) + - GoogleDataTransport (4.0.1) + - GoogleDataTransportCCTSupport (1.4.1): + - GoogleDataTransport (~> 4.0) + - nanopb (~> 0.3.901) + - GoogleSignIn (5.0.2): + - AppAuth (~> 1.2) + - GTMAppAuth (~> 1.0) + - GTMSessionFetcher/Core (~> 1.1) + - GoogleUtilities/AppDelegateSwizzler (6.5.1): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (6.5.1) + - GoogleUtilities/Logger (6.5.1): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (6.5.1): + - GoogleUtilities/Logger + - GoogleUtilities/Network (6.5.1): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (6.5.1)" + - GoogleUtilities/Reachability (6.5.1): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (6.5.1): + - GoogleUtilities/Logger + - "gRPC-C++ (0.0.9)": + - "gRPC-C++/Implementation (= 0.0.9)" + - "gRPC-C++/Interface (= 0.0.9)" + - "gRPC-C++/Implementation (0.0.9)": + - "gRPC-C++/Interface (= 0.0.9)" + - gRPC-Core (= 1.21.0) + - nanopb (~> 0.3) + - "gRPC-C++/Interface (0.0.9)" + - gRPC-Core (1.21.0): + - gRPC-Core/Implementation (= 1.21.0) + - gRPC-Core/Interface (= 1.21.0) + - gRPC-Core/Implementation (1.21.0): + - BoringSSL-GRPC (= 0.0.3) + - gRPC-Core/Interface (= 1.21.0) + - nanopb (~> 0.3) + - gRPC-Core/Interface (1.21.0) + - GTMAppAuth (1.0.0): + - AppAuth/Core (~> 1.0) + - GTMSessionFetcher (~> 1.1) + - GTMSessionFetcher (1.3.1): + - GTMSessionFetcher/Full (= 1.3.1) + - GTMSessionFetcher/Core (1.3.1) + - GTMSessionFetcher/Full (1.3.1): + - GTMSessionFetcher/Core (= 1.3.1) + - leveldb-library (1.22) + - nanopb (0.3.9011): + - nanopb/decode (= 0.3.9011) + - nanopb/encode (= 0.3.9011) + - nanopb/decode (0.3.9011) + - nanopb/encode (0.3.9011) + - PromisesObjC (1.2.8) + +DEPENDENCIES: + - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) + - cloud_firestore_web (from `.symlinks/plugins/cloud_firestore_web/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_auth_web (from `.symlinks/plugins/firebase_auth_web/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_core_web (from `.symlinks/plugins/firebase_core_web/ios`) + - Flutter (from `Flutter`) + - google_sign_in (from `.symlinks/plugins/google_sign_in/ios`) + - google_sign_in_web (from `.symlinks/plugins/google_sign_in_web/ios`) + +SPEC REPOS: + trunk: + - abseil + - AppAuth + - BoringSSL-GRPC + - Firebase + - FirebaseAnalytics + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreDiagnostics + - FirebaseCoreDiagnosticsInterop + - FirebaseFirestore + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleDataTransportCCTSupport + - GoogleSignIn + - GoogleUtilities + - "gRPC-C++" + - gRPC-Core + - GTMAppAuth + - GTMSessionFetcher + - leveldb-library + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + cloud_firestore: + :path: ".symlinks/plugins/cloud_firestore/ios" + cloud_firestore_web: + :path: ".symlinks/plugins/cloud_firestore_web/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_auth_web: + :path: ".symlinks/plugins/firebase_auth_web/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_core_web: + :path: ".symlinks/plugins/firebase_core_web/ios" + Flutter: + :path: Flutter + google_sign_in: + :path: ".symlinks/plugins/google_sign_in/ios" + google_sign_in_web: + :path: ".symlinks/plugins/google_sign_in_web/ios" + +SPEC CHECKSUMS: + abseil: 18063d773f5366ff8736a050fe035a28f635fd27 + AppAuth: 73574f3013a1e65b9601a3ddc8b3158cce68c09d + BoringSSL-GRPC: db8764df3204ccea016e1c8dd15d9a9ad63ff318 + cloud_firestore: 31454d48df21f3e1a900015e36143c0d46a304b7 + cloud_firestore_web: 9ec3dc7f5f98de5129339802d491c1204462bfec + Firebase: 0490eca762a72e4f1582319539153897f1508dee + firebase_auth: 4ee3a54d3f09434c508c284a62f895a741a30637 + firebase_auth_web: 0955c07bcc06e84af76b9d4e32e6f31518f2d7de + firebase_core: 0d8be0e0d14c4902953aeb5ac5d7316d1fe4b978 + firebase_core_web: d501d8b946b60c8af265428ce483b0fff5ad52d1 + FirebaseAnalytics: 058d71e714a1a6804d9e0f25e3bb18e377a51579 + FirebaseAuth: 5ce2b03a3d7fe56b7a6e4c5ec7ff1522890b1d6f + FirebaseAuthInterop: 0ffa57668be100582bb7643d4fcb7615496c41fc + FirebaseCore: 78276943ad85e616dfa54dafa6c89512987d9d60 + FirebaseCoreDiagnostics: 2109d10c35e8289b1ee6cabf44d9ffb055620194 + FirebaseCoreDiagnosticsInterop: 296e2c5f5314500a850ad0b83e9e7c10b011a850 + FirebaseFirestore: a23d596ae3a8c13d3b8353b565d2adfb690f9032 + FirebaseInstallations: 575cd32f2aec0feeb0e44f5d0110a09e5e60b47b + Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + google_sign_in: f32920a589fdf4ab2918ec6dc5e5b0d5b8040ff5 + google_sign_in_web: 52deb24929ac0992baff65c57956031c44ed44c3 + GoogleAppMeasurement: 39ecba10918b21c83877d392246157f65db351cf + GoogleDataTransport: 653963cf5be60fb59cf051e070f0836fdc305f81 + GoogleDataTransportCCTSupport: 84e4d4bbab642f2e9d83ee65d78aca2b5527d314 + GoogleSignIn: 7137d297ddc022a7e0aa4619c86d72c909fa7213 + GoogleUtilities: 06eb53bb579efe7099152735900dd04bf09e7275 + "gRPC-C++": 9dfe7b44821e7b3e44aacad2af29d2c21f7cde83 + gRPC-Core: c9aef9a261a1247e881b18059b84d597293c9947 + GTMAppAuth: 4deac854479704f348309e7b66189e604cf5e01e + GTMSessionFetcher: cea130bbfe5a7edc8d06d3f0d17288c32ffe9925 + leveldb-library: 55d93ee664b4007aac644a782d11da33fba316f7 + nanopb: 18003b5e52dab79db540fe93fe9579f399bd1ccd + PromisesObjC: c119f3cd559f50b7ae681fa59dc1acd19173b7e6 + +PODFILE CHECKSUM: c34e2287a9ccaa606aeceab922830efb9a6ff69a + +COCOAPODS: 1.9.1 diff --git a/experimental/web_dashboard/ios/Runner.xcodeproj/project.pbxproj b/experimental/web_dashboard/ios/Runner.xcodeproj/project.pbxproj index 53e2e54c8..b77796935 100644 --- a/experimental/web_dashboard/ios/Runner.xcodeproj/project.pbxproj +++ b/experimental/web_dashboard/ios/Runner.xcodeproj/project.pbxproj @@ -8,12 +8,9 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 385270C76FB0F533A7165A2E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 64BDA6743063ECE1AA5E480E /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -26,8 +23,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -35,21 +30,23 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 08134052407BF94155A97FD0 /* 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 = ""; }; 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 = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 46DCF2E0FFA915CFF3790E62 /* 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 = ""; }; + 64BDA6743063ECE1AA5E480E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E61020DFA7983C4F990D457D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -57,8 +54,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, + 385270C76FB0F533A7165A2E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -68,9 +64,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -84,6 +78,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + C968A41427A6C202DE27F5B1 /* Pods */, + D15429FA0FA3908CDDF0F16E /* Frameworks */, ); sourceTree = ""; }; @@ -118,6 +114,25 @@ name = "Supporting Files"; sourceTree = ""; }; + C968A41427A6C202DE27F5B1 /* Pods */ = { + isa = PBXGroup; + children = ( + E61020DFA7983C4F990D457D /* Pods-Runner.debug.xcconfig */, + 46DCF2E0FFA915CFF3790E62 /* Pods-Runner.release.xcconfig */, + 08134052407BF94155A97FD0 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D15429FA0FA3908CDDF0F16E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 64BDA6743063ECE1AA5E480E /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -125,12 +140,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + AC382224AC8D58F121CB73F0 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 7028144C5268179DEEEA28F6 /* [CP] Embed Pods Frameworks */, + 98E026760D1C31FD88E5C2A0 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -201,7 +219,47 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 7028144C5268179DEEEA28F6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/AppAuth/AppAuth.framework", + "${BUILT_PRODUCTS_DIR}/BoringSSL-GRPC/openssl_grpc.framework", + "${PODS_ROOT}/../Flutter/Flutter.framework", + "${BUILT_PRODUCTS_DIR}/GTMAppAuth/GTMAppAuth.framework", + "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", + "${BUILT_PRODUCTS_DIR}/abseil/absl.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-C++/grpcpp.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework", + "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl_grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMAppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/absl.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpcpp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -217,6 +275,46 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + 98E026760D1C31FD88E5C2A0 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", + "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + AC382224AC8D58F121CB73F0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/experimental/web_dashboard/ios/Runner.xcworkspace/contents.xcworkspacedata b/experimental/web_dashboard/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16e..21a3cc14c 100644 --- a/experimental/web_dashboard/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/experimental/web_dashboard/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/experimental/web_dashboard/lib/main.dart b/experimental/web_dashboard/lib/main.dart index 9769349cd..74e7df4e1 100644 --- a/experimental/web_dashboard/lib/main.dart +++ b/experimental/web_dashboard/lib/main.dart @@ -6,4 +6,6 @@ import 'package:flutter/material.dart'; import 'src/app.dart'; -void main() => runApp(DashboardApp()); +void main() { + runApp(DashboardApp()); +} diff --git a/experimental/web_dashboard/lib/main_mock.dart b/experimental/web_dashboard/lib/main_mock.dart new file mode 100644 index 000000000..c0b1767e6 --- /dev/null +++ b/experimental/web_dashboard/lib/main_mock.dart @@ -0,0 +1,11 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'src/app.dart'; + +void main() { + runApp(DashboardApp.mock()); +} diff --git a/experimental/web_dashboard/lib/src/api/api.dart b/experimental/web_dashboard/lib/src/api/api.dart index 69818842e..4445178b5 100644 --- a/experimental/web_dashboard/lib/src/api/api.dart +++ b/experimental/web_dashboard/lib/src/api/api.dart @@ -2,44 +2,106 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'api.g.dart'; + /// Manipulates app data, abstract class DashboardApi { - ItemApi get items; + CategoryApi get categories; EntryApi get entries; } -/// Manipulates [Item] data. -abstract class ItemApi { - Future delete(String id); - Future get(String id); - Future insert(Item item); - Future> list(); - Future update(Item item, String id); - Stream> allItemsStream(); -} +/// Manipulates [Category] data. +abstract class CategoryApi { + Future delete(String id); -/// Something being tracked. -class Item { - final String name; - String id; + Future get(String id); + + Future insert(Category category); + + Future> list(); + + Future update(Category category, String id); - Item(this.name); + Stream> subscribe(); } /// Manipulates [Entry] data. abstract class EntryApi { - Future delete(String itemId, String id); - Future insert(String itemId, Entry entry); - Future> list(String itemId); - Future update(String itemId, String id, Entry entry); - Stream> allEntriesStream(String itemId); + Future delete(String categoryId, String id); + + Future get(String categoryId, String id); + + Future insert(String categoryId, Entry entry); + + Future> list(String categoryId); + + Future update(String categoryId, String id, Entry entry); + + Stream> subscribe(String categoryId); +} + +/// Something that's being tracked, e.g. Hours Slept, Cups of water, etc. +@JsonSerializable() +class Category { + String name; + + @JsonKey(ignore: true) + String id; + + Category(this.name); + + factory Category.fromJson(Map json) => + _$CategoryFromJson(json); + + Map toJson() => _$CategoryToJson(this); + + @override + operator ==(Object other) => other is Category && other.id == id; + @override + int get hashCode => id.hashCode; + @override + String toString() { + return ''; + } } /// A number tracked at a point in time. +@JsonSerializable() class Entry { - final int value; - final DateTime time; + int value; + @JsonKey(fromJson: _timestampToDateTime, toJson: _dateTimeToTimestamp) + DateTime time; + + @JsonKey(ignore: true) String id; Entry(this.value, this.time); + + factory Entry.fromJson(Map json) => _$EntryFromJson(json); + + Map toJson() => _$EntryToJson(this); + + static DateTime _timestampToDateTime(Timestamp timestamp) { + return DateTime.fromMillisecondsSinceEpoch( + timestamp.millisecondsSinceEpoch); + } + + static Timestamp _dateTimeToTimestamp(DateTime dateTime) { + return Timestamp.fromMillisecondsSinceEpoch( + dateTime.millisecondsSinceEpoch); + } + + @override + operator ==(Object other) => other is Entry && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() { + return ''; + } } diff --git a/experimental/web_dashboard/lib/src/api/api.g.dart b/experimental/web_dashboard/lib/src/api/api.g.dart new file mode 100644 index 000000000..c4c029040 --- /dev/null +++ b/experimental/web_dashboard/lib/src/api/api.g.dart @@ -0,0 +1,33 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Category _$CategoryFromJson(Map json) { + return Category( + json['name'] as String, + ); +} + +Map _$CategoryToJson(Category instance) => { + 'name': instance.name, + }; + +Entry _$EntryFromJson(Map json) { + return Entry( + json['value'] as int, + Entry._timestampToDateTime(json['time'] as Timestamp), + ); +} + +Map _$EntryToJson(Entry instance) => { + 'value': instance.value, + 'time': Entry._dateTimeToTimestamp(instance.time), + }; diff --git a/experimental/web_dashboard/lib/src/api/firebase.dart b/experimental/web_dashboard/lib/src/api/firebase.dart index 47beab6af..5bdd981b5 100644 --- a/experimental/web_dashboard/lib/src/api/firebase.dart +++ b/experimental/web_dashboard/lib/src/api/firebase.dart @@ -1,3 +1,151 @@ // Copyright 2020, the Flutter project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; + +import 'api.dart'; + +class FirebaseDashboardApi implements DashboardApi { + @override + final EntryApi entries; + + @override + final CategoryApi categories; + + FirebaseDashboardApi(Firestore firestore, String userId) + : entries = FirebaseEntryApi(firestore, userId), + categories = FirebaseCategoryApi(firestore, userId); +} + +class FirebaseEntryApi implements EntryApi { + final Firestore firestore; + final String userId; + final CollectionReference _categoriesRef; + + FirebaseEntryApi(this.firestore, this.userId) + : _categoriesRef = firestore.collection('users/$userId/categories'); + + @override + Stream> subscribe(String categoryId) { + var snapshots = _categoriesRef + .document('$categoryId') + .collection('entries') + .snapshots(); + var result = snapshots.map((querySnapshot) { + return querySnapshot.documents.map((snapshot) { + return Entry.fromJson(snapshot.data)..id = snapshot.documentID; + }).toList(); + }); + + return result; + } + + @override + Future delete(String categoryId, String id) async { + var document = _categoriesRef.document('$categoryId/entries/$id'); + var entry = await get(categoryId, document.documentID); + + await document.delete(); + + return entry; + } + + @override + Future insert(String categoryId, Entry entry) async { + var document = await _categoriesRef + .document('$categoryId') + .collection('entries') + .add(entry.toJson()); + return await get(categoryId, document.documentID); + } + + @override + Future> list(String categoryId) async { + var entriesRef = + _categoriesRef.document('$categoryId').collection('entries'); + var querySnapshot = await entriesRef.getDocuments(); + var entries = querySnapshot.documents + .map((doc) => Entry.fromJson(doc.data)..id = doc.documentID) + .toList(); + + return entries; + } + + @override + Future update(String categoryId, String id, Entry entry) async { + var document = _categoriesRef.document('$categoryId/entries/$id'); + await document.setData(entry.toJson()); + var snapshot = await document.get(); + return Entry.fromJson(snapshot.data)..id = snapshot.documentID; + } + + @override + Future get(String categoryId, String id) async { + var document = _categoriesRef.document('$categoryId/entries/$id'); + var snapshot = await document.get(); + return Entry.fromJson(snapshot.data)..id = snapshot.documentID; + } +} + +class FirebaseCategoryApi implements CategoryApi { + final Firestore firestore; + final String userId; + final CollectionReference _categoriesRef; + + FirebaseCategoryApi(this.firestore, this.userId) + : _categoriesRef = firestore.collection('users/$userId/categories'); + + @override + Stream> subscribe() { + var snapshots = _categoriesRef.snapshots(); + var result = snapshots.map((querySnapshot) { + return querySnapshot.documents.map((snapshot) { + return Category.fromJson(snapshot.data)..id = snapshot.documentID; + }).toList(); + }); + + return result; + } + + @override + Future delete(String id) async { + var document = _categoriesRef.document('$id'); + var categories = await get(document.documentID); + + await document.delete(); + + return categories; + } + + @override + Future get(String id) async { + var document = _categoriesRef.document('$id'); + var snapshot = await document.get(); + return Category.fromJson(snapshot.data)..id = snapshot.documentID; + } + + @override + Future insert(Category category) async { + var document = await _categoriesRef.add(category.toJson()); + return await get(document.documentID); + } + + @override + Future> list() async { + var querySnapshot = await _categoriesRef.getDocuments(); + var categories = querySnapshot.documents + .map((doc) => Category.fromJson(doc.data)..id = doc.documentID) + .toList(); + + return categories; + } + + @override + Future update(Category category, String id) async { + var document = _categoriesRef.document('$id'); + await document.setData(category.toJson()); + var snapshot = await document.get(); + return Category.fromJson(snapshot.data)..id = snapshot.documentID; + } +} diff --git a/experimental/web_dashboard/lib/src/api/mock.dart b/experimental/web_dashboard/lib/src/api/mock.dart index 3d05242fb..6f47200c5 100644 --- a/experimental/web_dashboard/lib/src/api/mock.dart +++ b/experimental/web_dashboard/lib/src/api/mock.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:math'; import 'package:uuid/uuid.dart' as uuid; @@ -13,48 +14,67 @@ class MockDashboardApi implements DashboardApi { final EntryApi entries = MockEntryApi(); @override - final ItemApi items = MockItemApi(); + final CategoryApi categories = MockCategoryApi(); + + MockDashboardApi(); + + /// Creates a [MockDashboardApi] filled with mock data for the last 30 days. + Future fillWithMockData() async { + await Future.delayed(Duration(seconds: 1)); + var category1 = await categories.insert(Category('Coffee (oz)')); + var category2 = await categories.insert(Category('Running (miles)')); + var category3 = await categories.insert(Category('Git Commits')); + var monthAgo = DateTime.now().subtract(Duration(days: 30)); + + for (var category in [category1, category2, category3]) { + for (var i = 0; i < 30; i++) { + var date = monthAgo.add(Duration(days: i)); + var value = Random().nextInt(6) + 1; + await entries.insert(category.id, Entry(value, date)); + } + } + } } -class MockItemApi implements ItemApi { - Map _storage = {}; - StreamController> _streamController = - StreamController>.broadcast(); +class MockCategoryApi implements CategoryApi { + Map _storage = {}; + StreamController> _streamController = + StreamController>.broadcast(); @override - Future delete(String id) async { + Future delete(String id) async { + var removed = _storage.remove(id); _emit(); - return _storage.remove(id); + return removed; } @override - Future get(String id) async { + Future get(String id) async { return _storage[id]; } @override - Future insert(Item item) async { + Future insert(Category category) async { var id = uuid.Uuid().v4(); - var newItem = Item(item.name)..id = id; - _storage[id] = newItem; + var newCategory = Category(category.name)..id = id; + _storage[id] = newCategory; _emit(); - return newItem; + return newCategory; } @override - Future> list() async { + Future> list() async { return _storage.values.toList(); } @override - Future update(Item item, String id) async { - _storage[id] = item; - return item..id = id; + Future update(Category category, String id) async { + _storage[id] = category; + _emit(); + return category..id = id; } - Stream> allItemsStream() { - return _streamController.stream; - } + Stream> subscribe() => _streamController.stream; void _emit() { _streamController.add(_storage.values.toList()); @@ -63,44 +83,64 @@ class MockItemApi implements ItemApi { class MockEntryApi implements EntryApi { Map _storage = {}; - StreamController> _streamController = - StreamController>.broadcast(); + StreamController<_EntriesEvent> _streamController = + StreamController.broadcast(); @override - Future delete(String itemId, String id) async { - _emit(); - return _storage.remove('$itemId-$id'); + Future delete(String categoryId, String id) async { + _emit(categoryId); + return _storage.remove('$categoryId-$id'); } @override - Future insert(String itemId, Entry entry) async { + Future insert(String categoryId, Entry entry) async { var id = uuid.Uuid().v4(); var newEntry = Entry(entry.value, entry.time)..id = id; - _storage['$itemId-$id'] = newEntry; - _emit(); + _storage['$categoryId-$id'] = newEntry; + _emit(categoryId); return newEntry; } @override - Future> list(String itemId) async { + Future> list(String categoryId) async { return _storage.keys - .where((k) => k.startsWith(itemId)) + .where((k) => k.startsWith(categoryId)) .map((k) => _storage[k]) .toList(); } @override - Future update(String itemId, String id, Entry entry) async { - _storage['$itemId-$id'] = entry; + Future update(String categoryId, String id, Entry entry) async { + _storage['$categoryId-$id'] = entry; + _emit(categoryId); return entry..id = id; } @override - Stream> allEntriesStream(String itemId) { - return _streamController.stream; + Stream> subscribe(String categoryId) { + return _streamController.stream + .where((event) => event.categoryId == categoryId) + .map((event) => event.entries); } - void _emit() { - _streamController.add(_storage.values.toList()); + void _emit(String categoryId) { + var entries = _storage.keys + .where((k) => k.startsWith(categoryId)) + .map((k) => _storage[k]) + .toList(); + + _streamController.add(_EntriesEvent(categoryId, entries)); + } + + @override + Future get(String categoryId, String id) async { + return _storage['$categoryId-$id']; } } + +class _EntriesEvent { + final String categoryId; + final List entries; + + _EntriesEvent(this.categoryId, this.entries); +} diff --git a/experimental/web_dashboard/lib/src/app.dart b/experimental/web_dashboard/lib/src/app.dart index 0ca1deee2..1daacfe8e 100644 --- a/experimental/web_dashboard/lib/src/app.dart +++ b/experimental/web_dashboard/lib/src/app.dart @@ -2,58 +2,102 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'api/api.dart'; +import 'api/firebase.dart'; import 'api/mock.dart'; +import 'auth/auth.dart'; +import 'auth/firebase.dart'; +import 'auth/mock.dart'; import 'pages/home.dart'; -import 'widgets/third_party/adaptive_scaffold.dart'; +import 'pages/sign_in.dart'; -/// An app that shows a responsive dashboard. +/// The global state the app. +class AppState { + final Auth auth; + DashboardApi api; + + AppState(this.auth); +} + +/// Creates a [DashboardApi] when the user is logged in. +typedef DashboardApi ApiBuilder(User user); + +/// An app that displays a personalized dashboard. class DashboardApp extends StatefulWidget { + static ApiBuilder _mockApiBuilder = + (user) => MockDashboardApi()..fillWithMockData(); + static ApiBuilder _apiBuilder = + (user) => FirebaseDashboardApi(Firestore.instance, user.uid); + + final Auth auth; + final ApiBuilder apiBuilder; + + /// Runs the app using Firebase + DashboardApp() + : auth = FirebaseAuthService(), + apiBuilder = _apiBuilder; + + /// Runs the app using mock data + DashboardApp.mock() + : auth = MockAuthService(), + apiBuilder = _mockApiBuilder; + @override _DashboardAppState createState() => _DashboardAppState(); } class _DashboardAppState extends State { - int _pageIndex = 0; + AppState _appState; + + void initState() { + super.initState(); + _appState = AppState(widget.auth); + } @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - Provider(create: (_) => MockDashboardApi()), - ], + return Provider.value( + value: _appState, child: MaterialApp( - home: AdaptiveScaffold( - currentIndex: _pageIndex, - destinations: [ - AdaptiveScaffoldDestination(title: 'Home', icon: Icons.home), - AdaptiveScaffoldDestination(title: 'Entries', icon: Icons.list), - AdaptiveScaffoldDestination( - title: 'Settings', icon: Icons.settings), - ], - body: _pageAtIndex(_pageIndex), - onNavigationIndexChange: (newIndex) { - setState(() { - _pageIndex = newIndex; - }); - }, + home: Builder( + builder: (context) => SignInPage( + auth: _appState.auth, + onSuccess: (user) => _handleSignIn(user, context, _appState), + ), ), ), ); } - static Widget _pageAtIndex(int index) { - switch (index) { - case 1: - return Center(child: Text('page 2')); - case 2: - return Center(child: Text('page 3')); - case 0: - default: - return HomePage(); - } + /// Sets the DashboardApi on AppState and navigates to the home page. + void _handleSignIn(User user, BuildContext context, AppState appState) { + appState.api = widget.apiBuilder(user); + + _showPage(HomePage(), context); + } + + /// Navigates to the home page using a fade transition. + void _showPage(Widget page, BuildContext context) { + var route = _fadeRoute(page); + Navigator.of(context).pushReplacement(route); + } + + /// Creates a [Route] that shows [newPage] using a fade transition. + Route _fadeRoute(Widget newPage) { + return PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) { + return newPage; + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation.drive(CurveTween(curve: Curves.ease)), + child: child, + ); + }, + ); } } diff --git a/experimental/web_dashboard/lib/src/auth/auth.dart b/experimental/web_dashboard/lib/src/auth/auth.dart new file mode 100644 index 000000000..dd02bae28 --- /dev/null +++ b/experimental/web_dashboard/lib/src/auth/auth.dart @@ -0,0 +1,12 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +abstract class Auth { + Future signIn(); + Future signOut(); +} + +abstract class User { + String get uid; +} diff --git a/experimental/web_dashboard/lib/src/auth/firebase.dart b/experimental/web_dashboard/lib/src/auth/firebase.dart new file mode 100644 index 000000000..f11a02c0f --- /dev/null +++ b/experimental/web_dashboard/lib/src/auth/firebase.dart @@ -0,0 +1,41 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:firebase_auth/firebase_auth.dart' hide FirebaseUser; + +import 'auth.dart'; + +class FirebaseAuthService implements Auth { + final GoogleSignIn _googleSignIn = GoogleSignIn(); + final FirebaseAuth _auth = FirebaseAuth.instance; + + Future signIn() async { + GoogleSignInAccount googleUser; + if (await _googleSignIn.isSignedIn()) { + googleUser = await _googleSignIn.signInSilently(); + } else { + googleUser = await _googleSignIn.signIn(); + } + + var googleAuth = await googleUser.authentication; + + var credential = GoogleAuthProvider.getCredential( + accessToken: googleAuth.accessToken, idToken: googleAuth.idToken); + + var authResult = await _auth.signInWithCredential(credential); + + return _FirebaseUser(authResult.user.uid); + } + + Future signOut() async { + await _auth.signOut(); + } +} + +class _FirebaseUser implements User { + final String uid; + + _FirebaseUser(this.uid); +} diff --git a/experimental/web_dashboard/lib/src/auth/mock.dart b/experimental/web_dashboard/lib/src/auth/mock.dart new file mode 100644 index 000000000..45766a67d --- /dev/null +++ b/experimental/web_dashboard/lib/src/auth/mock.dart @@ -0,0 +1,21 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'auth.dart'; + +class MockAuthService implements Auth { + @override + Future signIn() async { + return MockUser(); + } + + @override + Future signOut() async { + return null; + } +} + +class MockUser implements User { + String get uid => "123"; +} diff --git a/experimental/web_dashboard/lib/src/pages/dashboard.dart b/experimental/web_dashboard/lib/src/pages/dashboard.dart new file mode 100644 index 000000000..93772b99c --- /dev/null +++ b/experimental/web_dashboard/lib/src/pages/dashboard.dart @@ -0,0 +1,67 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../api/api.dart'; +import '../app.dart'; +import '../widgets/category_chart.dart'; + +class DashboardPage extends StatelessWidget { + Widget build(BuildContext context) { + var appState = Provider.of(context); + return FutureBuilder>( + future: appState.api.categories.list(), + builder: (context, futureSnapshot) { + if (!futureSnapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + return StreamBuilder>( + initialData: futureSnapshot.data, + stream: appState.api.categories.subscribe(), + builder: (context, snapshot) { + if (snapshot.data == null) { + return Center( + child: CircularProgressIndicator(), + ); + } + return Dashboard(snapshot.data); + }, + ); + }, + ); + } +} + +class Dashboard extends StatelessWidget { + final List categories; + + Dashboard(this.categories); + + @override + Widget build(BuildContext context) { + var api = Provider.of(context).api; + return Scrollbar( + child: GridView( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + childAspectRatio: 2, + maxCrossAxisExtent: 500, + ), + children: [ + ...categories.map( + (category) => Card( + child: CategoryChart( + category: category, + api: api, + ), + ), + ) + ], + ), + ); + } +} diff --git a/experimental/web_dashboard/lib/src/pages/entries.dart b/experimental/web_dashboard/lib/src/pages/entries.dart new file mode 100644 index 000000000..40e25c3fa --- /dev/null +++ b/experimental/web_dashboard/lib/src/pages/entries.dart @@ -0,0 +1,161 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart' as intl; + +import '../api/api.dart'; +import '../app.dart'; +import '../widgets/categories_dropdown.dart'; +import '../widgets/dialogs.dart'; + +class EntriesPage extends StatefulWidget { + @override + _EntriesPageState createState() => _EntriesPageState(); +} + +class _EntriesPageState extends State { + Category _selected; + + @override + Widget build(BuildContext context) { + var appState = Provider.of(context); + return Column( + children: [ + CategoryDropdown( + api: appState.api.categories, + onSelected: (category) => setState(() => _selected = category)), + Expanded( + child: _selected == null + ? Center(child: CircularProgressIndicator()) + : EntriesList( + category: _selected, + api: appState.api.entries, + ), + ), + ], + ); + } +} + +class EntriesList extends StatefulWidget { + final Category category; + final EntryApi api; + + EntriesList({ + @required this.category, + @required this.api, + }) : super(key: ValueKey(category.id)); + + @override + _EntriesListState createState() => _EntriesListState(); +} + +class _EntriesListState extends State { + @override + Widget build(BuildContext context) { + if (widget.category == null) { + return _buildLoadingIndicator(); + } + + return FutureBuilder>( + future: widget.api.list(widget.category.id), + builder: (context, futureSnapshot) { + if (!futureSnapshot.hasData) { + return _buildLoadingIndicator(); + } + return StreamBuilder>( + initialData: futureSnapshot.data, + stream: widget.api.subscribe(widget.category.id), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return _buildLoadingIndicator(); + } + return ListView.builder( + itemBuilder: (context, index) { + return EntryTile( + category: widget.category, + entry: snapshot.data[index], + ); + }, + itemCount: snapshot.data.length, + ); + }, + ); + }, + ); + } + + Widget _buildLoadingIndicator() { + return Center(child: CircularProgressIndicator()); + } +} + +class EntryTile extends StatelessWidget { + final Category category; + final Entry entry; + + EntryTile({ + this.category, + this.entry, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(entry.value.toString()), + subtitle: Text(intl.DateFormat('MM/dd/yy h:mm a').format(entry.time)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlatButton( + child: Text('Edit'), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return EditEntryDialog(category: category, entry: entry); + }, + ); + }, + ), + FlatButton( + child: Text('Delete'), + onPressed: () async { + var shouldDelete = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Delete entry?'), + actions: [ + FlatButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + FlatButton( + child: Text('Delete'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + if (shouldDelete) { + await Provider.of(context, listen: false) + .api + .entries + .delete(category.id, entry.id); + + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text('Entry deleted'), + ), + ); + } + }, + ), + ], + ), + ); + } +} diff --git a/experimental/web_dashboard/lib/src/pages/home.dart b/experimental/web_dashboard/lib/src/pages/home.dart index 1a423af36..868fb4844 100644 --- a/experimental/web_dashboard/lib/src/pages/home.dart +++ b/experimental/web_dashboard/lib/src/pages/home.dart @@ -3,47 +3,79 @@ // BSD-style license that can be found in the LICENSE file. import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../api/api.dart'; -import 'item_details.dart'; +import '../widgets/dialogs.dart'; +import '../widgets/third_party/adaptive_scaffold.dart'; +import 'dashboard.dart'; +import 'entries.dart'; -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _pageIndex = 0; + + @override Widget build(BuildContext context) { - var api = Provider.of(context); - - return Scaffold( - body: StreamProvider>( - initialData: [], - create: (context) => api.items.allItemsStream(), - child: Consumer>( - builder: (context, items, child) { - return ListView.builder( - itemBuilder: (context, idx) { - return ListTile( - title: Text(items[idx].name), - onTap: () { - _showDetails(items[idx], context); - }, - ); - }, - itemCount: items.length, - ); - }, - ), - ), - floatingActionButton: FloatingActionButton( - child: Icon(Icons.add), - onPressed: () { - api.items.insert(Item('Coffees Drank')); - }, - ), + return AdaptiveScaffold( + currentIndex: _pageIndex, + destinations: [ + AdaptiveScaffoldDestination(title: 'Home', icon: Icons.home), + AdaptiveScaffoldDestination(title: 'Entries', icon: Icons.list), + AdaptiveScaffoldDestination(title: 'Settings', icon: Icons.settings), + ], + body: _pageAtIndex(_pageIndex), + onNavigationIndexChange: (newIndex) { + setState(() { + _pageIndex = newIndex; + }); + }, + floatingActionButton: + _hasFloatingActionButton ? _buildFab(context) : null, ); } - void _showDetails(Item item, BuildContext context) { - Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return ItemDetailsPage(item); - })); + bool get _hasFloatingActionButton { + if (_pageIndex == 2) return false; + return true; + } + + FloatingActionButton _buildFab(BuildContext context) { + return FloatingActionButton( + child: Icon(Icons.add), + onPressed: () => _handleFabPressed(), + ); + } + + void _handleFabPressed() { + if (_pageIndex == 0) { + showDialog( + context: context, + builder: (context) => NewCategoryDialog(), + ); + return; + } + + if (_pageIndex == 1) { + showDialog( + context: context, + builder: (context) => NewEntryDialog(), + ); + return; + } + } + + Widget _pageAtIndex(int index) { + if (index == 0) { + return DashboardPage(); + } + + if (index == 1) { + return EntriesPage(); + } + + return Center(child: Text('Settings page')); } } diff --git a/experimental/web_dashboard/lib/src/pages/item_details.dart b/experimental/web_dashboard/lib/src/pages/item_details.dart deleted file mode 100644 index b52c1eea5..000000000 --- a/experimental/web_dashboard/lib/src/pages/item_details.dart +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2020, the Flutter project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:web_dashboard/src/api/api.dart'; - -class ItemDetailsPage extends StatelessWidget { - final Item item; - - ItemDetailsPage(this.item); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: Center( - child: Text('${item.name}'), - ), - ); - } -} diff --git a/experimental/web_dashboard/lib/src/pages/sign_in.dart b/experimental/web_dashboard/lib/src/pages/sign_in.dart new file mode 100644 index 000000000..04d79e975 --- /dev/null +++ b/experimental/web_dashboard/lib/src/pages/sign_in.dart @@ -0,0 +1,46 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../auth/auth.dart'; + +class SignInPage extends StatefulWidget { + final Auth auth; + final ValueChanged onSuccess; + + SignInPage({ + @required this.auth, + @required this.onSuccess, + }); + + @override + _SignInPageState createState() => _SignInPageState(); +} + +class _SignInPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: RaisedButton( + child: Text('Sign In'), + onPressed: () async { + var user = await widget.auth.signIn(); + if (user != null) { + widget.onSuccess(user); + } else { + throw ('Unable to sign in'); + } + }, + ), + ), + ); + } +} diff --git a/experimental/web_dashboard/lib/src/utils/chart_utils.dart b/experimental/web_dashboard/lib/src/utils/chart_utils.dart new file mode 100644 index 000000000..29f691234 --- /dev/null +++ b/experimental/web_dashboard/lib/src/utils/chart_utils.dart @@ -0,0 +1,65 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../api/api.dart'; +import 'day_helpers.dart'; + +/// The total value of one or more [Entry]s on a given day. +class EntryTotal { + final DateTime day; + int value; + + EntryTotal(this.day, this.value); +} + +/// Returns a list of [EntryTotal] objects. Each [EntryTotal] is the sum of +/// the values of all the entries on a given day. +List entryTotalsByDay(List entries, int daysAgo, + {DateTime today}) { + today ??= DateTime.now(); + return _entryTotalsByDay(entries, daysAgo, today).toList(); +} + +Iterable _entryTotalsByDay( + List entries, int daysAgo, DateTime today) sync* { + var start = today.subtract(Duration(days: daysAgo)); + var entriesByDay = _entriesInRange(start, today, entries); + + for (var i = 0; i < entriesByDay.length; i++) { + var list = entriesByDay[i]; + var entryTotal = EntryTotal(start.add(Duration(days: i)), 0); + + for (var entry in list) { + entryTotal.value += entry.value; + } + + yield entryTotal; + } +} + +/// Groups entries by day between [start] and [end]. The result is a list of +/// lists. The outer list represents the number of days since [start], and the +/// inner list is the group of entries on that day. +List> _entriesInRange( + DateTime start, DateTime end, List entries) => + _entriesInRangeImpl(start, end, entries).toList(); + +Iterable> _entriesInRangeImpl( + DateTime start, DateTime end, List entries) sync* { + start = start.atMidnight; + end = end.atMidnight; + var d = start; + + while (d.compareTo(end) <= 0) { + var es = []; + for (var entry in entries) { + if (d.isSameDay(entry.time.atMidnight)) { + es.add(entry); + } + } + + yield es; + d = d.add(Duration(days: 1)); + } +} diff --git a/experimental/web_dashboard/lib/src/utils/day_helpers.dart b/experimental/web_dashboard/lib/src/utils/day_helpers.dart new file mode 100644 index 000000000..20afdde92 --- /dev/null +++ b/experimental/web_dashboard/lib/src/utils/day_helpers.dart @@ -0,0 +1,15 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +extension DayUtils on DateTime { + /// The UTC date portion of a datetime, without the minutes, seconds, etc. + DateTime get atMidnight { + return DateTime.utc(year, month, day); + } + + /// Checks that the two [DateTime]s share the same date. + bool isSameDay(DateTime d2) { + return this.year == d2.year && this.month == d2.month && this.day == d2.day; + } +} diff --git a/experimental/web_dashboard/lib/src/widgets/categories_dropdown.dart b/experimental/web_dashboard/lib/src/widgets/categories_dropdown.dart new file mode 100644 index 000000000..c162647d6 --- /dev/null +++ b/experimental/web_dashboard/lib/src/widgets/categories_dropdown.dart @@ -0,0 +1,108 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import '../api/api.dart'; + +/// Subscribes to the latest list of categories and allows the user to select +/// one. +class CategoryDropdown extends StatefulWidget { + final CategoryApi api; + final ValueChanged onSelected; + + CategoryDropdown({ + @required this.api, + @required this.onSelected, + }); + + @override + _CategoryDropdownState createState() => _CategoryDropdownState(); +} + +class _CategoryDropdownState extends State { + Category _selected; + Future> _future; + Stream> _stream; + + void initState() { + super.initState(); + + // This widget needs to wait for the list of Categories, select the first + // Category, and emit an `onSelected` event. + // + // This could be done inside the FutureBuilder's `builder` callback, + // but calling setState() during the build is an error. (Calling the + // onSelected callback will also cause the parent widget to call + // setState()). + // + // Instead, we'll create a new Future that sets the selected Category and + // calls `onSelected` if necessary. Then, we'll pass *that* future to + // FutureBuilder. Now the selected category is set and events are emitted + // *before* the build is triggered by the FutureBuilder. + _future = widget.api.list().then((categories) { + if (categories.isEmpty) { + return categories; + } + + _setSelected(categories.first); + return categories; + }); + + // Same here, we'll create a new stream that handles any potential + // setState() operations before we trigger our StreamBuilder. + _stream = widget.api.subscribe().map((categories) { + if (!categories.contains(_selected) && categories.isNotEmpty) { + _setSelected(categories.first); + } + + return categories; + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _future, + builder: (context, futureSnapshot) { + // Show an empty dropdown while the data is loading. + if (!futureSnapshot.hasData) { + return DropdownButton(items: [], onChanged: null); + } + + return StreamBuilder>( + initialData: futureSnapshot.hasData ? futureSnapshot.data : [], + stream: _stream, + builder: (context, snapshot) { + var data = snapshot.hasData ? snapshot.data : []; + return DropdownButton( + value: _selected, + items: data.map(_buildDropdownItem).toList(), + onChanged: (category) { + _setSelected(category); + }, + ); + }, + ); + }, + ); + } + + void _setSelected(Category category) { + if (_selected == category) { + return; + } + setState(() { + _selected = category; + }); + + widget.onSelected(_selected); + } + + DropdownMenuItem _buildDropdownItem(Category category) { + return DropdownMenuItem( + child: Text(category.name), value: category); + } +} diff --git a/experimental/web_dashboard/lib/src/widgets/category_chart.dart b/experimental/web_dashboard/lib/src/widgets/category_chart.dart new file mode 100644 index 000000000..6e91ccb94 --- /dev/null +++ b/experimental/web_dashboard/lib/src/widgets/category_chart.dart @@ -0,0 +1,111 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:intl/intl.dart' as intl; + +import '../api/api.dart'; +import '../utils/chart_utils.dart' as utils; +import 'dialogs.dart'; + +// The number of days to show in the chart +const _daysBefore = 10; + +class CategoryChart extends StatelessWidget { + final Category category; + final DashboardApi api; + + CategoryChart({ + @required this.category, + @required this.api, + }); + + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(category.name), + IconButton( + icon: Icon(Icons.settings), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return EditCategoryDialog(category: category); + }, + ); + }, + ), + ], + ), + ), + Expanded( + // Load the initial snapshot using a FutureBuilder, and subscribe to + // additional updates with a StreamBuilder. + child: FutureBuilder>( + future: api.entries.list(category.id), + builder: (context, futureSnapshot) { + if (!futureSnapshot.hasData) { + return _buildLoadingIndicator(); + } + return StreamBuilder>( + initialData: futureSnapshot.data, + stream: api.entries.subscribe(category.id), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return _buildLoadingIndicator(); + } + return _BarChart(entries: snapshot.data); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _buildLoadingIndicator() { + return Center(child: CircularProgressIndicator()); + } +} + +class _BarChart extends StatelessWidget { + final List entries; + + _BarChart({this.entries}); + + @override + Widget build(BuildContext context) { + return charts.BarChart( + [_seriesData()], + animate: false, + ); + } + + charts.Series _seriesData() { + return charts.Series( + id: 'Entries', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (entryTotal, _) { + if (entryTotal == null) return null; + + var format = intl.DateFormat.Md(); + return format.format(entryTotal.day); + }, + measureFn: (total, _) { + if (total == null) return null; + + return total.value; + }, + data: utils.entryTotalsByDay(entries, _daysBefore), + ); + } +} diff --git a/experimental/web_dashboard/lib/src/widgets/category_forms.dart b/experimental/web_dashboard/lib/src/widgets/category_forms.dart new file mode 100644 index 000000000..98f0f23d6 --- /dev/null +++ b/experimental/web_dashboard/lib/src/widgets/category_forms.dart @@ -0,0 +1,103 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:web_dashboard/src/api/api.dart'; +import 'package:web_dashboard/src/app.dart'; + +class NewCategoryForm extends StatefulWidget { + @override + _NewCategoryFormState createState() => _NewCategoryFormState(); +} + +class _NewCategoryFormState extends State { + Category _category = Category(''); + + @override + Widget build(BuildContext context) { + var api = Provider.of(context).api; + return EditCategoryForm( + category: _category, + onDone: (shouldInsert) { + if (shouldInsert) { + api.categories.insert(_category); + } + Navigator.of(context).pop(); + }, + ); + } +} + +class EditCategoryForm extends StatefulWidget { + final Category category; + final ValueChanged onDone; + + EditCategoryForm({ + @required this.category, + @required this.onDone, + }); + + @override + _EditCategoryFormState createState() => _EditCategoryFormState(); +} + +class _EditCategoryFormState extends State { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + initialValue: widget.category.name, + decoration: InputDecoration( + labelText: 'Name', + ), + onChanged: (newValue) { + widget.category.name = newValue; + }, + validator: (value) { + if (value.isEmpty) { + return 'Please enter a name'; + } + return null; + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: RaisedButton( + child: Text('Cancel'), + onPressed: () { + widget.onDone(false); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: RaisedButton( + child: Text('OK'), + onPressed: () { + if (_formKey.currentState.validate()) { + widget.onDone(true); + } + }, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/experimental/web_dashboard/lib/src/widgets/dialogs.dart b/experimental/web_dashboard/lib/src/widgets/dialogs.dart new file mode 100644 index 000000000..3fd8fb174 --- /dev/null +++ b/experimental/web_dashboard/lib/src/widgets/dialogs.dart @@ -0,0 +1,98 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:web_dashboard/src/api/api.dart'; +import 'package:web_dashboard/src/widgets/category_forms.dart'; + +import '../app.dart'; +import 'edit_entry.dart'; + +class NewCategoryDialog extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SimpleDialog( + title: Text('New Category'), + children: [ + NewCategoryForm(), + ], + ); + } +} + +class EditCategoryDialog extends StatelessWidget { + final Category category; + + EditCategoryDialog({ + @required this.category, + }); + + @override + Widget build(BuildContext context) { + var api = Provider.of(context).api; + + return SimpleDialog( + title: Text('Edit Category'), + children: [ + EditCategoryForm( + category: category, + onDone: (shouldUpdate) { + if (shouldUpdate) { + api.categories.update(category, category.id); + } + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class NewEntryDialog extends StatefulWidget { + @override + _NewEntryDialogState createState() => _NewEntryDialogState(); +} + +class _NewEntryDialogState extends State { + @override + Widget build(BuildContext context) { + return SimpleDialog( + title: Text('New Entry'), + children: [ + NewEntryForm(), + ], + ); + } +} + +class EditEntryDialog extends StatelessWidget { + final Category category; + final Entry entry; + + EditEntryDialog({ + this.category, + this.entry, + }); + + @override + Widget build(BuildContext context) { + var api = Provider.of(context).api; + + return SimpleDialog( + title: Text('Edit Entry'), + children: [ + EditEntryForm( + entry: entry, + onDone: (shouldUpdate) { + if (shouldUpdate) { + api.entries.update(category.id, entry.id, entry); + } + Navigator.of(context).pop(); + }, + ) + ], + ); + } +} diff --git a/experimental/web_dashboard/lib/src/widgets/edit_entry.dart b/experimental/web_dashboard/lib/src/widgets/edit_entry.dart new file mode 100644 index 000000000..aba2c7b29 --- /dev/null +++ b/experimental/web_dashboard/lib/src/widgets/edit_entry.dart @@ -0,0 +1,154 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:web_dashboard/src/api/api.dart'; +import 'package:intl/intl.dart' as intl; + +import '../app.dart'; +import 'categories_dropdown.dart'; + +class NewEntryForm extends StatefulWidget { + @override + _NewEntryFormState createState() => _NewEntryFormState(); +} + +class _NewEntryFormState extends State { + Category _selected; + Entry _entry = Entry(0, DateTime.now()); + + @override + Widget build(BuildContext context) { + var api = Provider.of(context).api; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: CategoryDropdown( + api: api.categories, + onSelected: (category) { + setState(() { + _selected = category; + }); + }, + ), + ), + EditEntryForm( + entry: _entry, + onDone: (shouldInsert) { + if (shouldInsert) { + api.entries.insert(_selected.id, _entry); + } + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class EditEntryForm extends StatefulWidget { + final Entry entry; + final ValueChanged onDone; + + EditEntryForm({ + @required this.entry, + @required this.onDone, + }); + + @override + _EditEntryFormState createState() => _EditEntryFormState(); +} + +class _EditEntryFormState extends State { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(8), + child: TextFormField( + initialValue: widget.entry.value.toString(), + decoration: InputDecoration(labelText: 'Value'), + keyboardType: TextInputType.number, + validator: (value) { + try { + int.parse(value); + } catch (e) { + return "Please enter a whole number"; + } + return null; + }, + onChanged: (newValue) { + try { + widget.entry.value = int.parse(newValue); + } on FormatException { + print('Entry cannot contain "$newValue". Expected a number'); + } + }, + ), + ), + Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(intl.DateFormat('MM/dd/yyyy').format(widget.entry.time)), + RaisedButton( + child: Text('Edit'), + onPressed: () async { + var result = await showDatePicker( + context: context, + initialDate: widget.entry.time, + firstDate: DateTime.now().subtract(Duration(days: 365)), + lastDate: DateTime.now()); + if (result == null) { + return; + } + setState(() { + widget.entry.time = result; + }); + }, + ) + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: RaisedButton( + child: Text('Cancel'), + onPressed: () { + widget.onDone(false); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: RaisedButton( + child: Text('OK'), + onPressed: () { + if (_formKey.currentState.validate()) { + widget.onDone(true); + } + }, + ), + ), + ], + ) + ], + ), + ); + } +} diff --git a/experimental/web_dashboard/pubspec.lock b/experimental/web_dashboard/pubspec.lock index bd3a93fc0..0a006204c 100644 --- a/experimental/web_dashboard/pubspec.lock +++ b/experimental/web_dashboard/pubspec.lock @@ -7,21 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "2.1.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "0.39.8" - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" + version: "0.39.6" args: dependency: transitive description: @@ -43,6 +36,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.2" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "7.0.9" charcode: dependency: transitive description: @@ -50,6 +99,69 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.3" + charts_common: + dependency: transitive + description: + name: charts_common + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.0" + charts_flutter: + dependency: "direct main" + description: + name: charts_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3+2" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4+2" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" collection: dependency: transitive description: @@ -92,6 +204,76 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.4" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + firebase: + dependency: transitive + description: + name: firebase + url: "https://pub.dartlang.org" + source: hosted + version: "7.3.0" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.5+3" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.7" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.4+3" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" flutter: dependency: "direct main" description: flutter @@ -102,6 +284,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -109,6 +296,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.1" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + grinder: + dependency: "direct dev" + description: + name: grinder + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4" html: dependency: transitive description: @@ -122,7 +344,7 @@ packages: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.12.1" + version: "0.12.0+4" http_multi_server: dependency: transitive description: @@ -137,13 +359,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.4" - image: + intl: dependency: transitive description: - name: image + name: intl url: "https://pub.dartlang.org" source: hosted - version: "2.1.12" + version: "0.16.1" io: dependency: transitive description: @@ -158,6 +380,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.1+1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" logging: dependency: transitive description: @@ -213,7 +449,7 @@ packages: name: node_io url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.0.1+2" node_preamble: dependency: transitive description: @@ -234,7 +470,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.6.4" + version: "1.7.0" pedantic: dependency: transitive description: @@ -242,13 +478,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.9.0" - petitparser: + plugin_platform_interface: dependency: transitive description: - name: petitparser + name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.4.0" + version: "1.0.2" pool: dependency: transitive description: @@ -262,7 +498,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.0.4" pub_semver: dependency: transitive description: @@ -270,6 +506,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.4.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" quiver: dependency: transitive description: @@ -310,6 +553,13 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.5" source_map_stack_trace: dependency: transitive description: @@ -345,6 +595,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" string_scanner: dependency: transitive description: @@ -365,7 +622,7 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.14.3" + version: "1.14.2" test_api: dependency: transitive description: @@ -379,7 +636,14 @@ packages: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.3.4" + version: "0.3.3" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" typed_data: dependency: transitive description: @@ -407,14 +671,14 @@ packages: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "4.0.2" + version: "4.0.0" watcher: dependency: transitive description: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "0.9.7+15" + version: "0.9.7+14" web_socket_channel: dependency: transitive description: @@ -428,21 +692,14 @@ packages: name: webkit_inspection_protocol url: "https://pub.dartlang.org" source: hosted - version: "0.5.3" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" + version: "0.5.0+1" yaml: dependency: transitive description: name: yaml url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.2.0" sdks: dart: ">=2.7.0 <3.0.0" - flutter: ">=1.17.0" + flutter: ">=1.12.13+hotfix.4 <2.0.0" diff --git a/experimental/web_dashboard/pubspec.yaml b/experimental/web_dashboard/pubspec.yaml index 700989fed..d85f89e31 100644 --- a/experimental/web_dashboard/pubspec.yaml +++ b/experimental/web_dashboard/pubspec.yaml @@ -1,17 +1,26 @@ name: web_dashboard -description: A desktop-friendly dashboard app +description: A dashboard app sample version: 1.0.0+1 environment: - sdk: ">=2.3.0 <3.0.0" + sdk: ">=2.6.0 <3.0.0" dependencies: flutter: sdk: flutter + cloud_firestore: ^0.13.0 cupertino_icons: ^0.1.2 + firebase_auth: ^0.15.0 + firebase_core: ^0.4.3 + google_sign_in: ^4.4 0 + json_annotation: ^3.0.0 provider: ^4.0.0 uuid: ^2.0.0 + charts_flutter: ^0.9.0 dev_dependencies: flutter_test: sdk: flutter - test: any + build_runner: ^1.8.0 + json_serializable: ^3.3.0 + test: ^1.14.0 + grinder: ^0.8.4 flutter: uses-material-design: true diff --git a/experimental/web_dashboard/test/chart_utils_test.dart b/experimental/web_dashboard/test/chart_utils_test.dart new file mode 100644 index 000000000..a4ff14112 --- /dev/null +++ b/experimental/web_dashboard/test/chart_utils_test.dart @@ -0,0 +1,29 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; + +import 'package:web_dashboard/src/api/api.dart'; +import 'package:web_dashboard/src/utils/chart_utils.dart'; + +void main() { + group('chart utils', () { + test('totals entries by day', () async { + var entries = [ + Entry(10, DateTime(2020, 3, 1)), + Entry(10, DateTime(2020, 3, 1)), + Entry(10, DateTime(2020, 3, 2)), + ]; + var totals = entryTotalsByDay(entries, 2, today: DateTime(2020, 3, 2)); + expect(totals, hasLength(3)); + expect(totals[1].value, 20); + expect(totals[2].value, 10); + }); + test('days', () async { + expect( + DateTime.utc(2020, 1, 3).difference(DateTime.utc(2020, 1, 2)).inDays, + 1); + }); + }); +} diff --git a/experimental/web_dashboard/test/mock_service_test.dart b/experimental/web_dashboard/test/mock_service_test.dart index e331be5e5..433da748b 100644 --- a/experimental/web_dashboard/test/mock_service_test.dart +++ b/experimental/web_dashboard/test/mock_service_test.dart @@ -17,80 +17,81 @@ void main() { group('items', () { test('insert', () async { - var item = await api.items.insert(Item('Coffees Drank')); - expect(item.name, 'Coffees Drank'); + var category = await api.categories.insert(Category('Coffees Drank')); + expect(category.name, 'Coffees Drank'); }); test('delete', () async { - await api.items.insert(Item('Coffees Drank')); - var item2 = await api.items.insert(Item('Miles Ran')); - var removed = await api.items.delete(item2.id); + await api.categories.insert(Category('Coffees Drank')); + var category = await api.categories.insert(Category('Miles Ran')); + var removed = await api.categories.delete(category.id); expect(removed.name, 'Miles Ran'); - var items = await api.items.list(); - expect(items, hasLength(1)); + var categories = await api.categories.list(); + expect(categories, hasLength(1)); }); test('update', () async { - var item = await api.items.insert(Item('Coffees Drank')); - await api.items.update(Item('Bagels Consumed'), item.id); + var category = await api.categories.insert(Category('Coffees Drank')); + await api.categories.update(Category('Bagels Consumed'), category.id); - var latest = await api.items.get(item.id); + var latest = await api.categories.get(category.id); expect(latest.name, equals('Bagels Consumed')); }); test('subscribe', () async { - var stream = api.items.allItemsStream(); + var stream = api.categories.subscribe(); stream.listen(expectAsync1((x) { expect(x, hasLength(1)); expect(x.first.name, equals('Coffees Drank')); }, count: 1)); - await api.items.insert(Item('Coffees Drank')); + await api.categories.insert(Category('Coffees Drank')); }); }); group('entry service', () { - Item item; + Category category; DateTime dateTime = DateTime(2020, 1, 1, 30, 45); setUp(() async { - item = await api.items.insert(Item('Lines of code committed')); + category = + await api.categories.insert(Category('Lines of code committed')); }); test('insert', () async { - var entry = await api.entries.insert(item.id, Entry(1, dateTime)); + var entry = await api.entries.insert(category.id, Entry(1, dateTime)); expect(entry.value, 1); expect(entry.time, dateTime); }); test('delete', () async { - await api.entries.insert(item.id, Entry(1, dateTime)); - var entry2 = await api.entries.insert(item.id, Entry(2, dateTime)); + await api.entries.insert(category.id, Entry(1, dateTime)); + var entry2 = await api.entries.insert(category.id, Entry(2, dateTime)); - await api.entries.delete(item.id, entry2.id); + await api.entries.delete(category.id, entry2.id); - var entries = await api.entries.list(item.id); + var entries = await api.entries.list(category.id); expect(entries, hasLength(1)); }); test('update', () async { - var entry = await api.entries.insert(item.id, Entry(1, dateTime)); + var entry = await api.entries.insert(category.id, Entry(1, dateTime)); var updated = - await api.entries.update(item.id, entry.id, Entry(2, dateTime)); + await api.entries.update(category.id, entry.id, Entry(2, dateTime)); expect(updated.value, 2); }); test('subscribe', () async { - var stream = api.entries.allEntriesStream(item.id); + var stream = api.entries.subscribe(category.id); stream.listen(expectAsync1((x) { expect(x, hasLength(1)); expect(x.first.value, equals(1)); }, count: 1)); - api.entries.insert(item.id, Entry(1, dateTime)); + await api.entries.insert(category.id, Entry(1, dateTime)); }); }); }); diff --git a/experimental/web_dashboard/tool/grind.dart b/experimental/web_dashboard/tool/grind.dart new file mode 100644 index 000000000..e22bc923f --- /dev/null +++ b/experimental/web_dashboard/tool/grind.dart @@ -0,0 +1,108 @@ +// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as path; + +import 'package:grinder/grinder.dart'; + +void main(List args) => grind(args); + +@Task() +void runSkia() { + run('flutter', + arguments: + 'run -d web --web-port=5000 --release --dart-define=FLUTTER_WEB_USE_SKIA=true lib/main.dart ' + .split(' ')); +} + +@Task() +void runWeb() { + run('flutter', + arguments: 'run -d web --web-port=5000 lib/main.dart '.split(' ')); +} + +@Task() +void runMock() { + run('flutter', + arguments: 'run -d web --web-port=5000 lib/main_mock.dart '.split(' ')); +} + +@Task() +void runMockSkia() { + run('flutter', + arguments: + 'run -d web --web-port=5000 --release --dart-define=FLUTTER_WEB_USE_SKIA=true lib/main_mock.dart' + .split(' ')); +} + +@Task() +void test() { + TestRunner().testAsync(); +} + +@DefaultTask() +@Depends(test, copyright) +void build() { + Pub.build(); +} + +@Task() +void clean() => defaultClean(); + +@Task() +void generate() { + Pub.run('build_runner', arguments: ['build']); +} + +const _copyright = + '''// Copyright 2020, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file.'''; + +@Task() +Future copyright() async { + var files = []; + await for (var file in _filesWithoutCopyright()) { + files.add(file); + } + + if (files.isNotEmpty) { + log('Found Dart files without a copyright header:'); + for (var file in files) { + log(file.toString()); + } + fail('run "grind fix-copyright" to add copyright headers'); + } +} + +@Task() +Future fixCopyright() async { + await for (var file in _filesWithoutCopyright()) { + var contents = await file.readAsString(); + await file.writeAsString(_copyright + '\n\n' + contents); + } +} + +Stream _filesWithoutCopyright() async* { + var set = FileSet.fromDir(Directory('.'), recurse: true); + var dartFiles = + set.files.where((file) => path.extension(file.path) == '.dart'); + + for (var file in dartFiles) { + var firstThreeLines = await file + .openRead() + .transform(utf8.decoder) + .transform(LineSplitter()) + .take(3) + .fold('', (previous, element) { + if (previous == '') return element; + return previous + '\n' + element; + }); + + if (firstThreeLines != _copyright) { + yield file; + } + } +} diff --git a/experimental/web_dashboard/web/firebase_init.js b/experimental/web_dashboard/web/firebase_init.js new file mode 100644 index 000000000..54f55233f --- /dev/null +++ b/experimental/web_dashboard/web/firebase_init.js @@ -0,0 +1,12 @@ +// Your web app's Firebase configuration +var firebaseConfig = { + apiKey: "", + authDomain: "", + databaseURL: "", + projectId: "", + storageBucket: "", + messagingSenderId: "", + appId: "" +}; +// Initialize Firebase +firebase.initializeApp(firebaseConfig); diff --git a/experimental/web_dashboard/web/index.html b/experimental/web_dashboard/web/index.html index aa0b74c95..17171134f 100644 --- a/experimental/web_dashboard/web/index.html +++ b/experimental/web_dashboard/web/index.html @@ -13,6 +13,15 @@ web_dashboard + + + + + + + + +