commit 64d39ac266e7e76fad46709275319e85d4900582 Author: Franz Heinfling Date: Tue Oct 1 14:14:58 2019 +0200 Initial commit. Co-authored-by: Miguel Beltran diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..474fe5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +/build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Flutter/flutter_export_environment.sh + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +janoodle-prod-firebase-adminsdk.json diff --git a/.gradle/4.10.2/fileChanges/last-build.bin b/.gradle/4.10.2/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/4.10.2/fileChanges/last-build.bin differ diff --git a/.gradle/4.10.2/fileHashes/fileHashes.lock b/.gradle/4.10.2/fileHashes/fileHashes.lock new file mode 100644 index 0000000..b450500 Binary files /dev/null and b/.gradle/4.10.2/fileHashes/fileHashes.lock differ diff --git a/.gradle/4.10.2/gc.properties b/.gradle/4.10.2/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..033ad2a --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7a4c33425ddd78c54aba07d86f3f9a4a0051769b + channel: stable + +project_type: app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf6c637 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Janoodle Unlimited GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..db2e93e --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Timy app + +An amazing open source group messaging app build with flutter. ✨ + +# Main Features + +- Multiple groups (similar to Teams in Slack). +- Multiple *open or private* channels within groups. +- Sharing of photos and photo collections. +- React to messages with emoji. +- Push-notifications for message and channel updates. +- Specific channels for events (e.g. containing date, venue). +- Editing of event channels. +- Calendar for all upcoming and passed events aggregated over all groups and channels. +- English and German localization. +- RSVP for events. + + +![screenshots](./timy.png) + + + + + +# Project Structure + +This is a Flutter mobile app targeting Android and iOS. + +The code for the Flutter app is contained in the folder `lib` and the +different native apps are in `android` and `ios`. Extra project assets are in +`assets` and `fonts`. + +As well, this repo hosts a series of Firebase config files and cloud functions. + +The documentation for Firebase part is inside the `firebase` folder. + + +# Prerequisites & Getting Started +## Client + +To build and run the mobile apps you’ll need to install [Flutter](https://flutter.dev) and its dependencies. To verify your installation run in the project’s root directory:**‌** + +``` +$ flutter doctor +``` + +The app is optimised for Android and iOS phones in portrait mode. + +**Note:** Additionally you’ll need to add the GoogleService-Info of your Firebase app to your clients as described in `B3. Configure firebase app` below. + + +## Backend (Firebase) + +The backend is build using Firebase’s `node.js` SDK. All files are provided in the `firebase` folder. To deploy the code create an app and install the firebase CLI (See step 1 & 2 in [Getting started](https://firebase.google.com/docs/functions/get-started)). + +*Note: The following steps assume you’re using Firebases’ free `Spark Plan`. Therefore we’re performing the configuration manually.* + +### B1. Setup sign-in method & adding users + +An initial sing-in method needs to be configured. + +- Select your project in [console.firebase.google.com](https://console.firebase.google.com). +- Navigate to `Authentication` +- Select `Sign-in methods` and activate `Email / Password`. + + +**Adding a user** + +Currently users need to be added *manually*. + +- In firebase navigate to `Authentication` and select `Users`. +- Then `Add user`. + +Please copy the `User-UID` of the created user. We’ll need to add this ID to a group in the next step. + +*Note: You’ll need to have at least one user configured to use the app.* + + +### B2. Create and setup database +In the firebase console select `Database` under `Develop` and create a Realtime Database in region `eur3 (europe-west)`. + +*Note: To use the app you’ll need to create a group. A “Group” is similar to e.g. a “Team” in Slack. To create one:* + + +**Create group collection** + +- Select the database you’ve just created. +- `Create collection` and name it `groups`.\ +- Add your first group with the following properties: + +| name | type | value | +|:--|:--|:--| +| abbreviation | string | TE | +| color | string | ffffff | +| members | array | *User-UID we’ve retrieved in **Adding a user*** above | +| name | string | test | + +We’ve now setup our fist test group. In addition to this step we’ll need to setup a default `Channel` (e.g. something similar to `#general` in Slack). + + +**Create channel sub-collection** + +- In the `groups` collection select the newly created group. +- `Create collection` within the group called `channels`. +- Add your first channel with the following properties: + +| name | type | value | +|:--|:--|:--| +| name | string | general | +| type | string | TOPIC | +| visibility | string | OPEN | + + +### B3. Configure firebase app +Next you’ll need to configure your firebase app for Flutter as described in [Add Firebase to an App / Flutter](https://firebase.google.com/docs/flutter/setup) + + +**iOS** + +- Enter iOS-Bundle-ID: `de.janoodle.circlesApp.debug` +- Download and rename `GoogleService-Info.plist` to `GoogleService-Info-Dev.plist`. +- Copy file to `ios/Runner/Firebase`. + +*NOTE: If you’re building for release you’ll additionally need to add a GoogleService-Info-Prod.plist pointing to your production Firebase app.* + + +### B4. Deploy firebase functions + +Navigate to the `firebase` directory and deploy all functions using: + +``` +$ firebase deploy --only functions +``` + + +### B5. Final steps + +Run the flutter app using your favourite IDE (e.g. Visual Studio Code / Android Studio). Next you’ll need to run the app. + +*Note: Please skip any error that might occur.* + +Login with the user you’ve created above. +Next create your first `event` to setup the *calendar collection* in our backend. + + +**Create an event** + +- In the app select the hamburger menu +- Hit the `+` sign next to `Events` +- Enter any data you like and hit `Create` + +At the root level of your database you should now see a collection called `calendar` in your firebase console. + +Now we’re ready to deploy all parts of our backend using: + +``` +$ firebase deploy +``` + + +# Deployment + +The app is setup to work with a development and production environment. We suggest you create a different Firebase app for each environment. + +When building for release the app will automatically use the production configuration that you’ve configured in step `B3`. + + +# About + +The concept for Timy was created and developed by [kaalita](https://github.com/orgs/janoodleFTW/people/kaalita) and [philippmoeser](https://github.com/orgs/janoodleFTW/people/philippmoeser). +The the initial version is a MVP messaging app focusing on organising events among groups. + +We hope this project can be a reference or building block for your next flutter app. 🚀 \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..d2ffc08 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,57 @@ +# Specify analysis options. +# +# For a list of lints, see: http://dart-lang.github.io/linter/lints/ +# See the configuration guide for more +# https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer +# +# There are four similar analysis options files in the flutter repos: +# - analysis_options.yaml +# - packages/flutter/lib/analysis_options_user.yaml (this file) +# - https://github.com/flutter/plugins/blob/master/analysis_options.yaml +# - https://github.com/flutter/engine/blob/master/analysis_options.yaml +# +analyzer: + errors: + # treat missing required parameters as a warning (not a hint) + missing_required_param: warning + exclude: + - '**.g.dart' + +linter: + rules: + - avoid_empty_else + - avoid_init_to_null + - avoid_return_types_on_setters + - await_only_futures + - camel_case_types + - cancel_subscriptions + - close_sinks + - control_flow_in_finally + - empty_constructor_bodies + - empty_statements + - hash_and_equals + - implementation_imports + - library_names + - non_constant_identifier_names + - package_api_docs + - package_names + - package_prefixed_library_names + - prefer_const_constructors_in_immutables + - prefer_double_quotes + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_is_not_empty + - slash_for_doc_comments + - test_types_in_equals + - throw_in_finally + - type_init_formals + - unawaited_futures + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_getters_setters + - unnecessary_new + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + - valid_regexps diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..a9c2c4f --- /dev/null +++ b/android/README.md @@ -0,0 +1,25 @@ +# Timy Android + +## Firebase + +The Firebase configuration file `google-services.json` need to be provided and located inside the `android/src/main` folder. + +If you want to have different configs for release and debug, +provide two different files in the `android/src/release` and `android/src/debug` folders. + +## Distribution + +To build this application for distribution, +provide a file `key.jks` containing the signing keys, +and the `key.properties` with the following content: + +``` +storePassword=..... +keyPassword=..... +keyAlias=key +storeFile=../key.jks +``` + +Where you set the `storePassword` and the `keyPassword`. You can also change the alias. + +You can also provide this files from your CI instead of including this in the project. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..5ce9b6e --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,106 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion 28 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + applicationId "de.janoodle.circlesApp" + minSdkVersion 21 // avoid max 64k method limit + targetSdkVersion 28 + + def (majorNum, minorNum, buildNum) = flutterVersionName.tokenize( '.' ) + versionCode majorNum.toInteger() * 1_000_000 + minorNum.toInteger() * 1_000 + buildNum.toInteger() + versionName flutterVersionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + debug { + // app id will be "de.janoodle.circlesApp.debug" + applicationIdSuffix ".debug" + } + release { + // NOTE: ProGuard is not enabled at the moment + // See: https://flutter.dev/docs/deployment/android to enable it + signingConfig signingConfigs.release + } + } + + packagingOptions { + exclude 'META-INF/proguard/coroutines.pro' + } + + compileOptions { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + testImplementation 'junit:junit:4.12' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0' + + implementation "androidx.work:work-runtime-ktx:2.2.0" +} + +apply plugin: 'io.fabric' +apply plugin: 'com.google.gms.google-services' // Google Play services Gradle plugin diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..c08b9bb --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..b73f9ad --- /dev/null +++ b/android/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Timy DEV + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2bc9877 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/circles_app/ImageResize.kt b/android/app/src/main/kotlin/com/example/circles_app/ImageResize.kt new file mode 100644 index 0000000..e20b1c3 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/circles_app/ImageResize.kt @@ -0,0 +1,121 @@ +package com.example.circles_app + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.createScaledBitmap +import android.graphics.BitmapFactory +import android.media.ExifInterface +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream + +const val MAX_DIM = 1080.0 + +fun getAspectRatio(fileName: String): Double { + val bmp = BitmapFactory.decodeFile(fileName) + val exif = ExifInterface(fileName) + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED) + + return when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270 -> { + bmp.height.toDouble() / bmp.width.toDouble() + } + else -> { + bmp.width.toDouble() / bmp.height.toDouble() + } + } + +} + +fun resizeImage(fileName: String, applicationContext: Context): String { + val file = File(fileName) + if (!file.exists()) { + throw Exception("file does not exist $fileName") + } + + var bmp = BitmapFactory.decodeFile(fileName) + + // Picture is inside the max dimension, no need to compress + if (bmp.width <= MAX_DIM && bmp.height <= MAX_DIM) { + Log.d("ImageResize", "Image is already small: $fileName") + return fileName + } + + var targetWidth = MAX_DIM + var targetHeight = MAX_DIM + if (bmp.width > bmp.height) { + targetHeight = bmp.height.toDouble() * (MAX_DIM / bmp.width.toDouble()) + } else if (bmp.height > bmp.width) { + targetWidth = bmp.width.toDouble() * (MAX_DIM / bmp.height.toDouble()) + } + Log.d("ImageResize", "Target sizes: $targetHeight x $targetWidth") + + val quality = 95 + + val bos = ByteArrayOutputStream() + bmp = createScaledBitmap(bmp, targetWidth.toInt(), targetHeight.toInt(), true) + val newBmp = bmp.copy(Bitmap.Config.RGB_565, false) + newBmp.compress(Bitmap.CompressFormat.JPEG, quality, bos) + + val outputFileName = File.createTempFile( + getFilenameWithoutExtension(file) + "_compressed", + ".jpg", + applicationContext.externalCacheDir + ).path + + val outputStream = FileOutputStream(outputFileName) + bos.writeTo(outputStream) + + copyExif(fileName, outputFileName) + + return outputFileName +} + +private fun copyExif(filePathOri: String, filePathDest: String) { + val oldExif = ExifInterface(filePathOri) + val newExif = ExifInterface(filePathDest) + + val attributes = listOf("FNumber", + "ExposureTime", + "ISOSpeedRatings", + "GPSAltitude", + "GPSAltitudeRef", + "FocalLength", + "GPSDateStamp", + "WhiteBalance", + "GPSProcessingMethod", + "GPSTimeStamp", + "DateTime", + "Flash", + "GPSLatitude", + "GPSLatitudeRef", + "GPSLongitude", + "GPSLongitudeRef", + "Make", + "Model", + "Orientation" + ) + for (attribute in attributes) { + setIfNotNull(oldExif, newExif, attribute); + } + + newExif.saveAttributes() +} + +private fun setIfNotNull(oldExif: ExifInterface, newExif: ExifInterface, property: String) { + if (oldExif.getAttribute(property) != null) { + newExif.setAttribute(property, oldExif.getAttribute(property)) + } +} + +private fun getFilenameWithoutExtension(file: File): String { + val fileName = file.name + return if (fileName.indexOf(".") > 0) { + fileName.substring(0, fileName.lastIndexOf(".")) + } else { + fileName + } +} diff --git a/android/app/src/main/kotlin/com/example/circles_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/circles_app/MainActivity.kt new file mode 100644 index 0000000..3a3a038 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/circles_app/MainActivity.kt @@ -0,0 +1,43 @@ +package com.example.circles_app + +import android.os.Bundle + +import io.flutter.app.FlutterActivity +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.GeneratedPluginRegistrant + +private const val UPLOAD_PLATFORM = "de.janoodle.timy/upload_platform" +private const val PERMISSION = "de.janoodle.timy/permission-android" +private const val THUMBNAILS = "de.janoodle.timy/thumbnails-android" + +class MainActivity : FlutterActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + GeneratedPluginRegistrant.registerWith(this) + + MethodChannel(flutterView, UPLOAD_PLATFORM).setMethodCallHandler { call, result -> + when (call.method) { + "uploadFiles" -> uploadFilesTask(call, applicationContext) + } + } + + MethodChannel(flutterView, PERMISSION).setMethodCallHandler { call, result -> + when (call.method) { + "requestPermission" -> PermissionHandler.request(call, this, result) + } + } + + MethodChannel(flutterView, THUMBNAILS).setMethodCallHandler { call, result -> + when (call.method) { + "getThumbnailBitmap" -> loadBitmap(call, this, result) + } + } + + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + PermissionHandler.onRequestPermissionsResult(requestCode, permissions, grantResults) + } +} diff --git a/android/app/src/main/kotlin/com/example/circles_app/PermissionHandler.kt b/android/app/src/main/kotlin/com/example/circles_app/PermissionHandler.kt new file mode 100644 index 0000000..f15ac6a --- /dev/null +++ b/android/app/src/main/kotlin/com/example/circles_app/PermissionHandler.kt @@ -0,0 +1,31 @@ +package com.example.circles_app + +import android.app.Activity +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +object PermissionHandler { + private var result: MethodChannel.Result? = null + private const val REQUEST_CODE = 1 + + fun request(call: MethodCall, activity: Activity, result: MethodChannel.Result) { + this.result = result + if (ActivityCompat.checkSelfPermission(activity, call.permission()) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, arrayOf(call.permission()), REQUEST_CODE) + } else { + result.success(mapOf(call.permission() to PackageManager.PERMISSION_GRANTED)) + } + } + + fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == REQUEST_CODE) { + result?.success(mapOf(permissions[0] to grantResults[0])) + } + } +} + +private fun MethodCall.permission(): String { + return argument("permissionType")!! +} diff --git a/android/app/src/main/kotlin/com/example/circles_app/ThumbnailUtils.kt b/android/app/src/main/kotlin/com/example/circles_app/ThumbnailUtils.kt new file mode 100644 index 0000000..edef85b --- /dev/null +++ b/android/app/src/main/kotlin/com/example/circles_app/ThumbnailUtils.kt @@ -0,0 +1,51 @@ +package com.example.circles_app + +import android.app.Activity +import android.content.Context +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private val executor: ExecutorService = Executors.newFixedThreadPool(1) + +fun loadBitmap(call: MethodCall, activity: Activity, result: MethodChannel.Result) { + val fileId = call.argument("fileId") + val type = call.argument("type") + if (fileId == null || type == null) { + result.error("INVALID_ARGUMENTS", "fileId or type must not be null", null) + return + } + executor.execute { + val byteArray = getThumbnailBitmap(context = activity, fileId = fileId.toLong(), type = type) + Handler(Looper.getMainLooper()).post { + result.success(byteArray) + } + } +} + +fun getThumbnailBitmap(context: Context, fileId: Long, type: Int): ByteArray? { + val bitmap: Bitmap = when (type) { + 0 -> { + MediaStore.Images.Thumbnails.getThumbnail( + context.contentResolver, fileId, + MediaStore.Images.Thumbnails.MINI_KIND, null) + } + 1 -> { + MediaStore.Video.Thumbnails.getThumbnail( + context.contentResolver, fileId, + MediaStore.Video.Thumbnails.MINI_KIND, null) + + } + else -> null + } ?: return null + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + bitmap.recycle() + return stream.toByteArray() +} diff --git a/android/app/src/main/kotlin/com/example/circles_app/UploadPlatform.kt b/android/app/src/main/kotlin/com/example/circles_app/UploadPlatform.kt new file mode 100644 index 0000000..084d76b --- /dev/null +++ b/android/app/src/main/kotlin/com/example/circles_app/UploadPlatform.kt @@ -0,0 +1,427 @@ +package com.example.circles_app + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.* +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageReference +import io.flutter.plugin.common.MethodCall +import kotlinx.coroutines.coroutineScope +import java.io.File +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + + +const val CHANNEL_ID = "timy.uploads" +const val TAG = "UploadPlatform" + +/** + * Launches an upload media task. + * + * Reads the *groupId*, *channelId* and *filePaths* from Flutter's [MethodCall] + * and then launches a background task [UploadWorker] that: + * + * 1. Displays a 'progress bar' notification indicating that files are being uploaded. + * 2. Creates an empty message on Firestore with the status 'uploading'. + * 3. Uploads images one by one. + * 4. Updates the message with the remote urls for the images. + * 5. Hides the notification. + * + * @param call Contains the argument data passed from Flutter. + * @param applicationContext Necessary to display notifications and read translations. + */ +fun uploadFilesTask(call: MethodCall, applicationContext: Context) { + // Load arguments from the Flutter platform call + val groupId = call.argument("groupId") + val channelId = call.argument("channelId") + val paths = call.argument>("filePaths")!! + + Log.d("UploadPlatform", "groupId: $groupId") + Log.d("UploadPlatform", "channelId: $channelId") + Log.d("UploadPlatform", "filePaths: $paths") + + // Create and launch the background job + val uploadWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder() + .putString("groupId", groupId) + .putString("channelId", channelId) + .putStringArray("filePaths", paths.toTypedArray()) + .build()) + .build() + WorkManager.getInstance(applicationContext).enqueue(uploadWorkRequest) +} + +/** + * Upload worker that performs the upload task in the background. + * + * This upload process runs in a Kotlin coroutine background thread. + * Coroutines: https://kotlinlang.org/docs/reference/coroutines/basics.html + * + * WorkManager takes care of continuing the task even when the system may destroy/recreate the + * app while it is in the background. + * + * @param appContext Required application [Context] to launch the task + * @param workerParams Worker parameters that include *groupId*, *channelId* and *filePaths* + */ +class UploadWorker(appContext: Context, workerParams: WorkerParameters) + : CoroutineWorker(appContext, workerParams) { + + // Background task that runs in a coroutine worker (thread) + override suspend fun doWork(): Result = coroutineScope { + var groupId: String? = null + var channelId: String? = null + var messageId: String? = null + + try { + Log.d(TAG, "Started background upload task") + createNotificationChannel(applicationContext) + + // Load passed arguments to worker + groupId = inputData.getString("groupId")!! + channelId = inputData.getString("channelId")!! + val paths = inputData.getStringArray("filePaths")!! + + // Show the initial notification displaying 'Uploading files' and 'Uploading 1 of x' + // This notification cannot be dismissed + showNotificationUpload(applicationContext, id, 0, paths.size) + + // Create an empty Media message with the status 'uploading' + messageId = createMediaMessage(groupId, channelId, paths) + + // Upload images, one by one, and return the remote url for each of them in a list + val urls = uploadImages( + groupId = groupId, + channelId = channelId, + messageId = messageId, + paths = paths, + taskId = id, + applicationContext = applicationContext) + + // Get the aspect ratio for the first picture, necessary to display correctly single images + val aspectRatio: String = getAspectRatioFromFirstPicture(paths) + + // Add all uploaded urls to the Media message + addMediaToMessage( + groupId = groupId, + channelId = channelId, + messageId = messageId, + urls = urls, + aspectRatio = aspectRatio) + + Log.d(TAG, "Finished background upload task") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Upload task finished with error: $e") + // In case of error, mark the media message with the error status + markMessageWithError(groupId, channelId, messageId) + Result.failure() + } finally { + // Whatever the final result was: + // Hide the upload progress notification + hideNotification(applicationContext, id) + } + } +} + +/** + * Creates an empty media message with status UPLOADING + * + * Creates a message in Firestore to temporally store the upload status. + * + * The message will have the following fields: + * + * - 'author' is set to the current user. + * - 'type' is set to MEDIA (to differentiate from USER or SYSTEM messages) + * - 'timestamp' is set to current time, but will be updated after upload. + * - 'media' contains the current local paths (to be used to retry uploads if it fails) + * - 'body' contains a hardcoded text for reference, it won't be displayed on the app + * + * @param groupId Group to create the message + * @param channelId Channel to create the message + * @param paths Paths to the local files + * @return the message id + */ +private suspend fun createMediaMessage(groupId: String, channelId: String, paths: Array): String { + val uid = getUserUid() + val firestore = FirebaseFirestore.getInstance() + // A suspendCoroutine wraps any callback into a synchronous coroutine + val docRef = suspendCoroutine { cont -> + firestore.getMessageCollection(groupId, channelId) + .add(hashMapOf( + "author" to uid, + "type" to "MEDIA", + "media_status" to "UPLOADING", + "timestamp" to Date().time.toString(), + // Store the local paths while upload, in case we need to retry + "media" to paths.toList(), + "body" to "[media message uploading]" + )) + .addOnSuccessListener { documentReference -> + Log.d(TAG, "DocumentSnapshot written with ID: ${documentReference.id}") + cont.resume(documentReference) + } + .addOnFailureListener { e -> + Log.w(TAG, "Error adding document", e) + throw e + } + } + return docRef.id +} + +/** + * Mark the message as failed (error) + * + * Changes the 'media_status' field to ERROR. + * Use when something goes wrong during the upload process. + * If any parameter is null, the function does not do anything. + * + * @param groupId Group that contains the message + * @param channelId Channel that contains the message + * @param messageId The message reference id + */ +private suspend fun markMessageWithError(groupId: String?, channelId: String?, messageId: String?) { + if (groupId == null || channelId == null || messageId == null) return + val firestore = FirebaseFirestore.getInstance() + suspendCoroutine { cont -> + firestore.getMessageCollection(groupId, channelId) + .document(messageId) + .update(hashMapOf( + "media_status" to "ERROR" + )) + .addOnCompleteListener { + cont.resume(true) + } + .addOnFailureListener { e -> + Log.e(TAG, "Error marking document with error: ", e) + } + } +} + +/** + * Extension function to the messages collection of a group and channel + */ +private fun FirebaseFirestore.getMessageCollection(groupId: String, channelId: String): CollectionReference { + return collection("groups") + .document(groupId) + .collection("channels") + .document(channelId) + .collection("messages") +} + +/** + * User ID from Firebase Auth (which is the same in our Firestore database) + */ +private fun getUserUid(): String { + val firebaseAuth = FirebaseAuth.getInstance() + val uid = firebaseAuth.currentUser!!.uid + return uid +} + +/** + * Aspect ratio from the first picture in the list of paths. + * + * The aspect ratio is used when we need to display single images (so the paths size is 1) + */ +private fun getAspectRatioFromFirstPicture(paths: Array): String { + return getAspectRatio(paths.first()).toString() +} + +/** + * Updates the message with the list of uploaded media URLs + * + * Updates the following fields: + * + * - 'media' is set to the list of urls + * - 'media_status' to DONE + * - 'media_aspect_ratio' to the calculated aspect ratio for the first picture + * - 'timestamp' to the time of this update + * - 'body' changed to a different message for debug purposes + * + */ +private suspend fun addMediaToMessage(groupId: String, channelId: String, messageId: String, urls: List, aspectRatio: String) { + val firestore = FirebaseFirestore.getInstance() + suspendCoroutine { cont -> + firestore.getMessageCollection(groupId, channelId) + .document(messageId) + .update(hashMapOf( + "media" to urls, + "media_status" to "DONE", + "media_aspect_ratio" to aspectRatio, + "timestamp" to Date().time.toString(), + "body" to "[media message done]" + )) + .addOnCompleteListener { + cont.resume(true) + } + .addOnFailureListener { e -> + Log.w(TAG, "Error adding document", e) + throw e + } + } +} + +/** + * Performs the resize and upload image process + * + * The upload process performs the following steps for every image: + * + * 1. Resizes the picture + * 2. Creates a storage reference pointing /channel/message with the following structure: + * + * "/groups/{groupId}/channels/{channelId}/messages/{messageId}/{filename}" + * + * NOTE: This is a Storage file reference not a Firestore Document. + * + * 3. Uploads the resized file to that storage reference. + * 4. Obtains the public URL of the file. + * 5. Updates the displayed progress notification. + * + * @param paths list of local files to upload + * @param groupId Group Id to create the storage reference + * @param channelId Channel Id , to create the storage reference + * @param messageId Message Id , to create the storage reference + * @param taskId Used to identify the notification displayed + * @param applicationContext Necessary to modify the notification + * @return the resulting URLs + */ +private suspend fun uploadImages(paths: Array, groupId: String, channelId: String, messageId: String, taskId: UUID, applicationContext: Context): List { + val storage = FirebaseStorage.getInstance() + val urls = mutableListOf() + for (path in paths) { + val resized = resizeImage(path, applicationContext) + val file = File(resized) + val storageRef = storage.reference + // Store images in a path equal to the message in Firestore, for easier reference + val fileRef = storageRef.child("/groups/$groupId/channels/$channelId/messages/$messageId/${file.name}") + val uri = Uri.fromFile(file) + when (val result = fileRef.putFileK(uri)) { + is UploadResult.Error -> { + Log.e(TAG, "Error: " + result.exception.toString()) + throw result.exception + } + is UploadResult.Success -> { + urls.add(result.url) + // Update the notification process bar + showNotificationUpload(applicationContext, taskId, urls.size, paths.size) + Log.d(TAG, "Success! " + result.url) + } + } + } + return urls +} + +/** + * Helper extension function to handle the upload process + * + * First will perform the upload with [StorageReference.putFile] + * then will obtain the URL with [StorageReference.getDownloadUrl] + * + * Both actions are asynchronous and both can fail. + * + * @return [UploadResult] containing the url if success or the exception if failed + */ +suspend fun StorageReference.putFileK(uri: Uri): UploadResult = + suspendCoroutine { cont -> + putFile(uri) + .addOnFailureListener { cont.resume(UploadResult.Error(it)) } + .addOnSuccessListener { + downloadUrl + .addOnFailureListener { + cont.resume(UploadResult.Error(it)) + } + .addOnSuccessListener { uri -> + cont.resume(UploadResult.Success(uri.toString())) + } + } + } + +/** + * sealed class to handle the two different cases after an upload + * + * [Error] holds the exception in case of error + * [Success] holds the URL in case of success + */ +sealed class UploadResult { + data class Error(val exception: Exception) : UploadResult() + data class Success(val url: String) : UploadResult() +} + +/** + * Display or updates a notification with a progress indicator + * + * Each notification displayed has a unique ID based on the current upload process. + * + * The notification has the following properties: + * + * - Title is localized + * - Content text is localized and contains the current task number vs. total (e.g. 1 of 10) + * - App icon used as the notification icon + * - Sound is set to null to make the notification silent + * - Only alert once is to update the notification silently too + * - Ongoing makes the notification not dismissible + * - Shows a built-in progress indicator + * + * TODO: Create a proper notification icon: https://github.com/janoodleFTW/flutter-app/issues/269 + * + * @param applicationContext Context required for translations and to obtain the [NotificationManager] + * @param taskId WorkManager unique task ID used to identify the notification + * @param currentTaskCount current task count, first should be 0 + * @param totalTaskCount total task count + */ +private fun showNotificationUpload(applicationContext: Context, taskId: UUID, currentTaskCount: Int, totalTaskCount: Int) { + // Show a notification while we upload files + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val b = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setWhen(System.currentTimeMillis()) + .setContentTitle(applicationContext.getString(R.string.upload_notification_title)) + .setContentText(applicationContext.getString(R.string.upload_notification_text, currentTaskCount + 1, totalTaskCount)) + .setSmallIcon(R.mipmap.ic_launcher) + .setSound(null) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(totalTaskCount, currentTaskCount, false) + notificationManager.notify(taskId.hashCode(), b.build()) +} + +/** + * Creates a Notification Channel if it does not exist + * + * From Android 8.0, apps need to display notifications inside Channels. + * This method creates a Notification Channel for Uploads. + * Sets this Channel sound to null to make these notifications silent. + * + * @param applicationContext necessary app context + */ +private fun createNotificationChannel(applicationContext: Context) { + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(CHANNEL_ID, "Uploads", importance) + channel.setSound(null, null) + notificationManager.createNotificationChannel(channel) + } +} + +/** + * Hides a notification + * + * Use this when we don't need to show the notification anymore (at the end of the upload process) + * + * @param applicationContext necessary app context + * @param id current task Id to identify the notification + */ +private fun hideNotification(applicationContext: Context, id: UUID) { + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(id.hashCode()) +} + diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..05942aa Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d9002b5 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-nodpi/splash.png b/android/app/src/main/res/drawable-nodpi/splash.png new file mode 100644 index 0000000..4894546 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8a27fdb Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8c96a79 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6431a2c Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..cd3efbe --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9ab3c45 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..be7ee8d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..cc4aaa7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..009d814 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..2e834e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..239a4a3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..4f23a9c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..101dd80 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..13bf5e3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5a81564 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..6dc0b27 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..ea209e7 --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,5 @@ + + + Dateien werden hochgeladen + Datei %d von %d + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..beab31f --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7f742f9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Timy + Uploading files + File %d of %d + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..00fa441 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..c08b9bb --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..b0b2cb1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,44 @@ +buildscript { + ext.kotlin_version = '1.3.0' + repositories { + google() + jcenter() + maven { + url 'https://maven.fabric.io/public' + } + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.2.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.google.gms:google-services:3.2.1' + classpath 'io.fabric.tools:gradle:1.26.1' + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} +subprojects { + project.configurations.all { + resolutionStrategy { + force "androidx.core:core:1.0.2" + force "androidx.localbroadcastmanager:localbroadcastmanager:1.0.0-rc01" + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..4d3226a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2819f02 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5a2f14f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/android/store/feature.png b/android/store/feature.png new file mode 100644 index 0000000..70f94cd Binary files /dev/null and b/android/store/feature.png differ diff --git a/android/store/hi-res-icon.png b/android/store/hi-res-icon.png new file mode 100644 index 0000000..11f5444 Binary files /dev/null and b/android/store/hi-res-icon.png differ diff --git a/android/store/screenshot1.png b/android/store/screenshot1.png new file mode 100644 index 0000000..c5a3cb4 Binary files /dev/null and b/android/store/screenshot1.png differ diff --git a/android/store/screenshot2.png b/android/store/screenshot2.png new file mode 100644 index 0000000..4a6230f Binary files /dev/null and b/android/store/screenshot2.png differ diff --git a/assets/graphics/avatar_no_picture.png b/assets/graphics/avatar_no_picture.png new file mode 100644 index 0000000..ee194cd Binary files /dev/null and b/assets/graphics/avatar_no_picture.png differ diff --git a/assets/graphics/calendar/calendar_today.png b/assets/graphics/calendar/calendar_today.png new file mode 100644 index 0000000..b9220e2 Binary files /dev/null and b/assets/graphics/calendar/calendar_today.png differ diff --git a/assets/graphics/channel/create_new_channel.png b/assets/graphics/channel/create_new_channel.png new file mode 100644 index 0000000..351cf9d Binary files /dev/null and b/assets/graphics/channel/create_new_channel.png differ diff --git a/assets/graphics/channel/details_date.png b/assets/graphics/channel/details_date.png new file mode 100644 index 0000000..e42fe03 Binary files /dev/null and b/assets/graphics/channel/details_date.png differ diff --git a/assets/graphics/channel/details_location.png b/assets/graphics/channel/details_location.png new file mode 100644 index 0000000..646bc5c Binary files /dev/null and b/assets/graphics/channel/details_location.png differ diff --git a/assets/graphics/channel/details_members.png b/assets/graphics/channel/details_members.png new file mode 100644 index 0000000..96e64be Binary files /dev/null and b/assets/graphics/channel/details_members.png differ diff --git a/assets/graphics/channel/details_padlock.png b/assets/graphics/channel/details_padlock.png new file mode 100644 index 0000000..8ab9601 Binary files /dev/null and b/assets/graphics/channel/details_padlock.png differ diff --git a/assets/graphics/channel/event_joined.png b/assets/graphics/channel/event_joined.png new file mode 100644 index 0000000..ff704ed Binary files /dev/null and b/assets/graphics/channel/event_joined.png differ diff --git a/assets/graphics/channel/event_open.png b/assets/graphics/channel/event_open.png new file mode 100644 index 0000000..8d6fcfb Binary files /dev/null and b/assets/graphics/channel/event_open.png differ diff --git a/assets/graphics/channel/header_calendar_icon.png b/assets/graphics/channel/header_calendar_icon.png new file mode 100644 index 0000000..25f4aca Binary files /dev/null and b/assets/graphics/channel/header_calendar_icon.png differ diff --git a/assets/graphics/channel/padlock.png b/assets/graphics/channel/padlock.png new file mode 100644 index 0000000..0156d17 Binary files /dev/null and b/assets/graphics/channel/padlock.png differ diff --git a/assets/graphics/channel/rsvp/rsvp_maybe.png b/assets/graphics/channel/rsvp/rsvp_maybe.png new file mode 100644 index 0000000..4a3ac0b Binary files /dev/null and b/assets/graphics/channel/rsvp/rsvp_maybe.png differ diff --git a/assets/graphics/channel/rsvp/rsvp_maybe_large.png b/assets/graphics/channel/rsvp/rsvp_maybe_large.png new file mode 100644 index 0000000..a9431fc Binary files /dev/null and b/assets/graphics/channel/rsvp/rsvp_maybe_large.png differ diff --git a/assets/graphics/channel/rsvp/rsvp_no_large.png b/assets/graphics/channel/rsvp/rsvp_no_large.png new file mode 100644 index 0000000..0156384 Binary files /dev/null and b/assets/graphics/channel/rsvp/rsvp_no_large.png differ diff --git a/assets/graphics/channel/rsvp/rsvp_yes.png b/assets/graphics/channel/rsvp/rsvp_yes.png new file mode 100644 index 0000000..cfbcba0 Binary files /dev/null and b/assets/graphics/channel/rsvp/rsvp_yes.png differ diff --git a/assets/graphics/channel/rsvp/rsvp_yes_large.png b/assets/graphics/channel/rsvp/rsvp_yes_large.png new file mode 100644 index 0000000..fa5b4d7 Binary files /dev/null and b/assets/graphics/channel/rsvp/rsvp_yes_large.png differ diff --git a/assets/graphics/channel/topic_joined.png b/assets/graphics/channel/topic_joined.png new file mode 100644 index 0000000..9381b35 Binary files /dev/null and b/assets/graphics/channel/topic_joined.png differ diff --git a/assets/graphics/channel/topic_open.png b/assets/graphics/channel/topic_open.png new file mode 100644 index 0000000..2b34425 Binary files /dev/null and b/assets/graphics/channel/topic_open.png differ diff --git a/assets/graphics/drawer/account.png b/assets/graphics/drawer/account.png new file mode 100644 index 0000000..1bcecfd Binary files /dev/null and b/assets/graphics/drawer/account.png differ diff --git a/assets/graphics/drawer/create_topic.png b/assets/graphics/drawer/create_topic.png new file mode 100644 index 0000000..02009cf Binary files /dev/null and b/assets/graphics/drawer/create_topic.png differ diff --git a/assets/graphics/drawer/direct_message.png b/assets/graphics/drawer/direct_message.png new file mode 100644 index 0000000..eab17c9 Binary files /dev/null and b/assets/graphics/drawer/direct_message.png differ diff --git a/assets/graphics/drawer/events.png b/assets/graphics/drawer/events.png new file mode 100644 index 0000000..a0413fa Binary files /dev/null and b/assets/graphics/drawer/events.png differ diff --git a/assets/graphics/drawer/settings.png b/assets/graphics/drawer/settings.png new file mode 100644 index 0000000..6296d69 Binary files /dev/null and b/assets/graphics/drawer/settings.png differ diff --git a/assets/graphics/icon_notification.png b/assets/graphics/icon_notification.png new file mode 100644 index 0000000..87ce643 Binary files /dev/null and b/assets/graphics/icon_notification.png differ diff --git a/assets/graphics/icon_smile.png b/assets/graphics/icon_smile.png new file mode 100644 index 0000000..f266128 Binary files /dev/null and b/assets/graphics/icon_smile.png differ diff --git a/assets/graphics/input/checkbox_active.png b/assets/graphics/input/checkbox_active.png new file mode 100644 index 0000000..3df058c Binary files /dev/null and b/assets/graphics/input/checkbox_active.png differ diff --git a/assets/graphics/input/checkbox_inactive.png b/assets/graphics/input/checkbox_inactive.png new file mode 100644 index 0000000..d9a0635 Binary files /dev/null and b/assets/graphics/input/checkbox_inactive.png differ diff --git a/assets/graphics/input/icon_add_content.png b/assets/graphics/input/icon_add_content.png new file mode 100644 index 0000000..c6dbdf7 Binary files /dev/null and b/assets/graphics/input/icon_add_content.png differ diff --git a/assets/graphics/input/icon_camera.png b/assets/graphics/input/icon_camera.png new file mode 100644 index 0000000..f81154a Binary files /dev/null and b/assets/graphics/input/icon_camera.png differ diff --git a/assets/graphics/input/icon_pictures.png b/assets/graphics/input/icon_pictures.png new file mode 100644 index 0000000..cca6fcd Binary files /dev/null and b/assets/graphics/input/icon_pictures.png differ diff --git a/assets/graphics/menu_icon.png b/assets/graphics/menu_icon.png new file mode 100644 index 0000000..bf4646b Binary files /dev/null and b/assets/graphics/menu_icon.png differ diff --git a/assets/graphics/menu_more_icon.png b/assets/graphics/menu_more_icon.png new file mode 100644 index 0000000..3b7d70b Binary files /dev/null and b/assets/graphics/menu_more_icon.png differ diff --git a/assets/graphics/update_indicator_darkgreen.png b/assets/graphics/update_indicator_darkgreen.png new file mode 100644 index 0000000..dc0c05a Binary files /dev/null and b/assets/graphics/update_indicator_darkgreen.png differ diff --git a/assets/graphics/updates_indicator.png b/assets/graphics/updates_indicator.png new file mode 100644 index 0000000..abb038b Binary files /dev/null and b/assets/graphics/updates_indicator.png differ diff --git a/assets/graphics/updates_indicator_white.png b/assets/graphics/updates_indicator_white.png new file mode 100644 index 0000000..f5025ff Binary files /dev/null and b/assets/graphics/updates_indicator_white.png differ diff --git a/assets/graphics/upload/indicator_0_try_again.png b/assets/graphics/upload/indicator_0_try_again.png new file mode 100644 index 0000000..39ac1ea Binary files /dev/null and b/assets/graphics/upload/indicator_0_try_again.png differ diff --git a/assets/graphics/upload/selected.png b/assets/graphics/upload/selected.png new file mode 100644 index 0000000..d8bed96 Binary files /dev/null and b/assets/graphics/upload/selected.png differ diff --git a/assets/graphics/visual_twist.png b/assets/graphics/visual_twist.png new file mode 100644 index 0000000..30d131d Binary files /dev/null and b/assets/graphics/visual_twist.png differ diff --git a/assets/graphics/visual_twist_white_petrol.png b/assets/graphics/visual_twist_white_petrol.png new file mode 100644 index 0000000..396770b Binary files /dev/null and b/assets/graphics/visual_twist_white_petrol.png differ diff --git a/assets/icon/icon.png b/assets/icon/icon.png new file mode 100644 index 0000000..4a8622d Binary files /dev/null and b/assets/icon/icon.png differ diff --git a/assets/placeholder/2.0x/user_image_placeholder.png b/assets/placeholder/2.0x/user_image_placeholder.png new file mode 100644 index 0000000..8417c44 Binary files /dev/null and b/assets/placeholder/2.0x/user_image_placeholder.png differ diff --git a/assets/placeholder/3.0x/user_image_placeholder.png b/assets/placeholder/3.0x/user_image_placeholder.png new file mode 100644 index 0000000..ad45bc5 Binary files /dev/null and b/assets/placeholder/3.0x/user_image_placeholder.png differ diff --git a/assets/placeholder/user_image_placeholder.png b/assets/placeholder/user_image_placeholder.png new file mode 100644 index 0000000..83b1b77 Binary files /dev/null and b/assets/placeholder/user_image_placeholder.png differ diff --git a/firebase/.gitignore b/firebase/.gitignore new file mode 100644 index 0000000..f626852 --- /dev/null +++ b/firebase/.gitignore @@ -0,0 +1,65 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env diff --git a/firebase/README.md b/firebase/README.md new file mode 100644 index 0000000..253574d --- /dev/null +++ b/firebase/README.md @@ -0,0 +1,9 @@ +# Timy app on Firebase + +This folder contains all Firebase related code that does not belong to the client app. + +## Structure + +- `functions` contains all the code for cloud functions +- `scripts` contains local scripts for database admin and migration help +- `firestore.rules` defines the access rules for Firestore by app users diff --git a/firebase/firebase.json b/firebase/firebase.json new file mode 100644 index 0000000..d4d918a --- /dev/null +++ b/firebase/firebase.json @@ -0,0 +1,6 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + } +} diff --git a/firebase/firestore.indexes.json b/firebase/firestore.indexes.json new file mode 100644 index 0000000..ab0f0c6 --- /dev/null +++ b/firebase/firestore.indexes.json @@ -0,0 +1,51 @@ +{ + "indexes": [ + { + "collectionGroup": "calendar", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "users", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "event_date", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "channels", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "has_start_time", + "order": "ASCENDING" + }, + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "start_date", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "channels", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "start_date", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/firebase/firestore.rules b/firebase/firestore.rules new file mode 100644 index 0000000..91caa22 --- /dev/null +++ b/firebase/firestore.rules @@ -0,0 +1,36 @@ +service cloud.firestore { + match /databases/{database}/documents { + function userIsMemberOfGroup(groupId) { + return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.members; + } + + match /calendar/{calendarId} { + allow read, write: if request.auth.uid != null; + } + + match /groups/{groupId} { + allow read, write: if request.auth.uid in resource.data.members; + + // TODO: Channels should be filtered on client side by their visibility + match /channels/{channelId} { + allow read, write: if userIsMemberOfGroup(groupId); + + match /users/{userId} { + allow read, create, update: if userIsMemberOfGroup(groupId); + // Only allow writes on your user. Or allow author to perform writes. + allow delete: if request.auth.uid == userId; + } + + match /messages/{messageId} { + allow read, write: if userIsMemberOfGroup(groupId); + } + } + } + + // TODO: For security reasons we should probably move private user data in to a private sub collection. + match /users/{userId} { + allow update: if request.auth.uid == userId; + allow read, create: if request.auth.uid != null; + } + } +} \ No newline at end of file diff --git a/firebase/functions/.gitignore b/firebase/functions/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/firebase/functions/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/firebase/functions/admin.js b/firebase/functions/admin.js new file mode 100644 index 0000000..22d1598 --- /dev/null +++ b/firebase/functions/admin.js @@ -0,0 +1,11 @@ +const functions = require('firebase-functions'); + +const admin = require('firebase-admin'); +admin.initializeApp(functions.config().firebase); + +const db = admin.firestore(); + +module.exports = { + db, + admin, +} diff --git a/firebase/functions/calendar-update.js b/firebase/functions/calendar-update.js new file mode 100644 index 0000000..5f714f2 --- /dev/null +++ b/firebase/functions/calendar-update.js @@ -0,0 +1,83 @@ +const { db, admin } = require('./admin'); + +const addCalendarEntry = async (groupId, channelId, channelName, groupName, eventDate, hasStartTime, timezoneSecondsOffset, users) => { + const calendarEntry = { + has_start_time: hasStartTime != null ? hasStartTime : false, + timezone_seconds_offset: timezoneSecondsOffset != null ? timezoneSecondsOffset : 0, + channel_id: channelId, + group_id: groupId, + channel_name: channelName, + group_name: groupName, + event_date: eventDate, + users: users + }; + + return await db.collection("calendar") + .doc(`${groupId}-${channelId}`) + .set(calendarEntry); +} + +const deleteCalendarEntry = async (groupId, channelId) => { + const calendarEntryDoc = await db.collection("calendar") + .doc(`${groupId}-${channelId}`) + .get(); + + if (calendarEntryDoc.exists) { + return await calendarEntryDoc.ref.delete(); + } +} + +const updateCalendarChannel = async (groupId, channelId, channelName, eventDate, hasStartTime, timezoneSecondsOffset) => { + const calendarEntryDoc = await db.collection("calendar") + .doc(`${groupId}-${channelId}`) + .get() + + if (!calendarEntryDoc.exists) { + return; + } + + const calendarEntry = { + has_start_time: hasStartTime != null ? hasStartTime : false, + timezone_seconds_offset: timezoneSecondsOffset != null ? timezoneSecondsOffset : 0 + }; + + if (channelName != null) calendarEntry.channel_name = channelName; + if (eventDate != null) calendarEntry.event_date = eventDate; + + return await calendarEntryDoc.ref.update(calendarEntry); +} + +const updateCalendarGroupName = async (groupId, groupName) => { + const calendarGroup = await db.collection("calendar") + .where("group_id", "==", groupId) + .get() + + calendarGroup.docs.forEach(async (doc) => { + await doc.ref.update({ + group_name: groupName + }); + }); +} + +const updateCalendarUser = async (groupId, channelId, userId, didLeave) => { + // Add calendar event + const calendarDoc = await db.collection("calendar") + .doc(`${groupId}-${channelId}`) + .get() + + if (!calendarDoc.exists) { + return; + } + + if (didLeave) { + return await calendarDoc.ref.update({ + users: admin.firestore.FieldValue.arrayRemove(userId) + }); + } else { + return await calendarDoc.ref.update({ + users: admin.firestore.FieldValue.arrayUnion(userId) + }); + } +} + +module.exports = { addCalendarEntry, updateCalendarChannel, updateCalendarGroupName, updateCalendarUser, deleteCalendarEntry }; diff --git a/firebase/functions/channel-edit.js b/firebase/functions/channel-edit.js new file mode 100644 index 0000000..cebba16 --- /dev/null +++ b/firebase/functions/channel-edit.js @@ -0,0 +1,94 @@ +const functions = require('firebase-functions'); +const { sendSystemMessage } = require('./channel-util'); +const { pushToUser } = require('./push-send'); +const constants = require('./constants'); +const { usersInChannel, getUser } = require('./user-util'); +const { getGroupName } = require('./group-util'); +const { updateCalendarChannel } = require('./calendar-update'); +const { + localizeDate, + getNotificationTitleForEventEdit, + getNotificationBodyForEventEdit, + getSystemMessageEventEditDate, + getSystemMessageEventEditVenue, + getSystemMessageEventEditDescription +} = require('./localize-util'); + +async function sendPushToChannelUsers(author, groupId, channelId, channelName, channelVenue, eventDate, eventEdits) { + const groupName = await getGroupName(groupId); + const channelUsers = await usersInChannel(groupId, channelId); + const title = getNotificationTitleForEventEdit(groupName, channelName); + + for (const channelUser of channelUsers.docs) { + const rsvpStatus = channelUser.data().rsvp; + if (rsvpStatus == constants.RSVP_MAYBE || rsvpStatus == constants.RSVP_YES) { + const user = await getUser(channelUser.data().uid); + const token = user.data().token; + const locale = user.data().locale; + const body = getNotificationBodyForEventEdit(locale, author, localizeDate(locale, eventDate), channelVenue, eventEdits); + if (body != null) { + await pushToUser(channelUser.data().uid, token, title, body, "", groupId, channelId); + } + } + } +} + +/** + * On channel edits (event channels) + * - Post separate message to channel if [start_date | venue | description] have been edited + * - Send push notification to channel users except author notifying about changes + */ +const editChannel = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}') + .onUpdate(async (change, context) => { + const channelAfter = change.after.data(); + const channelBefore = change.before.data(); + + const user = await getUser(channelAfter['authorId']); + const locale = user.data().locale; + const name = user.data().name; + var editChanges = []; + + if (channelAfter.start_date != null && + (channelAfter.start_date.toDate().getTime() !== channelBefore.start_date.toDate().getTime() + || channelAfter['has_start_time'] !== channelBefore['has_start_time'])) { + const body = getSystemMessageEventEditDate(locale, name); + await sendSystemMessage(context.params.groupId, context.params.channelId, body); + editChanges.push(constants.EVENT_EDIT_TIME); + } + + if (channelAfter['venue'] !== channelBefore['venue']) { + const body = getSystemMessageEventEditVenue(locale, name); + await sendSystemMessage(context.params.groupId, context.params.channelId, body); + editChanges.push(constants.EVENT_EDIT_VENUE); + } + + if (channelAfter['description'] !== channelBefore['description']) { + const body = getSystemMessageEventEditDescription(locale, name); + await sendSystemMessage(context.params.groupId, context.params.channelId, body); + editChanges.push(constants.EVENT_EDIT_DESCRIPTION); + } + + // Notify channel members about changes via notifications + if (editChanges.length > 0) { + const venue = channelAfter.venue; + const timezoneOffset = channelAfter.timezone_seconds_offset * 1000; + const startDate = channelAfter.start_date.toDate(); + startDate.setTime(startDate.getTime() + timezoneOffset); + await sendPushToChannelUsers(user.data().name, context.params.groupId, context.params.channelId, channelAfter.name, venue, startDate, editChanges); + } + + // Update calendar event + await updateCalendarChannel( + context.params.groupId, + context.params.channelId, + channelAfter.name, + channelAfter.start_date, + channelAfter.has_start_time, + channelAfter.timezone_seconds_offset + ); + }); + +module.exports = editChannel; diff --git a/firebase/functions/channel-flagChannelUnread.js b/firebase/functions/channel-flagChannelUnread.js new file mode 100644 index 0000000..789c4af --- /dev/null +++ b/firebase/functions/channel-flagChannelUnread.js @@ -0,0 +1,24 @@ +const { db } = require('./admin'); + +/** + * Sets `hasUpdates` flag for user in channel to false. + * This is used to allow the channels listener on client-side + * to update its list accordingly. + */ +const flagChannelUnread = async (groupdId, channelId, userId) => { + try { + await db + .collection("/groups/") + .doc(groupdId) + .collection("/channels/") + .doc(channelId) + .collection("/users/").doc(userId).update({ + hasUpdates: true + }); + console.log("Updated has updates for user: " + userId); + } catch (error) { + console.error("Error writing document: ", error); + } +} + +module.exports = flagChannelUnread; diff --git a/firebase/functions/channel-new-user.js b/firebase/functions/channel-new-user.js new file mode 100644 index 0000000..85b6c37 --- /dev/null +++ b/firebase/functions/channel-new-user.js @@ -0,0 +1,95 @@ +const functions = require('firebase-functions'); +const { getNotificationBodyForNewChannel, + getNotificationTitleForNewChannel, + getNotificationTitleForNewEvent, + getNotificationBodyForNewEvent } = require('./localize-util'); +const { pushToUser } = require('./push-send'); +const constants = require('./constants'); +const { getUser } = require('./user-util'); +const { updateCalendarUser } = require('./calendar-update'); +const { db } = require('./admin'); +const updateLatestActivityForChannel = require('./channel-updatedAt'); +const { getChannel } = require('./channel-util'); + +async function postSystemMessage(context) { + const channel = await getChannel(context.params.groupId, context.params.channelId); + await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, Date.now()); + + return db.collection("/users/") + .doc(context.params.userId) + .get() + .then(snapshot => { + return db.collection(`/groups/${context.params.groupId}/channels/${context.params.channelId}/messages/`) + .add({ + body: channel.data().type == 'EVENT' ? `${snapshot.data().name} {JOINED_EVENT}` : `${snapshot.data().name} {JOINED_CHANNEL}`, + type: "SYSTEM", + timestamp: `${Date.now()}`, + author: { email: "system", name: "system" } + }); + }); +} + +async function addUserToChannel(groupId, channelId, uid, isInvitation, tempMetadata) { + return await db.collection(`/groups/${groupId}/channels/${channelId}/users/`) + .doc(uid) + .set({ + uid: uid, + invitation: isInvitation, + metadata: tempMetadata, + }); +} + +async function pushToInvitedChannelUser(channelUserData, groupId, channelId) { + // Send push notification to invited users: + const groupName = channelUserData.metadata.group_name; + const channelType = channelUserData.metadata.type; + const channelName = channelUserData.metadata.channel_name; + const channelVisibility = channelUserData.metadata.visibility; + const authorName = channelUserData.metadata.inviting_user; + + const isEvent = channelType == constants.CHANNEL_EVENT_TYPE; + const isvVisibilityOpen = channelVisibility == constants.CHANNEL_VISIBILITY_OPEN; + + const user = await getUser(channelUserData.uid); + const userData = user.data(); + const userToken = userData.token; + const userLocale = userData.locale; + + const title = isEvent ? getNotificationTitleForNewEvent(userLocale, isvVisibilityOpen, groupName, channelName) : + getNotificationTitleForNewChannel(userLocale, isvVisibilityOpen, groupName, channelName); + const message = isEvent ? getNotificationBodyForNewEvent(userLocale, isvVisibilityOpen, authorName, channelName) : + getNotificationBodyForNewChannel(userLocale, isvVisibilityOpen, authorName, channelName); + + return await pushToUser(channelUserData.uid, userToken, title, message, "", groupId, channelId); +} + +/** + * This function is triggered when a new user joins a channel. + * + * @type {CloudFunction} + */ + +const newChannelUser = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}/users/{userId}') + .onCreate(async (snap, context) => { + const channelUserData = snap.data() + + // Post system message + await postSystemMessage(context); + + // Update Calendar + await updateCalendarUser(context.params.groupId, context.params.channelId, channelUserData.uid, false); + + // Send push notification to invited users: + if (channelUserData.invitation) { + await pushToInvitedChannelUser(channelUserData, context.params.groupId, context.params.channelId); + } + + // Clean up user by removing metadata + const FieldValue = require('firebase-admin').firestore.FieldValue; + return await snap.ref.update({ metadata: FieldValue.delete() }); + }); + +module.exports = { newChannelUser, addUserToChannel }; diff --git a/firebase/functions/channel-new.js b/firebase/functions/channel-new.js new file mode 100644 index 0000000..d3873cc --- /dev/null +++ b/firebase/functions/channel-new.js @@ -0,0 +1,105 @@ +const functions = require('firebase-functions'); +const { getUsersInGroup, getUser } = require('./user-util'); +const { pushToUser } = require('./push-send'); +const { getNotificationBodyForNewChannel, + getNotificationTitleForNewChannel, + getNotificationTitleForNewEvent, + getNotificationBodyForNewEvent } = require('./localize-util'); +const { getGroupName } = require('./group-util'); +const constants = require('./constants'); +const { db } = require('./admin'); +const { addUserToChannel } = require('./channel-new-user'); +const { addCalendarEntry } = require('./calendar-update'); + +async function setRsvpGoingForEventCreator(groupId, channelId, userId) { + try { + return await db + .collection("/groups/") + .doc(groupId) + .collection("/channels/") + .doc(channelId) + .collection("/users/") + .doc(userId).update({ + rsvp: "YES" + }); + } catch (error) { + console.error("Error setting rsvp 'YES' for channel creator: ", error); + } +} + +async function addInvitedUsersToChannel(snap, channel, groupId, channelId, authorId, groupName) { + let author = await getUser(authorId); + + let tempMetadata = { + inviting_user: author.data().name, + group_name: groupName, + channel_name: channel.name, + visibility: channel.visibility, + type: channel.type + } + + for (const inviteId of channel.invited_members) { + await addUserToChannel(groupId, channelId, inviteId, authorId != inviteId, tempMetadata); + } + + // Clean up channel by removing invited_members array + const FieldValue = require('firebase-admin').firestore.FieldValue; + return snap.ref.update({ invited_members: FieldValue.delete() }); +} + +async function sendPushToGroupForOpenChannel(channelData, groupId, channelId, authorId, isEvent, groupName) { + if (channelData.visibility != "OPEN") { + return; + } + + const users = await getUsersInGroup(groupId); + const authorDoc = await getUser(authorId); + const authorName = authorDoc.data().name + const channelName = channelData.name; + + for (const userSnapshot of users.docs) { + const userData = userSnapshot.data(); + const title = isEvent ? getNotificationTitleForNewEvent(userData.locale, true, groupName, channelName) : + getNotificationTitleForNewChannel(userData.locale, true, groupName, channelName); + const message = isEvent ? getNotificationBodyForNewEvent(userData.locale, true, authorName, channelName) : + getNotificationBodyForNewChannel(userData.locale, true, authorName, channelName); + + await pushToUser(userData.uid, userData.token, title, message, "", groupId, channelId); + } + + return; +} + +const newChannel = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}') + .onCreate(async (snap, context) => { + const channel = snap.data(); + const isEvent = channel["type"] == constants.CHANNEL_EVENT_TYPE; + const authorId = channel["authorId"]; + const groupName = await getGroupName(context.params.groupId); + + // Add author and invited users to channel + await addInvitedUsersToChannel(snap, channel, context.params.groupId, context.params.channelId, authorId, groupName); + + if (isEvent) { + await setRsvpGoingForEventCreator(context.params.groupId, context.params.channelId, authorId); + + // Create calendar entry + await addCalendarEntry( + context.params.groupId, + context.params.channelId, + channel.name, groupName, + channel.start_date, + channel.has_start_time, + channel.timezone_seconds_offset, + channel.invited_members, + ); + } + + // Send push notification to group members for open channel. + return await sendPushToGroupForOpenChannel(channel, context.params.groupId, context.params.channelId, authorId, isEvent, groupName); + }); + +module.exports = newChannel; diff --git a/firebase/functions/channel-remove-user.js b/firebase/functions/channel-remove-user.js new file mode 100644 index 0000000..c633359 --- /dev/null +++ b/firebase/functions/channel-remove-user.js @@ -0,0 +1,44 @@ +const functions = require('firebase-functions'); +const { updateCalendarUser } = require('./calendar-update'); +const { getChannel } = require('./channel-util'); +const updateLatestActivityForChannel = require('./channel-updatedAt'); +const { db } = require('./admin'); + +/** + * This function is triggered when a user leaves a channel. + * + * @type {CloudFunction} +**/ + +const deleteChannelUser = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}/users/{userId}') + .onDelete(async (snap, context) => { + // Remove user from calendar entry + await updateCalendarUser(context.params.groupId, context.params.channelId, context.params.userId, true); + + // System message + const channel = await getChannel(context.params.groupId, context.params.channelId); + + if (channel.data() == null) { + return; + } + + await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, Date.now()); + + await db.collection("/users/") + .doc(context.params.userId) + .get() + .then(snapshot => { + return db.collection(`/groups/${context.params.groupId}/channels/${context.params.channelId}/messages/`) + .add({ + body: channel.data().type == 'EVENT' ? `${snapshot.data().name} {LEFT_EVENT}` : `${snapshot.data().name} {LEFT_CHANNEL}`, + type: "SYSTEM", + timestamp: `${Date.now()}`, + author: { email: "system", name: "system" } + }); + }); + }); + +module.exports = deleteChannelUser; diff --git a/firebase/functions/channel-remove.js b/firebase/functions/channel-remove.js new file mode 100644 index 0000000..2c15565 --- /dev/null +++ b/firebase/functions/channel-remove.js @@ -0,0 +1,12 @@ +const functions = require('firebase-functions'); +const { deleteCalendarEntry } = require('./calendar-update'); + +const deleteChannel = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}') + .onDelete(async (snap, context) => { + return await deleteCalendarEntry(context.params.groupId, context.params.channelId); + }); + +module.exports = deleteChannel; diff --git a/firebase/functions/channel-updatedAt.js b/firebase/functions/channel-updatedAt.js new file mode 100644 index 0000000..40e805a --- /dev/null +++ b/firebase/functions/channel-updatedAt.js @@ -0,0 +1,21 @@ +const { db } = require('./admin'); + +/** + * Sets `updatedAt` for channel to last activity's timestamp. + */ +const updateLatestActivityForChannel = async (groupId, channelId, activityDate) => { + try { + await db + .collection("/groups/") + .doc(groupId) + .collection("/channels/") + .doc(channelId).update({ + updatedAt: activityDate + }) + + } catch (error) { + console.error("Error updating channel timestamp: ", error); + } +} + +module.exports = updateLatestActivityForChannel; \ No newline at end of file diff --git a/firebase/functions/channel-util.js b/firebase/functions/channel-util.js new file mode 100644 index 0000000..f4f2c9f --- /dev/null +++ b/firebase/functions/channel-util.js @@ -0,0 +1,25 @@ +const { db } = require('./admin'); + +const getChannel = async (groupId, channelId) => { + return await db + .collection(`/groups/${groupId}/channels/`) + .doc(channelId) + .get(); +} + +const getChannelName = async (groupId, channelId) => { + let channelDoc = await getChannel(groupId, channelId); + return channelDoc.data().name +}; + +const sendSystemMessage = async (groupId, channelId, body) => { + await db.collection(`/groups/${groupId}/channels/${channelId}/messages/`).add({ + body: body, + type: "SYSTEM", + timestamp: `${Date.now()}`, + author: { email: "system", name: "system" } + }); +}; + + +module.exports = { getChannel, getChannelName, sendSystemMessage }; diff --git a/firebase/functions/constants.js b/firebase/functions/constants.js new file mode 100644 index 0000000..01fea98 --- /dev/null +++ b/firebase/functions/constants.js @@ -0,0 +1,10 @@ +module.exports = Object.freeze({ + CHANNEL_EVENT_TYPE: 'EVENT', + CHANNEL_VISIBILITY_OPEN: 'OPEN', + PUSH_LOCALE_DE: 'de', + RSVP_MAYBE: 'MAYBE', + RSVP_YES: 'YES', + EVENT_EDIT_TIME: 'EVENT_EDIT_TIME', + EVENT_EDIT_VENUE: 'EVENT_EDIT_VENUE', + EVENT_EDIT_DESCRIPTION: 'EVENT_EDIT_DESCRIPTION', +}); \ No newline at end of file diff --git a/firebase/functions/cron/event-notify-about-upcoming-event.js b/firebase/functions/cron/event-notify-about-upcoming-event.js new file mode 100644 index 0000000..2191a3b --- /dev/null +++ b/firebase/functions/cron/event-notify-about-upcoming-event.js @@ -0,0 +1,174 @@ +'use strict'; + +const { db } = require('../admin'); +const functions = require('firebase-functions'); +const { getAllGroups } = require('../group-util'); +const { pushToUser } = require('../push-send'); +const { usersInChannel, getUser } = require('../user-util'); +const { getNotificationTitleForUpcomingEvent, + getNotificationBodyForUpcomingEvent } = require('../localize-util'); + +/** + * Returning a time string in the format h:mm + * + * @param {Timestamp} eventTimestamp: In UTC + * @param {Int} timezoneSecondsOffset: Event timezone offset + */ +const timeStringForDate = (eventTimestamp, timezoneSecondsOffset) => { + const offset = timezoneSecondsOffset * 1000; + var eventStartDate = eventTimestamp.toDate(); + eventStartDate.setTime(eventStartDate.getTime() + offset); + + const hours = eventStartDate.getHours(); + var minutes = eventStartDate.getMinutes(); + minutes = (minutes < 10) ? `0${minutes}` : minutes; + return `${hours}:${minutes}`; +} + +/** + * Will send a localized notification to all members subscribed to a channel. + * The action is reported by setting the `notified_members` flag of the event channel to true. + * + * @param {DocumentSnapshot} channelDocument + * @param {string} groupId + * @param {string} groupName + * @param {bool} isTomorrow: Indicates if event is starting tomorrow or today. + */ +const pushToChannelMembers = async (channelDocument, groupId, groupName, isTomorrow = false) => { + const channelUsers = await usersInChannel(groupId, channelDocument.id); + const channelData = channelDocument.data(); + + channelDocument.ref.update({ + notified_members: true + }); + + for (const userSnapshot of channelUsers.docs) { + const uid = userSnapshot.data().uid; + const userDoc = await getUser(uid); + const locale = userDoc.data().locale; + + const localizedTimeString = timeStringForDate(channelData.start_date, channelData.timezone_seconds_offset); + const title = getNotificationTitleForUpcomingEvent(locale, groupName); + const body = getNotificationBodyForUpcomingEvent(locale, channelData.name, isTomorrow ? null : localizedTimeString); + + await pushToUser(userDoc.id, userDoc.data().token, title, body, "", groupId, channelDocument.id); + } +} + +/** + * Returing event channels with a start date between now and hoursInFuture. + * + * @param {string} groupId + * @param {int} hoursInFuture: -1 if we're looking for events with unset start time. + */ + +const getUpcomingEventChannels = async (groupId, hoursInFuture) => { + var channelDocs; + const channelDocsQuery = db + .collection('groups') + .doc(groupId) + .collection('channels') + .where("type", "==", "EVENT") + + // Configure query according to event type: + // - Configure event without start time + // - Configure event with star time + + if (hoursInFuture == -1) { + const startOffset = 97200000 // 3600000 * 27; + const endOffset = 198000000 // 3600000 * 28; + var endFilter = new Date(); + endFilter.setTime(endFilter.getTime() + endOffset); + var startFilter = new Date(); + startFilter.setTime(startFilter.getTime() + startOffset); + + channelDocs = await channelDocsQuery + .where('has_start_time', '==', false) + .where('start_date', '>=', startFilter) + .where('start_date', '<=', endFilter) + .get(); + + } else { + const endOffset = 3600000 * hoursInFuture // (60m * 60s * 1000ms) * hoursInFuture + var endFilter = new Date(); + endFilter.setTime(endFilter.getTime() + endOffset); + + channelDocs = await channelDocsQuery + .where('start_date', '>=', new Date()) + .where('start_date', '<=', endFilter) + .get(); + } + + return channelDocs; +} + +const pushToEventChannelMembers = async (eventChannelDocs, groupId, groupName, isTomorrow) => { + for (const channelDocument of eventChannelDocs) { + const channel = channelDocument.data(); + + if (channel.notified_members !== true) { + await pushToChannelMembers(channelDocument, groupId, groupName, isTomorrow); + } + } +} + +/** + * Sending notifications to users in groups + * + * @param {QuerySnapshot} groupDocuments: All groups + * @param {int} hoursInFuture: Used to determine the range between now and hoursInFuture + */ +const processEventsInGroupDocuments = async (groupDocuments, hoursInFuture) => { + for (const groupDocument of groupDocuments) { + const groupName = groupDocument.data().name; + const groupId = groupDocument.id + const eventChannels = await getUpcomingEventChannels(groupId, hoursInFuture); + + if (!eventChannels.empty) { + await pushToEventChannelMembers(eventChannels.docs, groupId, groupName, hoursInFuture == -1); + } + } +} + +// Cron jobs +const runtimeOpts = { + timeoutSeconds: 540, // firebase max of 9 minutes +} + +/** + * Fired every hour. + * Will send notificaitons to all users wo are members of event channels with a start_date + * starting between now and now + 2 hours where notified_members for the channel is not set. + */ +const notifyAboutUpcomingEventsToday = + functions.runWith(runtimeOpts) + .region('europe-west1') + .pubsub + .schedule('every 60 minutes') + .timeZone('UTC') + .onRun(async (context) => { + const groups = await getAllGroups(); + await processEventsInGroupDocuments(groups.docs, 2); + }); + +/** + * Fired at 20:00 Europe/Berlin time. + * Will send notifications to all users who are members of event channels with a start_date + * (starting the next day and having not start time set). + * + * NOTE: This will only work as long as events without a set time have a time default of 23:59. + */ +const notifyAboutUpcomingEventsTomorrow = functions.runWith(runtimeOpts) + .region('europe-west1') + .pubsub + .schedule('every day 20:00') + .timeZone('Europe/Berlin') + .onRun(async (context) => { + const groups = await getAllGroups(); + await processEventsInGroupDocuments(groups.docs, -1); + }); + +module.exports = { + notifyAboutUpcomingEventsTomorrow, + notifyAboutUpcomingEventsToday +} diff --git a/firebase/functions/group-update.js b/firebase/functions/group-update.js new file mode 100644 index 0000000..3e1c21b --- /dev/null +++ b/firebase/functions/group-update.js @@ -0,0 +1,15 @@ +const functions = require('firebase-functions'); +const { updateCalendarGroupName } = require('./calendar-update'); + +const updatedGroup = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}') + .onUpdate(async (change, context) => { + const groupAfter = change.after.data(); + + // Update Calendar + await updateCalendarGroupName(context.params.groupId, groupAfter.name); + }); + +module.exports = updatedGroup; \ No newline at end of file diff --git a/firebase/functions/group-util.js b/firebase/functions/group-util.js new file mode 100644 index 0000000..c7472a0 --- /dev/null +++ b/firebase/functions/group-util.js @@ -0,0 +1,25 @@ +const { db } = require('./admin'); + +const getAllGroups = async () => { + let groupDocs = await db + .collection('groups') + .get(); + + if (groupDocs.empty) { + console.log('Could not find any groups'); + return null; + } + + return groupDocs; +} + +const getGroupName = async (groupId) => { + let groupDoc = await db + .collection('groups') + .doc(groupId) + .get(); + + return groupDoc.data().name; +} + +module.exports = { getGroupName, getAllGroups }; diff --git a/firebase/functions/index.js b/firebase/functions/index.js new file mode 100644 index 0000000..313276c --- /dev/null +++ b/firebase/functions/index.js @@ -0,0 +1,36 @@ +const updatedMessages = require('./message-update'); +const newMessages = require('./message-new'); +const newChannel = require('./channel-new'); +const editChannel = require('./channel-edit'); +const rsvpUpdate = require('./rsvp-update'); +const newUser = require('./user-new'); +const { notifyAboutUpcomingEventsToday, + notifyAboutUpcomingEventsTomorrow } = require('./cron/event-notify-about-upcoming-event'); +const newChannelUser = require('./channel-new-user'); +const deleteChannelUser = require('./channel-remove-user'); +const updatedGroup = require('./group-update'); +const deleteChannel = require('./channel-remove'); + +exports.newUser = newUser; +exports.newChannelUser = newChannelUser; +exports.deleteChannelUser = deleteChannelUser; + +exports.newChannel = newChannel; +exports.editChannel = editChannel; +exports.deleteChannel = deleteChannel; + +exports.newMessages = newMessages; +exports.updatedMessages = updatedMessages; + +exports.rsvpUpdate = rsvpUpdate; + +exports.updatedGroup = updatedGroup; + +/// Crons make us of the scheduling APIs in firebase. +/// These APIs aren't available on the free Spark Plan. +/// To deploy crons you'll need to enable the `Blaze Plan` first. +/* +// Crons +exports.notifyAboutUpcomingEventsToday = notifyAboutUpcomingEventsToday; +exports.notifyAboutUpcomingEventsTomorrow = notifyAboutUpcomingEventsTomorrow; +*/ diff --git a/firebase/functions/localize-util.js b/firebase/functions/localize-util.js new file mode 100644 index 0000000..343e781 --- /dev/null +++ b/firebase/functions/localize-util.js @@ -0,0 +1,289 @@ +const constants = require('./constants'); + +// RSVP //// +/** + * Building notification title for RSVP update for event author. + */ +const getTitleForRSVPUpdate = (locale, rsvpType, groupName, channelName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `${getTitleIconForRSVPUpdate(rsvpType)} Neue Antwort (${groupName} - ${channelName})`; + } else { + return `${getTitleIconForRSVPUpdate(rsvpType)} New RSVP (${groupName} - ${channelName})`; + } +} + +function getTitleIconForRSVPUpdate(rsvpType) { + if (rsvpType == constants.RSVP_YES) { + return "✅"; + } else if (rsvpType == constants.RSVP_MAYBE) { + return "❓"; + } else { + return "❌"; + } +} + +/** + * Building notification message body for RSVP update for event author. + */ +const getBodyForRSVPUpdate = (locale, rsvpType, userName, channelName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `${userName} ${getMessageForRSVP(locale, rsvpType)} ${channelName} teil`; + } else { + return `${userName} ${getMessageForRSVP(locale, rsvpType)} ${channelName}`; + } +} + +function getMessageForRSVP(locale, rsvpType) { + if (locale == constants.PUSH_LOCALE_DE) { + if (rsvpType == constants.RSVP_YES) { + return "nimmt an"; + } else if (rsvpType == constants.RSVP_MAYBE) { + return "nimmt vielleicht an"; + } else { + return "nimmt nicht an"; + } + } else { + if (rsvpType == constants.RSVP_YES) { + return "is going to"; + } else if (rsvpType == constants.RSVP_MAYBE) { + return "is maybe going to"; + } else { + return "is not going to"; + } + } +} + +// REACTION //// + +/** + * Building emoji reaction notification body for message. + * Defaults to "EN" when no locale is set. + */ +const getNotificationBodyForReaction = (locale, emoji) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `Hat mit ${emoji} auf deine Nachricht reagiert`; + } else { + return `Reacted with ${emoji} to your message`; + } +} + +/** + * Building emoji reaction notification title for message. + * Defaults to "EN" when no locale is set. + */ +const getNotificationTitleForReaction = (authorName, groupName, channelName) => { + return `${authorName} (${groupName} - ${channelName})`; +} + +// MESSAGE //// + +/** + * Building regular notification title for message. + * Defaults to "EN" when no locale is set. + */ +const getNotificationTitleForMessage = (hasAttachment, authorName, groupName, channelName) => { + if (hasAttachment) { + return `📸 ${authorName} (${groupName} - ${channelName})`; + } else { + return `💬 ${authorName} (${groupName} - ${channelName})`; + } +} + +/** + * Building notification body for photo-message without text. + * Defaults to "EN" when no locale is set. + */ +const getNotificationBodyForEmptyPhotoMessage = (locale) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `Neues Foto`; + } else { + return `New photo`; + } +} + +/** + * Building notification body for event. + * Defaults to "EN" when no locale is set. + */ +const getNotificationBodyForNewEvent = (locale, openEvent, authorName, channelName) => { + if (openEvent) { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat ein neues Event ${channelName} erstellt`; + } + return `${authorName} created a new event ${channelName}`; + } else { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat dich zum Event ${channelName} eingeladen`; + } + return `${authorName} invited you to the event ${channelName}`; + } +} + +// CHANNEL / EVENT //// + +/** + * Event edit notificatin : Title + */ +const getNotificationTitleForEventEdit = (groupName, eventName) => { + return `⚠️ Event ${eventName} in ${groupName}`; +} + +/** + * Event edit notification : Body + */ +const getNotificationBodyForEventEdit = (locale, authorName, date, location, eventEdits) => { + if (eventEdits.includes(constants.EVENT_EDIT_TIME)) { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat das Datum auf ${date} geändert`; + } + return `${authorName} changed the date to ${date}`; + + } else if (eventEdits.includes(constants.EVENT_EDIT_DESCRIPTION)) { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat die Beschreibung geändert`; + } + return `${authorName} changed the description`; + + } else if (eventEdits.includes(constants.EVENT_EDIT_VENUE)) { + + if (locale == constants.PUSH_LOCALE_DE) { + return location.length == 0 ? `${authorName} hat den Ort entfernt` : `${authorName} hat den Ort auf ${location} geändert`; + } + return location.length == 0 ? `${authorName} removed the location` : `${authorName} changed the location to ${location}`; + } +} + +/** + * Building notification title for event. + * Defaults to "EN" when no locale is set. + */ +const getNotificationTitleForNewEvent = (locale, openEvent, groupName, channelName) => { + const prefix = openEvent ? "🗓️" : "🗓️🔒"; + + if (openEvent) { + return `${prefix} Event ${channelName} in ${groupName}`; + } else { + if (locale == constants.PUSH_LOCALE_DE) { + return `${prefix} Event Einladung ${channelName} in ${groupName}`; + } else { + return `${prefix} Event invitation ${channelName} in ${groupName}`; + } + } +} + +/** + * Building notification title for channel. + * Defaults to "EN" when no locale is set. + */ +const getNotificationTitleForNewChannel = (locale, openTopic, groupName, channelName) => { + const prefix = openTopic ? "🆕" : "🆕🔒"; + + if (locale == constants.PUSH_LOCALE_DE) { + return `${prefix} Thema ${channelName} in ${groupName}`; + } else { + return `${prefix} Topic ${channelName} in ${groupName}`; + } +} + +/** + * Building notification message. + * Defaults to "EN" when no locale is set. + */ +const getNotificationBodyForNewChannel = (locale, openTopic, authorName, channelName) => { + if (locale == constants.PUSH_LOCALE_DE) { + if (openTopic) { + return `${authorName} hat das Thema ${channelName} erstellt`; + } else { + return `${authorName} hat dich zum Thema ${channelName} hinzugefügt`; + } + + } else { + if (openTopic) { + return `${authorName} created the topic ${channelName}`; + } else { + return `${authorName} added you to the topic ${channelName}`; + } + } +} + +/** + * Title for upcoming event notifications + */ +const getNotificationTitleForUpcomingEvent = (locale, groupName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `🗓 Event Erinnerung (${groupName})`; + } + + return `🗓 Event Reminder (${groupName})`; +} + +const getNotificationBodyForUpcomingEvent = (locale, eventChannelName, startTime) => { + // When no start time is set, the event is happening the day after + if (startTime == null) { + return (locale == constants.PUSH_LOCALE_DE) ? `${eventChannelName} findet morgen statt` : `${eventChannelName} happens tomorrow`; + } + + // When a startTime is present + if (locale == constants.PUSH_LOCALE_DE) { + return `${eventChannelName} beginnt heute um ${startTime}`; + } + + return `${eventChannelName} starts today at ${startTime}`; +} + +const getSystemMessageEventEditDate = (locale, authorName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat das Datum des Events geändert`; + } else { + return `${authorName} changed the event date`; + } +}; + +const getSystemMessageEventEditVenue = (locale, authorName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat den Ort des Events geändert`; + } else { + return `${authorName} changed the event location`; + } +}; + +const getSystemMessageEventEditDescription = (locale, authorName) => { + if (locale == constants.PUSH_LOCALE_DE) { + return `${authorName} hat die Beschreibung des Events geändert`; + } else { + return `${authorName} changed the event description`; + } +}; + +const localizeDate = (locale, date) => { + const options = { year: "numeric", month: '2-digit', day: 'numeric', hour12: false, hour: 'numeric', minute: 'numeric' }; + const localizedENDate = date.toLocaleDateString("en-US", options); + + if (locale == constants.PUSH_LOCALE_DE) { + const dateSegments = localizedENDate.split('/'); + return `${dateSegments[1]}.${dateSegments[0]}.${dateSegments[2]}`; // DD.MM.YYYY, HH:MM + } + + return localizedENDate // MM/DD/YYYY, HH:MM +} + +module.exports = { + getNotificationTitleForUpcomingEvent, + getNotificationBodyForUpcomingEvent, + getNotificationTitleForNewChannel, + getNotificationBodyForNewChannel, + getNotificationTitleForNewEvent, + getNotificationBodyForNewEvent, + getNotificationTitleForMessage, + getNotificationTitleForReaction, + getNotificationBodyForReaction, + getNotificationBodyForEmptyPhotoMessage, + getNotificationTitleForEventEdit, + getNotificationBodyForEventEdit, + getTitleForRSVPUpdate, + getBodyForRSVPUpdate, + getSystemMessageEventEditDate, + getSystemMessageEventEditVenue, + getSystemMessageEventEditDescription, + localizeDate +}; \ No newline at end of file diff --git a/firebase/functions/message-get.js b/firebase/functions/message-get.js new file mode 100644 index 0000000..c0548e4 --- /dev/null +++ b/firebase/functions/message-get.js @@ -0,0 +1,20 @@ +/** + * Create the push notification payload JSON, include the user token + * + * API v1 reference: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages + * + */ +const getMessage = (token, title, body, data, apns, android) => { + return { + token: token, + notification: { + title: title, + body: body + }, + data: data, + apns: apns, + android: android + }; +} + +module.exports = getMessage; diff --git a/firebase/functions/message-new.js b/firebase/functions/message-new.js new file mode 100644 index 0000000..fac440e --- /dev/null +++ b/firebase/functions/message-new.js @@ -0,0 +1,53 @@ +const functions = require('firebase-functions'); + +const updateLatestActivityForChannel = require('./channel-updatedAt'); +const userGroupUpdate = require('./user-groupUpdate'); +const flagChannelUnread = require('./channel-flagChannelUnread'); +const { usersInChannel, getUser } = require('./user-util'); +const { sendNewMessagePush } = require('./push-send'); + +/** + * This function is triggered when a new message document is created + * + * @type {CloudFunction} + */ +const newMessages = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}/messages/{messageId}') + .onCreate(async (snap, context) => { + // Obtain the newly created message + const message = snap.data(); + const path = snap.ref.path; + const body = message.body; + const authorId = message.author; + const attachment = message.attachment; + + if (message.type != "USER") { + console.log('System message. Ignoring'); + return; + } + + console.log(`Received ${body} with attachment: ${attachment}`); + + // get the list of users that joined the channel + const users = await usersInChannel(context.params.groupId, context.params.channelId); + + // get the message author, we need their name + const author = await getUser(authorId) ; + + // for each user in the channel, get the id + for (const userSnapshot of users.docs) { + let uid = userSnapshot.data().uid; + // Only send notification or flag channel as unread if user is not author + if (authorId !== uid) { + await userGroupUpdate(context.params.groupId, uid, context.params.channelId); + await flagChannelUnread(context.params.groupId, context.params.channelId, uid); + await sendNewMessagePush(uid, body, attachment, context, author.data()); + } + } + + await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, message.timestamp); + }); + +module.exports = newMessages; diff --git a/firebase/functions/message-update.js b/firebase/functions/message-update.js new file mode 100644 index 0000000..c695f13 --- /dev/null +++ b/firebase/functions/message-update.js @@ -0,0 +1,17 @@ +const functions = require('firebase-functions'); +const sendPushForNewReaction = require('./reaction-push'); + +const updatedMessages = functions + .region('europe-west1') + .firestore + .document('/groups/{groupId}/channels/{channelId}/messages/{messageId}') + .onUpdate(async (change, context) => { + const messageBefore = change.before.data(); + const messageAfter = change.after.data(); + const authorUid = messageAfter.author; + + await sendPushForNewReaction(messageBefore, messageAfter, authorUid, context); + + }); + +module.exports = updatedMessages; diff --git a/firebase/functions/package-lock.json b/firebase/functions/package-lock.json new file mode 100644 index 0000000..fb2a38a --- /dev/null +++ b/firebase/functions/package-lock.json @@ -0,0 +1,2813 @@ +{ + "name": "functions", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@firebase/database": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.4.12.tgz", + "integrity": "sha512-CdPZU8kNYyvtTCr7fdLiM71EX9yooiKzpMLkTfL2ay7EfvSmnbSKPPCODYeUXvijfH6w2QSyoRsS69HIBaU3iA==", + "requires": { + "@firebase/database-types": "0.4.2", + "@firebase/logger": "0.1.22", + "@firebase/util": "0.2.25", + "faye-websocket": "0.11.3", + "tslib": "1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + } + } + }, + "@firebase/database-types": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.4.2.tgz", + "integrity": "sha512-rBF/Sp4S4zzVg+a6h0iEiXR2GdNRrvx2BR6IcvGHnSPF7XVpj9UuUWtZMJyO+vWP3zlIGDvlNRJ4qF01Y6KxGg==" + }, + "@firebase/logger": { + "version": "0.1.22", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.1.22.tgz", + "integrity": "sha512-os1vG5FohEF9gl27duZeTtEphOP7oHQ+YjnT+sT2dGprkTIAyaEkzH6G8AgLPUqmASSsoa6BqY5kFXHQi9+xGw==" + }, + "@firebase/util": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-0.2.25.tgz", + "integrity": "sha512-J/JgYhvFLCpejzfzjzNDZGFZD3kNtTlMu+2EjiQ3tCII6w0N/uEza5GtFiYTKCjGBa51Lmi2j/OPLz+yhlQCWg==", + "requires": { + "tslib": "1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + } + } + }, + "@google-cloud/common": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-2.1.2.tgz", + "integrity": "sha512-VAjWRrTEgcGujj/MgTTAtjjzeDoQqs/FDT6DG7004QFZoJsSwBmx2vGpI5TJmCuxLWvhEc0Xs5AMOvhgt7FLSw==", + "optional": true, + "requires": { + "@google-cloud/projectify": "^1.0.0", + "@google-cloud/promisify": "^1.0.0", + "arrify": "^2.0.0", + "duplexify": "^3.6.0", + "ent": "^2.2.0", + "extend": "^3.0.2", + "google-auth-library": "^5.0.0", + "retry-request": "^4.0.0", + "teeny-request": "^5.2.1" + } + }, + "@google-cloud/firestore": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-2.2.8.tgz", + "integrity": "sha512-838nh/3Eyv3GB1TSIwWJaOLTDjQZ5mNm3PzgkNZA31GHK91GE4mziYsH05FQJ9FkCbn2TSEhXFihufs5hqBPOg==", + "optional": true, + "requires": { + "bun": "^0.0.12", + "deep-equal": "^1.0.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^1.1.2", + "through2": "^3.0.0" + } + }, + "@google-cloud/paginator": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-2.0.0.tgz", + "integrity": "sha512-droVsitvSUGSoMV7Hbk2B5dCFkZIz9YNu3D1AxgFh+hmbSEWJ9SgB/M3WrU8CUx3pseH7IbLuq8jgs3HEFzeHw==", + "optional": true, + "requires": { + "arrify": "^2.0.0", + "extend": "^3.0.1" + } + }, + "@google-cloud/projectify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-1.0.1.tgz", + "integrity": "sha512-xknDOmsMgOYHksKc1GPbwDLsdej8aRNIA17SlSZgQdyrcC0lx0OGo4VZgYfwoEU1YS8oUxF9Y+6EzDOb0eB7Xg==", + "optional": true + }, + "@google-cloud/promisify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-1.0.2.tgz", + "integrity": "sha512-7WfV4R/3YV5T30WRZW0lqmvZy9hE2/p9MvpI34WuKa2Wz62mLu5XplGTFEMK6uTbJCLWUxTcZ4J4IyClKucE5g==", + "optional": true + }, + "@google-cloud/storage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-3.2.0.tgz", + "integrity": "sha512-vV8MUb9WinJqeX4s+OaZk3PjpB3L8Z5SH7j9rQFtOyMMUiiwrvqtT8AgG2/Egmobbfg5cyXhO/OJrILM17HOGg==", + "optional": true, + "requires": { + "@google-cloud/common": "^2.1.1", + "@google-cloud/paginator": "^2.0.0", + "@google-cloud/promisify": "^1.0.0", + "arrify": "^2.0.0", + "compressible": "^2.0.12", + "concat-stream": "^2.0.0", + "date-and-time": "^0.9.0", + "duplexify": "^3.5.0", + "extend": "^3.0.2", + "gaxios": "^2.0.1", + "gcs-resumable-upload": "^2.0.0", + "hash-stream-validation": "^0.2.1", + "mime": "^2.2.0", + "mime-types": "^2.0.8", + "onetime": "^5.1.0", + "p-limit": "^2.2.0", + "pumpify": "^2.0.0", + "snakeize": "^0.1.0", + "stream-events": "^1.0.1", + "through2": "^3.0.0", + "xdg-basedir": "^4.0.0" + }, + "dependencies": { + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "optional": true + } + } + }, + "@grpc/grpc-js": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-0.5.2.tgz", + "integrity": "sha512-NE1tP/1AF6BqhLdILElnF7aOBfoky+4ZOdZU/0NmKo2d+9F9QD8zGoElpBk/5BfyQZ3u1Zs+wFbDOFpVUzDx1w==", + "optional": true, + "requires": { + "semver": "^6.0.0" + } + }, + "@grpc/proto-loader": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.5.1.tgz", + "integrity": "sha512-3y0FhacYAwWvyXshH18eDkUI40wT/uGio7MAegzY8lO5+wVsc19+1A7T0pPptae4kl7bdITL+0cHpnAPmryBjQ==", + "optional": true, + "requires": { + "lodash.camelcase": "^4.3.0", + "protobufjs": "^6.8.6" + } + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "optional": true + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "optional": true + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "optional": true + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=", + "optional": true + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "optional": true, + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "optional": true + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=", + "optional": true + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "optional": true + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "optional": true + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=", + "optional": true + }, + "@types/body-parser": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.1.tgz", + "integrity": "sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.32", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", + "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.1.tgz", + "integrity": "sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.16.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.9.tgz", + "integrity": "sha512-GqpaVWR0DM8FnRUJYKlWgyARoBUAVfRIeVDZQKOttLFp5SmhhF9YFIYeTPwMd/AXfxlP7xVO2dj1fGu0Q+krKQ==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/lodash": { + "version": "4.14.134", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.134.tgz", + "integrity": "sha512-2/O0khFUCFeDlbi7sZ7ZFRCcT812fAeOLm7Ev4KbwASkZ575TDrDcY7YyaoHdTOzKcNbfiwLYZqPmoC4wadrsw==", + "dev": true + }, + "@types/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.0.tgz", + "integrity": "sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q==", + "optional": true + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" + }, + "@types/node": { + "version": "8.10.52", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.52.tgz", + "integrity": "sha512-2RbW7WXeLex6RI+kQSxq6Ym0GiVcODeQ4Km7MnnTX5BHdOGQnqVa+s6AUmAW+OFYAJ8wv9QxvNZXm7/kBdGTVw==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "optional": true, + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "optional": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "ajv-keywords": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", + "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", + "dev": true + }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "optional": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + } + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", + "optional": true + }, + "bignumber.js": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==", + "optional": true + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "bun": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/bun/-/bun-0.0.12.tgz", + "integrity": "sha512-Toms18J9DqnT+IfWkwxVTB2EaBprHvjlMWrTIsfX4xbu3ZBqVBwrERU0em1IgtRe04wT+wJxMlKHZok24hrcSQ==", + "optional": true, + "requires": { + "readable-stream": "~1.0.32" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "^0.2.0" + } + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "compressible": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz", + "integrity": "sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw==", + "optional": true, + "requires": { + "mime-db": ">= 1.40.0 < 2" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "optional": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "requires": { + "safe-buffer": "~5.2.0" + } + } + } + }, + "configstore": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.0.tgz", + "integrity": "sha512-eE/hvMs7qw7DlcB5JPRnthmrITuHMmACUJAp89v6PT6iOqzoLS7HRWhBtuHMlhNHo2AhUSA/3Dh1bKNJHcublQ==", + "optional": true, + "requires": { + "dot-prop": "^5.1.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + }, + "dependencies": { + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "optional": true + }, + "dot-prop": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.1.0.tgz", + "integrity": "sha512-n1oC6NBF+KM9oVXtjmen4Yo7HyAVWV2UUl50dCYJdw2924K6dX9bf9TTTWaKtYlRn0FEtxG27KS80ayVLixxJA==", + "optional": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "optional": true + }, + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "optional": true, + "requires": { + "semver": "^6.0.0" + } + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "optional": true, + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "write-file-atomic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.0.tgz", + "integrity": "sha512-EIgkf60l2oWsffja2Sf2AL384dx328c0B+cIYPTQq5q2rOYuDV00/iPFBOUiDKKwKMOhkymH8AidPaRvzfxY+Q==", + "optional": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "optional": true + } + } + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } + }, + "date-and-time": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.9.0.tgz", + "integrity": "sha512-4JybB6PbR+EebpFx/KyR5Ybl+TcdXMLIJkyYsCx3P4M4CWGMuDyFF19yh6TyasMAIF5lrsgIxiSHBXh2FFc7Fg==", + "optional": true + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "optional": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "requires": { + "streamsearch": "0.1.2" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "optional": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "optional": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "optional": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "optional": true, + "requires": { + "once": "^1.4.0" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "optional": true + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "optional": true + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "optional": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", + "dev": true, + "requires": { + "ajv": "^5.3.0", + "babel-code-frame": "^6.22.0", + "chalk": "^2.1.0", + "concat-stream": "^1.6.0", + "cross-spawn": "^5.1.0", + "debug": "^3.1.0", + "doctrine": "^2.1.0", + "eslint-scope": "^3.7.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^3.5.4", + "esquery": "^1.0.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.0.1", + "ignore": "^3.3.3", + "imurmurhash": "^0.1.4", + "inquirer": "^3.0.6", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.9.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.4", + "minimatch": "^3.0.2", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "pluralize": "^7.0.0", + "progress": "^2.0.0", + "regexpp": "^1.0.1", + "require-uncached": "^1.0.3", + "semver": "^5.3.0", + "strip-ansi": "^4.0.0", + "strip-json-comments": "~2.0.1", + "table": "4.0.2", + "text-table": "~0.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "eslint-plugin-promise": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-3.8.0.tgz", + "integrity": "sha512-JiFL9UFR15NKpHyGii1ZcvmtIqa3UTwiDAGb8atSffe43qJ3+1czVGN6UtkklpcJ2DVnqvTMzEKRaJdBkAL2aQ==", + "dev": true + }, + "eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", + "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", + "dev": true + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "dev": true, + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "optional": true + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "optional": true + }, + "external-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-text-encoding": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz", + "integrity": "sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==", + "optional": true + }, + "faye-websocket": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.3.tgz", + "integrity": "sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA==", + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "firebase-admin": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-8.4.0.tgz", + "integrity": "sha512-DRAAPRFYhdpwNu8KDceuem7Y1yvFZRqAf6iO5/5IwiHTp9ojRin/V8eV2eNjY3C4tZCKkJDpXvCBtwbvBejFDA==", + "requires": { + "@firebase/database": "^0.4.7", + "@google-cloud/firestore": "^2.0.0", + "@google-cloud/storage": "^3.0.2", + "@types/node": "^8.0.53", + "dicer": "^0.3.0", + "jsonwebtoken": "8.1.0", + "node-forge": "0.7.4" + } + }, + "firebase-functions": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-3.2.0.tgz", + "integrity": "sha512-v61CXYFSb53SdSSqwc/QhdBrR+H0bhwxSOIhKIYFFa2m5APUsuj8SrkAOBL2CfOJo3yk7+nuuWOtz16JFaXLxg==", + "requires": { + "@types/express": "^4.17.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.14" + }, + "dependencies": { + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "firebase-functions-test": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-0.1.6.tgz", + "integrity": "sha512-sITLbQunI75gL690qFOq4mqxUEcdETEbY4HcLFawWVJC3PmlSFt81mhfZjJe45GJTt1+7xeowaHQx3jpnoPNpA==", + "dev": true, + "requires": { + "@types/lodash": "^4.14.104", + "lodash": "^4.17.5" + } + }, + "flat-cache": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.4.tgz", + "integrity": "sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg==", + "dev": true, + "requires": { + "circular-json": "^0.3.1", + "graceful-fs": "^4.1.2", + "rimraf": "~2.6.2", + "write": "^0.2.1" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" + }, + "gaxios": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.0.1.tgz", + "integrity": "sha512-c1NXovTxkgRJTIgB2FrFmOFg4YIV6N/bAa4f/FZ4jIw13Ql9ya/82x69CswvotJhbV3DiGnlTZwoq2NVXk2Irg==", + "optional": true, + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^2.2.1", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-2.0.1.tgz", + "integrity": "sha512-nrbLj5O1MurvpLC/doFwzdTfKnmYGDYXlY/v7eQ4tJNVIvQXbOK672J9UFbradbtmuTkyHzjpzD8HD0Djz0LWw==", + "optional": true, + "requires": { + "gaxios": "^2.0.0", + "json-bigint": "^0.3.0" + } + }, + "gcs-resumable-upload": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-2.2.4.tgz", + "integrity": "sha512-UqoGRLImof+6DRv/7QnMGP3ot+RKhsIS2dVziGFe+ajFDW0cnit7xYyViFA99utDQB0RD+fSqKBkYwNXX3Y42w==", + "optional": true, + "requires": { + "abort-controller": "^3.0.0", + "configstore": "^5.0.0", + "gaxios": "^2.0.0", + "google-auth-library": "^5.0.0", + "pumpify": "^2.0.0", + "stream-events": "^1.0.4" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "google-auth-library": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-5.2.0.tgz", + "integrity": "sha512-I2726rgOedQ06HgTvoNvBeRCzy5iFe6z3khwj6ugfRd1b0VHwnTYKl/3t2ytOTo7kKc6KivYIBsCIdZf2ep67g==", + "optional": true, + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "fast-text-encoding": "^1.0.0", + "gaxios": "^2.0.0", + "gcp-metadata": "^2.0.0", + "gtoken": "^4.0.0", + "jws": "^3.1.5", + "lru-cache": "^5.0.0" + } + }, + "google-gax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-1.3.0.tgz", + "integrity": "sha512-35MlgFOxtjEzb730V/Ku1ToOCt795bxXYuQHEZ9kFUnvWKKe98Njf6XtHW41Zr4Vm2e87Rt0MrU9H0iwgM0BZQ==", + "optional": true, + "requires": { + "@grpc/grpc-js": "^0.5.2", + "@grpc/proto-loader": "^0.5.1", + "duplexify": "^3.6.0", + "google-auth-library": "^5.0.0", + "is-stream-ended": "^0.1.4", + "lodash.at": "^4.6.0", + "lodash.has": "^4.5.2", + "protobufjs": "^6.8.8", + "retry-request": "^4.0.0", + "semver": "^6.0.0", + "walkdir": "^0.4.0" + } + }, + "google-p12-pem": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.1.tgz", + "integrity": "sha512-6h6x+eBX3k+IDSe/c8dVYmn8Mzr1mUcmKC9MdUSwaBkFAXlqBEnwFWmSFgGC+tcqtsLn73BDP/vUNWEehf1Rww==", + "optional": true, + "requires": { + "node-forge": "^0.8.0" + }, + "dependencies": { + "node-forge": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", + "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==", + "optional": true + } + } + }, + "graceful-fs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", + "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==" + }, + "gtoken": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-4.0.0.tgz", + "integrity": "sha512-XaRCfHJxhj06LmnWNBzVTAr85NfAErq0W1oabkdqwbq3uL/QTB1kyvGog361Uu2FMG/8e3115sIy/97Rnd4GjQ==", + "optional": true, + "requires": { + "gaxios": "^2.0.0", + "google-p12-pem": "^2.0.0", + "jws": "^3.1.5", + "mime": "^2.2.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "hash-stream-validation": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.1.tgz", + "integrity": "sha1-7Mm5l7IYvluzEphii7gHhptz3NE=", + "optional": true, + "requires": { + "through2": "^2.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "optional": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "optional": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "optional": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + } + } + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + } + } + }, + "http-parser-js": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.10.tgz", + "integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q=" + }, + "http-proxy-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", + "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "optional": true, + "requires": { + "agent-base": "4", + "debug": "3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "optional": true + } + } + }, + "https-proxy-agent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz", + "integrity": "sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg==", + "optional": true, + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.0.4", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rx-lite": "^4.0.8", + "rx-lite-aggregates": "^4.0.8", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "ipaddr.js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", + "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==" + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==", + "optional": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "optional": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "optional": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "json-bigint": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", + "integrity": "sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=", + "optional": true, + "requires": { + "bignumber.js": "^7.0.0" + } + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "jsonwebtoken": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.1.0.tgz", + "integrity": "sha1-xjl80uX9WD1lwAeoPce7eOaYK4M=", + "requires": { + "jws": "^3.1.4", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.0.0", + "xtend": "^4.0.1" + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash.at": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.at/-/lodash.at-4.6.0.tgz", + "integrity": "sha1-k83OZk8KGZTqM9181A4jr9EbD/g=", + "optional": true + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "optional": true + }, + "lodash.has": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", + "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=", + "optional": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "optional": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "optional": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==", + "optional": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "requires": { + "mime-db": "1.40.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "optional": true + }, + "node-forge": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.4.tgz", + "integrity": "sha512-8Df0906+tq/omxuCZD6PqhPaQDYuyJ1d+VITgxoIA8zvQd1ru+nMJcDChHH324MWitIgbVkAkQoGEEVJNpn/PA==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "optional": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "optional": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "optional": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "optional": true, + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "dependencies": { + "@types/node": { + "version": "10.14.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.16.tgz", + "integrity": "sha512-/opXIbfn0P+VLt+N8DE4l8Mn8rbhiJgabU96ZJ0p9mxOkIks5gh6RUnpHak7Yh0SFkyjO/ODbxsQQPV2bpMmyA==", + "optional": true + } + } + }, + "proxy-addr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", + "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.0" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.0.tgz", + "integrity": "sha512-ieN9HmpFPt4J4U4qnjN4BxrnqpPPXJyp3qFErxfwBtFOec6ewpIHdS2eu3TkmGW6S+RzFGEOGpm5ih/X/onRPQ==", + "optional": true, + "requires": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + }, + "dependencies": { + "duplexify": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz", + "integrity": "sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==", + "optional": true, + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "requires": { + "safe-buffer": "~5.2.0" + } + } + } + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "regexpp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", + "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", + "dev": true + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + } + } + }, + "retry-request": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-4.1.1.tgz", + "integrity": "sha512-BINDzVtLI2BDukjWmjAIRZ0oglnCAkpP2vQjM3jdLhmT62h0xnQgciPwBRDAvHqpkPT2Wo1XuUyLyn6nbGrZQQ==", + "optional": true, + "requires": { + "debug": "^4.1.1", + "through2": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "optional": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "dev": true + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "dev": true, + "requires": { + "rx-lite": "*" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "optional": true + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + } + } + }, + "snakeize": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/snakeize/-/snakeize-0.1.0.tgz", + "integrity": "sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0=", + "optional": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "optional": true, + "requires": { + "stubs": "^3.0.0" + } + }, + "stream-shift": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", + "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "optional": true + }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "optional": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=", + "optional": true + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "table": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz", + "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==", + "dev": true, + "requires": { + "ajv": "^5.2.3", + "ajv-keywords": "^2.1.0", + "chalk": "^2.1.0", + "lodash": "^4.17.4", + "slice-ansi": "1.0.0", + "string-width": "^2.1.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "teeny-request": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-5.2.1.tgz", + "integrity": "sha512-gCVm5EV3z0p/yZOKyeBOFOpSXuxdIs3foeWDWb/foKMBejK18w40L0k0UMd/ZrGkOH+gxodjqpL8KK6x3haYCQ==", + "optional": true, + "requires": { + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^2.2.1", + "node-fetch": "^2.2.0", + "stream-events": "^1.0.5", + "uuid": "^3.3.2" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "optional": true, + "requires": { + "readable-stream": "2 || 3" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "requires": { + "safe-buffer": "~5.2.0" + } + } + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "optional": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", + "optional": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "optional": true + }, + "websocket-driver": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.3.tgz", + "integrity": "sha512-bpxWlvbbB459Mlipc5GBzzZwhoZgGEZLuqPaR0INBGnPAY1vdBX6hPnoFXiw+3yWxDuHyQjO2oXTMyS8A5haFg==", + "requires": { + "http-parser-js": ">=0.4.0 <0.4.11", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", + "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==" + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "optional": true + } + } +} diff --git a/firebase/functions/package.json b/firebase/functions/package.json new file mode 100644 index 0000000..24bd683 --- /dev/null +++ b/firebase/functions/package.json @@ -0,0 +1,25 @@ +{ + "name": "functions", + "description": "Cloud Functions for Firebase", + "scripts": { + "lint": "./node_modules/.bin/eslint --max-warnings=0 .", + "serve": "firebase serve --only functions", + "shell": "firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "8" + }, + "dependencies": { + "firebase-admin": "^8.4.0", + "firebase-functions": "^3.2.0" + }, + "devDependencies": { + "eslint": "^4.13.1", + "eslint-plugin-promise": "^3.6.0", + "firebase-functions-test": "^0.1.6" + }, + "private": true +} diff --git a/firebase/functions/push-send.js b/firebase/functions/push-send.js new file mode 100644 index 0000000..454266b --- /dev/null +++ b/firebase/functions/push-send.js @@ -0,0 +1,126 @@ +const { db, admin } = require('./admin'); +const { getUser } = require('./user-util'); +const { getGroupName } = require('./group-util'); +const { getChannelName } = require('./channel-util'); +const getMessage = require('./message-get'); +const { getNotificationTitleForMessage, getNotificationBodyForEmptyPhotoMessage } = require('./localize-util'); +/** + * Send push notification. + * @param message is a JSON that follows this specs: https://firebase.google.com/docs/cloud-messaging/http-server-ref + */ +const sendPush = async (message) => { + try { + const response = await admin.messaging().send(message); + console.log('Notification sent successfully:', response); + } catch (error) { + console.error('Notification sent failed:', error); + } +} + +async function countTotalUnread(userId) { + let badgeCount = 0; + + try { + let listGroups = await db + .collection("/groups") + .listDocuments(); + + for (let ref of listGroups) { + + let listChannels = await ref + .collection('channels') + .listDocuments(); + + for (let channel of listChannels) { + + let userInChannel = await channel + .collection('users') + .doc(userId) + .get(); + + if (userInChannel.exists) { + if (userInChannel.data().hasUpdates) { + badgeCount++; + } + } + } + } + } catch (error) { + console.error("Error calculating badges: ", error); + } + + return badgeCount; +} + +function getMessageData(groupId, channelId, username) { + return { + groupId: groupId, + channelId: channelId, + username: username, + type: "message" + }; +} + +/** +* iOS Reference: https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html#//apple_ref/doc/uid/TP40008194-CH17-SW1 +*/ +function getApnsData(badge) { + return { + payload: { + aps: { + badge: badge + } + } + } +} + +/** + * https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidnotification + */ +function getAndroidData() { + return { + notification: { + // required for onResume and onLaunch callbacks on notification tap + click_action: "FLUTTER_NOTIFICATION_CLICK" + } + } +} + +const sendNewMessagePush = async (uid, body, attachment = null, context, author) => { + const groupName = await getGroupName(context.params.groupId); + const channelName = await getChannelName(context.params.groupId, context.params.channelId); + const title = getNotificationTitleForMessage(attachment != null, author.name, groupName, channelName); + + // get the user document from the /users collection + const doc = await getUser(uid); + const message = (attachment != null && body.length == 0) ? getNotificationBodyForEmptyPhotoMessage(doc.data().locale) : body; + + // If the user has a cloud messaging token registered + let token = doc.data().token; + + await pushToUser(uid, token, title, message, author.name, context.params.groupId, context.params.channelId); +} + +const pushToUser = async (uid, token, title, body, authorName, groupId, channelId) => { + // compute the total number of unreads + const badge = await countTotalUnread(uid); + + // If the user has a cloud messaging token registered + if (token != null) { + const push = getMessage( + token, + title, + body, + getMessageData( + groupId, + channelId, + authorName, + body + ), + getApnsData(badge), + getAndroidData()); + await sendPush(push); + } +} + +module.exports = { sendPush, sendNewMessagePush, pushToUser }; diff --git a/firebase/functions/reaction-push.js b/firebase/functions/reaction-push.js new file mode 100644 index 0000000..001fba0 --- /dev/null +++ b/firebase/functions/reaction-push.js @@ -0,0 +1,55 @@ +const { getUser, usersInChannel } = require('./user-util'); +const flagChannelUnread = require('./channel-flagChannelUnread'); +const getMessage = require('./message-get'); +const { sendPush } = require('./push-send'); +const { getNotificationTitleForReaction, getNotificationBodyForReaction } = require('./localize-util'); +const { getGroupName } = require('./group-util'); +const { getChannelName } = require('./channel-util'); + +const sendPushForNewReaction = async (messageBefore, messageAfter, authorUid, context) => { + const reactionBefore = messageBefore.reaction; + const reactionAfter = messageAfter.reaction; + const author = await getUser(authorUid); + const groupName = await getGroupName(context.params.groupId); + const channelName = await getChannelName(context.params.groupId, context.params.channelId); + + for (const key in reactionAfter) { + // A new reaction was added + if (!Object.keys(reactionBefore).includes(key)) { + console.log('New Emoji reaction added to message'); + + // get the list of users that joined the channel + const users = await usersInChannel(context.params.groupId, context.params.channelId); + // Check if the author is still part of the channel + if (!users.docs.some((user) => { + return authorUid === user.id; + })) { + console.log('Author left the channel'); + return; + } + + // Update read flag for author. + await flagChannelUnread(context.params.groupId, context.params.channelId, author.data().uid); + + const val = reactionAfter[key]; + const emoji = val.emoji; + const username = val.user_name; + const token = author.data().token; + + // If the user has a cloud messaging token registered + if (token != null) { + const title = getNotificationTitleForReaction(username, groupName, channelName); + const body = getNotificationBodyForReaction(author.data().locale, emoji); + console.log('Sending notification to user ' + author.data().name + ' with text: ' + body); + const message = getMessage( + token, + title, + body, + { type: "reaction" }); + await sendPush(message); + } + } + } +} + +module.exports = sendPushForNewReaction; diff --git a/firebase/functions/rsvp-update.js b/firebase/functions/rsvp-update.js new file mode 100644 index 0000000..0acbeb7 --- /dev/null +++ b/firebase/functions/rsvp-update.js @@ -0,0 +1,68 @@ +const { getUser } = require('./user-util'); +const functions = require('firebase-functions'); +const { db } = require('./admin'); +const { getGroupName } = require('./group-util'); +const updateLatestActivityForChannel = require('./channel-updatedAt'); +const { getTitleForRSVPUpdate, + getBodyForRSVPUpdate } = require('./localize-util'); +const { pushToUser } = require('./push-send'); +const { getChannel } = require('./channel-util'); + +async function sendUpdateToRSVPAuthor(authorId, updatingMemberName, rsvpType, groupId, channel) { + const groupName = await getGroupName(groupId); + const channelName = channel.data().name; + const authorDoc = await getUser(authorId); + const locale = authorDoc.data().locale; + const authorToken = authorDoc.data().token; + const title = getTitleForRSVPUpdate(locale, rsvpType, groupName, channelName); + const message = getBodyForRSVPUpdate(locale, rsvpType, updatingMemberName, channelName); + + await pushToUser(authorId, authorToken, title, message, "", groupId, channel.id); +} + +const rsvpUpdate = functions + .region('europe-west1') + .firestore.document('/groups/{groupId}/channels/{channelId}/users/{userId}') + .onUpdate(async (change, context) => { + const userBefore = change.before.data(); + const userAfter = change.after.data(); + const rsvpBefore = userBefore.rsvp; + const rsvpAfter = userAfter.rsvp; + const validRSVP = rsvpBefore !== rsvpAfter; + + const channel = await getChannel(context.params.groupId, context.params.channelId); + const isAuthor = channel.data().authorId === context.params.userId; + + if (validRSVP) { + if (isAuthor) { + return; + } + + const doc = await getUser(userBefore.uid); + await sendUpdateToRSVPAuthor(channel.data().authorId, doc.data().name, rsvpAfter, context.params.groupId, channel); + + let message; + switch (rsvpAfter) { + case "YES": + message = "{RSVP_YES}"; + break; + case "MAYBE": + message = "{RSVP_MAYBE}"; + break; + case "NO": + message = "{RSVP_NO}"; + break; + } + + await db.collection(`/groups/${context.params.groupId}/channels/${context.params.channelId}/messages/`) + .add({ + body: `${doc.data().name} ${message}`, + type: "RSVP", + timestamp: `${Date.now()}` + }); + + await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, Date.now()); + } + }); + +module.exports = rsvpUpdate; diff --git a/firebase/functions/user-groupUpdate.js b/firebase/functions/user-groupUpdate.js new file mode 100644 index 0000000..d0fc6d7 --- /dev/null +++ b/firebase/functions/user-groupUpdate.js @@ -0,0 +1,21 @@ +const { db, admin } = require('./admin'); + +/** + * Performs two updates: + * 1. Updates / Sets `updatedGroups` user `userId` with `groupId`. This is used to build a map of unread channels for a group on the client. + * 2. Adds key `groupId` with value `channelId` of all channels containing updates. + * This is used to highlight that there are yet unread updates. + */ +const userGroupUpdate = async (groupId, userId, channelId) => { + try { + const userDocRef = db.collection("/users/").doc(userId); + await userDocRef.update({ + updatedGroups: admin.firestore.FieldValue.arrayUnion(groupId), + [groupId]: admin.firestore.FieldValue.arrayUnion(channelId) + }) + } catch (error) { + console.error("Error updating user for group update: ", error); + } +} + +module.exports = userGroupUpdate; \ No newline at end of file diff --git a/firebase/functions/user-new.js b/firebase/functions/user-new.js new file mode 100644 index 0000000..7870919 --- /dev/null +++ b/firebase/functions/user-new.js @@ -0,0 +1,24 @@ +const functions = require('firebase-functions'); +const { db } = require('./admin'); + +const newUser = functions + .region('europe-west1') + .firestore + .document('/users/{userId}') + .onCreate(async (snapshot, context) => { + const allGroups = await db.collection("/groups/") + .where("members", "array-contains", context.params.userId) + .get(); + + var groupIds = []; + for(const groupSnapshot of allGroups.docs) { + groupIds.push(groupSnapshot.id); + } + + // Set all "joinedGroups" according to "members" in each group + snapshot.ref.update({ + joinedGroups : groupIds + }); +}); + +module.exports = newUser; \ No newline at end of file diff --git a/firebase/functions/user-util.js b/firebase/functions/user-util.js new file mode 100644 index 0000000..f5bab4d --- /dev/null +++ b/firebase/functions/user-util.js @@ -0,0 +1,30 @@ +const { db } = require('./admin'); + +const getUsersInGroup = async (groupId) => { + try { + return await db.collection("users") + .where("joinedGroups", "array-contains", groupId) + .get() + } catch (error) { + console.log(`Error getting users for group: ${groupId}`); + } +} + +const getUser = (uid) => { + try { + return db + .collection('users') + .doc(uid) + .get(); + } catch (error) { + console.error("Error getting user: ", error); + } +} + +const usersInChannel = async (groupId, channelId) => { + return await db + .collection(`/groups/${groupId}/channels/${channelId}/users`) + .get(); +} + +module.exports = { getUser, usersInChannel, getUsersInGroup }; diff --git a/firebase/package-lock.json b/firebase/package-lock.json new file mode 100644 index 0000000..2af4b5c --- /dev/null +++ b/firebase/package-lock.json @@ -0,0 +1,11 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + } + } +} diff --git a/fonts/Edmondsans-Bold.otf b/fonts/Edmondsans-Bold.otf new file mode 100644 index 0000000..f4cea36 Binary files /dev/null and b/fonts/Edmondsans-Bold.otf differ diff --git a/fonts/Edmondsans-Medium.otf b/fonts/Edmondsans-Medium.otf new file mode 100644 index 0000000..eb982db Binary files /dev/null and b/fonts/Edmondsans-Medium.otf differ diff --git a/fonts/Edmondsans-Regular.otf b/fonts/Edmondsans-Regular.otf new file mode 100644 index 0000000..d79cc5b Binary files /dev/null and b/fonts/Edmondsans-Regular.otf differ diff --git a/fonts/Poppins-ExtraBold.ttf b/fonts/Poppins-ExtraBold.ttf new file mode 100755 index 0000000..9e7b797 Binary files /dev/null and b/fonts/Poppins-ExtraBold.ttf differ diff --git a/fonts/Poppins-Regular.ttf b/fonts/Poppins-Regular.ttf new file mode 100755 index 0000000..850d03e Binary files /dev/null and b/fonts/Poppins-Regular.ttf differ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9367d48 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..e8efba1 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..399e934 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..64eddc6 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,71 @@ +# 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 + pods_ary = [] + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) { |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) + pods_ary.push({:name => podname, :path => podpath}); + else + puts "Invalid plugin specification: #{line}" + end + } + return pods_ary +end + +target 'Runner' do + use_frameworks! + + # 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') + + # Flutter Pods + generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig') + if generated_xcode_build_settings.empty? + puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." + end + generated_xcode_build_settings.map { |p| + if p[:name] == 'FLUTTER_FRAMEWORK_DIR' + symlink = File.join('.symlinks', 'flutter') + File.symlink(File.dirname(p[:path]), symlink) + pod 'Flutter', :path => File.join(symlink, File.basename(p[:path])) + end + } + + # Plugin Pods + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.map { |p| + symlink = File.join('.symlinks', 'plugins', p[:name]) + File.symlink(p[:path], symlink) + pod p[:name], :path => File.join(symlink, 'ios') + } +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/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..8ecc25c --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,317 @@ +PODS: + - 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 + - Crashlytics (3.14.0): + - Fabric (~> 1.10.2) + - Fabric (1.10.2) + - Firebase/Analytics (6.8.1): + - Firebase/Core + - Firebase/Auth (6.8.1): + - Firebase/CoreOnly + - FirebaseAuth (~> 6.2.3) + - Firebase/Core (6.8.1): + - Firebase/CoreOnly + - FirebaseAnalytics (= 6.1.1) + - Firebase/CoreOnly (6.8.1): + - FirebaseCore (= 6.2.3) + - Firebase/Firestore (6.8.1): + - Firebase/CoreOnly + - FirebaseFirestore (~> 1.5.0) + - Firebase/Messaging (6.8.1): + - Firebase/CoreOnly + - FirebaseMessaging (~> 4.1.4) + - Firebase/Storage (6.8.1): + - Firebase/CoreOnly + - FirebaseStorage (~> 3.4.1) + - firebase_analytics (0.0.1): + - Firebase/Analytics (~> 6.0) + - Firebase/Core + - Flutter + - firebase_auth (0.0.1): + - Firebase/Auth (~> 6.0) + - Firebase/Core + - Flutter + - firebase_core (0.0.1): + - Firebase/Core + - Flutter + - firebase_crashlytics (0.0.1): + - Crashlytics + - Fabric + - Firebase/Core + - Flutter + - firebase_messaging (0.0.1): + - Firebase/Core + - Firebase/Messaging + - Flutter + - firebase_storage (0.0.1): + - Firebase/Storage + - Flutter + - FirebaseAnalytics (6.1.1): + - FirebaseCore (~> 6.2) + - FirebaseInstanceID (~> 4.2) + - GoogleAppMeasurement (= 6.1.1) + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (~> 0.3) + - FirebaseAnalyticsInterop (1.4.0) + - FirebaseAuth (6.2.3): + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.2) + - GoogleUtilities/AppDelegateSwizzler (~> 6.2) + - GoogleUtilities/Environment (~> 6.2) + - GTMSessionFetcher/Core (~> 1.1) + - FirebaseAuthInterop (1.0.0) + - FirebaseCore (6.2.3): + - FirebaseCoreDiagnostics (~> 1.0) + - FirebaseCoreDiagnosticsInterop (~> 1.0) + - GoogleUtilities/Environment (~> 6.2) + - GoogleUtilities/Logger (~> 6.2) + - FirebaseCoreDiagnostics (1.0.1): + - FirebaseCoreDiagnosticsInterop (~> 1.0) + - GoogleDataTransportCCTSupport (~> 1.0) + - GoogleUtilities/Environment (~> 6.2) + - GoogleUtilities/Logger (~> 6.2) + - FirebaseCoreDiagnosticsInterop (1.0.0) + - FirebaseFirestore (1.5.0): + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.2) + - FirebaseFirestore/abseil-cpp (= 1.5.0) + - "gRPC-C++ (= 0.0.9)" + - leveldb-library (~> 1.22) + - nanopb (~> 0.3.901) + - Protobuf (~> 3.1) + - FirebaseFirestore/abseil-cpp (1.5.0): + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.2) + - "gRPC-C++ (= 0.0.9)" + - leveldb-library (~> 1.22) + - nanopb (~> 0.3.901) + - Protobuf (~> 3.1) + - FirebaseInstanceID (4.2.5): + - FirebaseCore (~> 6.0) + - GoogleUtilities/Environment (~> 6.0) + - GoogleUtilities/UserDefaults (~> 6.0) + - FirebaseMessaging (4.1.4): + - FirebaseAnalyticsInterop (~> 1.3) + - FirebaseCore (~> 6.2) + - FirebaseInstanceID (~> 4.1) + - GoogleUtilities/AppDelegateSwizzler (~> 6.2) + - GoogleUtilities/Environment (~> 6.2) + - GoogleUtilities/Reachability (~> 6.2) + - GoogleUtilities/UserDefaults (~> 6.2) + - Protobuf (~> 3.1) + - FirebaseStorage (3.4.1): + - FirebaseAuthInterop (~> 1.0) + - FirebaseCore (~> 6.0) + - GTMSessionFetcher/Core (~> 1.1) + - Flutter (1.0.0) + - flutter_native_image (0.0.1): + - Flutter + - google_sign_in (0.0.1): + - Flutter + - GoogleSignIn (~> 4.0) + - GoogleAppMeasurement (6.1.1): + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (~> 0.3) + - GoogleDataTransport (1.2.0) + - GoogleDataTransportCCTSupport (1.0.4): + - GoogleDataTransport (~> 1.2) + - nanopb + - GoogleSignIn (4.4.0): + - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" + - "GoogleToolboxForMac/NSString+URLArguments (~> 2.1)" + - GTMSessionFetcher/Core (~> 1.1) + - GoogleToolboxForMac/DebugUtils (2.2.1): + - GoogleToolboxForMac/Defines (= 2.2.1) + - GoogleToolboxForMac/Defines (2.2.1) + - "GoogleToolboxForMac/NSDictionary+URLArguments (2.2.1)": + - GoogleToolboxForMac/DebugUtils (= 2.2.1) + - GoogleToolboxForMac/Defines (= 2.2.1) + - "GoogleToolboxForMac/NSString+URLArguments (= 2.2.1)" + - "GoogleToolboxForMac/NSString+URLArguments (2.2.1)" + - GoogleUtilities/AppDelegateSwizzler (6.3.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (6.3.0) + - GoogleUtilities/Logger (6.3.0): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (6.3.0): + - GoogleUtilities/Logger + - GoogleUtilities/Network (6.3.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (6.3.0)" + - GoogleUtilities/Reachability (6.3.0): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (6.3.0): + - 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) + - GTMSessionFetcher/Core (1.2.2) + - image_picker (0.0.1): + - Flutter + - leveldb-library (1.22) + - media_picker_builder (0.0.1): + - Flutter + - nanopb (0.3.901): + - nanopb/decode (= 0.3.901) + - nanopb/encode (= 0.3.901) + - nanopb/decode (0.3.901) + - nanopb/encode (0.3.901) + - pinch_zoom_image (0.0.1): + - Flutter + - Protobuf (3.9.2) + - url_launcher (0.0.1): + - Flutter + +DEPENDENCIES: + - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - firebase_storage (from `.symlinks/plugins/firebase_storage/ios`) + - Flutter (from `.symlinks/flutter/ios`) + - flutter_native_image (from `.symlinks/plugins/flutter_native_image/ios`) + - google_sign_in (from `.symlinks/plugins/google_sign_in/ios`) + - image_picker (from `.symlinks/plugins/image_picker/ios`) + - media_picker_builder (from `.symlinks/plugins/media_picker_builder/ios`) + - pinch_zoom_image (from `.symlinks/plugins/pinch_zoom_image/ios`) + - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - BoringSSL-GRPC + - Crashlytics + - Fabric + - Firebase + - FirebaseAnalytics + - FirebaseAnalyticsInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreDiagnostics + - FirebaseCoreDiagnosticsInterop + - FirebaseFirestore + - FirebaseInstanceID + - FirebaseMessaging + - FirebaseStorage + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleDataTransportCCTSupport + - GoogleSignIn + - GoogleToolboxForMac + - GoogleUtilities + - "gRPC-C++" + - gRPC-Core + - GTMSessionFetcher + - leveldb-library + - nanopb + - Protobuf + +EXTERNAL SOURCES: + cloud_firestore: + :path: ".symlinks/plugins/cloud_firestore/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_crashlytics: + :path: ".symlinks/plugins/firebase_crashlytics/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" + firebase_storage: + :path: ".symlinks/plugins/firebase_storage/ios" + Flutter: + :path: ".symlinks/flutter/ios" + flutter_native_image: + :path: ".symlinks/plugins/flutter_native_image/ios" + google_sign_in: + :path: ".symlinks/plugins/google_sign_in/ios" + image_picker: + :path: ".symlinks/plugins/image_picker/ios" + media_picker_builder: + :path: ".symlinks/plugins/media_picker_builder/ios" + pinch_zoom_image: + :path: ".symlinks/plugins/pinch_zoom_image/ios" + url_launcher: + :path: ".symlinks/plugins/url_launcher/ios" + +SPEC CHECKSUMS: + BoringSSL-GRPC: db8764df3204ccea016e1c8dd15d9a9ad63ff318 + cloud_firestore: 0ac494d23db743f104b95d3291946afec187b74c + Crashlytics: 540b7e5f5da5a042647227a5e3ac51d85eed06df + Fabric: 706c8b8098fff96c33c0db69cbf81f9c551d0d74 + Firebase: 9cbe4e5b5eaafa05dc932be58b7c8c3820d71e88 + firebase_analytics: 045e7ebe7b8a4d27357168072868ff082dca15a6 + firebase_auth: 1e3b7bdd37b9fbd4ef75ba1d985b00be09e02d3d + firebase_core: ae55ea92448ec8675d325da4db22cf3b4d58a54d + firebase_crashlytics: 6b8e7358dbac1359c1e29e0796adf48eddef99c9 + firebase_messaging: acdcd2edbe7270bae6c3a93cf6c60e86538b3064 + firebase_storage: 52b0c06e27af628b627be28d32b4b540f6b67cf7 + FirebaseAnalytics: 843c7f64a8f9c79f0d03281197ebe7bb1d58d477 + FirebaseAnalyticsInterop: d48b6ab67bcf016a05e55b71fc39c61c0cb6b7f3 + FirebaseAuth: e7f86c2dfc57281cd01f7da5e4b40e01e4510a4a + FirebaseAuthInterop: 0ffa57668be100582bb7643d4fcb7615496c41fc + FirebaseCore: e9d9bd1dae61c1e82bc1e0e617a9d832392086a0 + FirebaseCoreDiagnostics: 4c04ae09d0ab027c30179828c6bb47764df1bd13 + FirebaseCoreDiagnosticsInterop: 6829da2b8d1fc795ff1bd99df751d3788035d2cb + FirebaseFirestore: c5873e279490fbe02239ab2cdfb91c2d546261cc + FirebaseInstanceID: 550df9be1f99f751d8fcde3ac342a1e21a0e6c42 + FirebaseMessaging: 9483ac438b7b223c09dad8712310e9ee7d562c99 + FirebaseStorage: b7c6d00997bc21d4465453bdcc5cc65513110fed + Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + flutter_native_image: 9c0b7451838484458e5b0fae007b86a4c2d4bdfe + google_sign_in: 27e70a98b529f0b076d4b19f231b81da28b1750b + GoogleAppMeasurement: 86a82f0e1f20b8eedf8e20326530138fd71409de + GoogleDataTransport: 8f9897b8e073687f24ca8d3c3a8013dec7d2d1cc + GoogleDataTransportCCTSupport: 7455d07b98851aa63e4c05a34dad356ca588479e + GoogleSignIn: 7ff245e1a7b26d379099d3243a562f5747e23d39 + GoogleToolboxForMac: b3553629623a3b1bff17f555e736cd5a6d95ad55 + GoogleUtilities: 9c2c544202301110b29f7974a82e77fdcf12bf51 + "gRPC-C++": 9dfe7b44821e7b3e44aacad2af29d2c21f7cde83 + gRPC-Core: c9aef9a261a1247e881b18059b84d597293c9947 + GTMSessionFetcher: 61bb0f61a4cb560030f1222021178008a5727a23 + image_picker: 16e5fec1fbc87fd3b297c53e4048521eaf17cd06 + leveldb-library: 55d93ee664b4007aac644a782d11da33fba316f7 + media_picker_builder: fac2646d747c8058a4f049190f1b349f2c74dc41 + nanopb: 2901f78ea1b7b4015c860c2fdd1ea2fee1a18d48 + pinch_zoom_image: 2455133f9c87dce3f63c828dd583e3b1cdaa2df9 + Protobuf: 67fb42ba613def994e61854de2b3164f13790cc4 + url_launcher: 0067ddb8f10d36786672aa0722a21717dba3a298 + +PODFILE CHECKSUM: ebd43b443038e611b86ede96e613bd6033c49497 + +COCOAPODS: 1.8.1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..96a0c42 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,715 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 16506ABBE2CD9B346FC76921 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F6E69CB91BC76A89A48186A /* 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, ); }; }; + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; + 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 */; }; + BFFD09F09FF23803E4771137 /* UploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFD0A7CF676C3ADC109B631 /* UploadService.swift */; }; + E51F2E6D22F87E1D0010B216 /* Info-dev.plist in Resources */ = {isa = PBXBuildFile; fileRef = E51F2E6C22F87E1D0010B216 /* Info-dev.plist */; }; + E545035D231E8789009FF153 /* ImageProcessingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E545035C231E8789009FF153 /* ImageProcessingService.swift */; }; + E57D164D23278F5500434ABB /* PermissionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57D164C23278F5500434ABB /* PermissionService.swift */; }; + E5C0F73E23277C7D00500DE5 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5C0F74023277C7D00500DE5 /* InfoPlist.strings */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 113A1DE1D635C14684AB7A41 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.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 = ""; }; + 55BCA668344803514F1B52AC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 753C22B056B4EE12374C9C6F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; 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 = ""; }; + 9F6E69CB91BC76A89A48186A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BFFD0A7CF676C3ADC109B631 /* UploadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = ""; }; + E51F2E6A22F87A930010B216 /* Runner-dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Runner-dev.entitlements"; sourceTree = ""; }; + E51F2E6C22F87E1D0010B216 /* Info-dev.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-dev.plist"; sourceTree = ""; }; + E545035C231E8789009FF153 /* ImageProcessingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingService.swift; sourceTree = ""; }; + E57D164C23278F5500434ABB /* PermissionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionService.swift; sourceTree = ""; }; + E5C0F73F23277C7D00500DE5 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + E5C0F74123277C9500500DE5 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; + E5C0F74223277C9500500DE5 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/LaunchScreen.strings; sourceTree = ""; }; + E5C0F74323277C9500500DE5 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + E5FAEB8322CB9FF200FE2DD9 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, + 16506ABBE2CD9B346FC76921 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2FF7554FF18441A2D016BF63 /* Pods */ = { + isa = PBXGroup; + children = ( + 113A1DE1D635C14684AB7A41 /* Pods-Runner.debug.xcconfig */, + 55BCA668344803514F1B52AC /* Pods-Runner.release.xcconfig */, + 753C22B056B4EE12374C9C6F /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + 9060288C8670BEDC3E842A32 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9F6E69CB91BC76A89A48186A /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B80C3931E831B6300D905FE /* App.framework */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEBA1CF902C7004384FC /* Flutter.framework */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 2FF7554FF18441A2D016BF63 /* Pods */, + 9060288C8670BEDC3E842A32 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + E57D164B23278F2F00434ABB /* Services */, + E51F2E6A22F87A930010B216 /* Runner-dev.entitlements */, + E5FAEB8322CB9FF200FE2DD9 /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + E51F2E6C22F87E1D0010B216 /* Info-dev.plist */, + E5C0F74023277C7D00500DE5 /* InfoPlist.strings */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + ); + name = "Supporting Files"; + sourceTree = ""; + }; + E57D164B23278F2F00434ABB /* Services */ = { + isa = PBXGroup; + children = ( + BFFD0A7CF676C3ADC109B631 /* UploadService.swift */, + E57D164C23278F5500434ABB /* PermissionService.swift */, + E545035C231E8789009FF153 /* ImageProcessingService.swift */, + ); + name = Services; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 04D15002361CA43D9486DDBD /* [CP] Check Pods Manifest.lock */, + E5C8B25622F85BDD005021ED /* Firebase include GoogleService-Info.plist */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 22E0B7FC839242A46C37A49F /* [CP] Embed Pods Frameworks */, + 328ECCCBEF142012295A3695 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 0910; + ORGANIZATIONNAME = "The Chromium Authors"; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + DevelopmentTeam = HZMZQ2YW7E; + LastSwiftMigration = 0910; + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + English, + en, + Base, + de, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + E5C0F73E23277C7D00500DE5 /* InfoPlist.strings in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + E51F2E6D22F87E1D0010B216 /* Info-dev.plist in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 04D15002361CA43D9486DDBD /* [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; + }; + 22E0B7FC839242A46C37A49F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/BoringSSL-GRPC/openssl_grpc.framework", + "${PODS_ROOT}/../.symlinks/flutter/ios/Flutter.framework", + "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", + "${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/Protobuf/Protobuf.framework", + "${BUILT_PRODUCTS_DIR}/flutter_native_image/flutter_native_image.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-C++/grpcpp.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework", + "${BUILT_PRODUCTS_DIR}/image_picker/image_picker.framework", + "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", + "${BUILT_PRODUCTS_DIR}/media_picker_builder/media_picker_builder.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + "${BUILT_PRODUCTS_DIR}/pinch_zoom_image/pinch_zoom_image.framework", + "${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl_grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Protobuf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_native_image.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpcpp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_picker_builder.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/pinch_zoom_image.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 328ECCCBEF142012295A3695 /* [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; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + E5C8B25622F85BDD005021ED /* Firebase include GoogleService-Info.plist */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Firebase include GoogleService-Info.plist"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nPATH_TO_GOOGLE_PLISTS=\"${PROJECT_DIR}/Runner/Firebase/\"\n\ncase \"${CONFIGURATION}\" in\n\n\"Debug\" )\ncp -r \"$PATH_TO_GOOGLE_PLISTS/GoogleService-Info-Dev.plist\" \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist\" ;;\n\n\"Release\" | \"Profile\" )\ncp -r \"$PATH_TO_GOOGLE_PLISTS/GoogleService-Info-Prod.plist\" \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist\" ;;\n\n*)\n;;\nesac\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E545035D231E8789009FF153 /* ImageProcessingService.swift in Sources */, + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + E57D164D23278F5500434ABB /* PermissionService.swift in Sources */, + BFFD09F09FF23803E4771137 /* UploadService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + E5C0F74123277C9500500DE5 /* de */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + E5C0F74223277C9500500DE5 /* de */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; + E5C0F74023277C7D00500DE5 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + E5C0F73F23277C7D00500DE5 /* en */, + E5C0F74323277C9500500DE5 /* de */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "Runner/Runner-dev.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = "Runner/Info-dev.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.janoodle.circlesApp.debug; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "Runner/Runner-dev.entitlements"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = "Runner/Info-dev.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.janoodle.circlesApp.debug; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.janoodle.circlesApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..786d6aa --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..949b678 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + BuildSystemType + Original + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..e72cabe --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,17 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? + ) -> Bool { + + UploadService.shared.configureFlutterHandler(flutterBinaryMessenger: window.rootViewController as! FlutterBinaryMessenger) + PermissionService.shared.configureFlutterHandler(flutterBinaryMessenger: window.rootViewController as! FlutterBinaryMessenger) + + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..fa0cb1f --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-Spotlight-40.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-60.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small-1.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Spotlight-40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Spotlight-40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-Small20.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-Spotlight-42.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Spotlight-41.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Spotlight-40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-iPadPro@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "timy-icon.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60.png new file mode 100644 index 0000000..b32eed2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 0000000..df10e9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 0000000..18db789 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 0000000..e64a0e3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 0000000..8370764 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-1.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-1.png new file mode 100644 index 0000000..aed2bcd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-1.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png new file mode 100644 index 0000000..aed2bcd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small20.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small20.png new file mode 100644 index 0000000..6407797 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small20.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png new file mode 100644 index 0000000..b8e3603 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png new file mode 100644 index 0000000..b8e3603 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png new file mode 100644 index 0000000..630a29f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png new file mode 100644 index 0000000..d642b83 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png new file mode 100644 index 0000000..de169fd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png new file mode 100644 index 0000000..de169fd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png new file mode 100644 index 0000000..b1e14cb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png new file mode 100644 index 0000000..d642b83 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png new file mode 100644 index 0000000..d642b83 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png new file mode 100644 index 0000000..b78e0ef Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/timy-icon.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/timy-icon.png new file mode 100644 index 0000000..9e517c6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/timy-icon.png differ diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..a5f9f86 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash screen.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash screen.pdf b/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash screen.pdf new file mode 100644 index 0000000..61a4782 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash screen.pdf differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..afc3d40 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/ImageProcessingService.swift b/ios/Runner/ImageProcessingService.swift new file mode 100644 index 0000000..d92ade5 --- /dev/null +++ b/ios/Runner/ImageProcessingService.swift @@ -0,0 +1,57 @@ +import UIKit +import func AVFoundation.AVMakeRect + +private enum ImageProcessingConstants { + static let maxImageSize = CGSize(width: 1080, height: 1080); + static let imageDefaultCompression = CGFloat(0.9); +} + +final class ImageProcessingService { + /** + Processes an image for a given URL by resizing and compressing it. + If the image is smaller than the set maxAspectSize it will not be scaled up nor will it be compressed. + + - Parameter url: Image URL + - Parameter maxAspectSize: Specifies the max with / height allowed for the given image. Default is 1080 x 1080. + - Parameter compression: Compression for the image. From 0.0 to 1.0 (high to low) + */ + static func processImageToJPEG(for url: URL, with maxAspectSize: CGSize = ImageProcessingConstants.maxImageSize, and compression: CGFloat = ImageProcessingConstants.imageDefaultCompression) -> Data? { + return imageToJPEG(url: url, with: maxAspectSize, and: compression) + } + + static func processImageToJPEG(for data: Data, with maxAspectSize: CGSize = ImageProcessingConstants.maxImageSize, and compression: CGFloat = ImageProcessingConstants.imageDefaultCompression) -> Data? { + return imageToJPEG(for: data, with: maxAspectSize, and: compression) + } +} + +private extension ImageProcessingService { + static func imageToJPEG(for data: Data? = nil, + url: URL? = nil, + with maxAspectSize: CGSize = ImageProcessingConstants.maxImageSize, + and compression: CGFloat = ImageProcessingConstants.imageDefaultCompression) -> Data? { + var loadedImage: UIImage? = nil; + if let imageData = data, let uiImage = UIImage(data: imageData) { + loadedImage = uiImage + } else if let imageURL = url, let uiImage = UIImage(contentsOfFile: imageURL.path) { + loadedImage = uiImage + } + + guard let image = loadedImage else { + return nil + } + + guard image.size.width > maxAspectSize.width || image.size.height > maxAspectSize.height else { + return UIImageJPEGRepresentation(image, 1) + } + + let aspectRect = AVMakeRect(aspectRatio: image.size, insideRect: CGRect(origin: CGPoint.zero, size: maxAspectSize)) + let rendererFormat = UIGraphicsImageRendererFormat() + rendererFormat.scale = 1.0 // We'd like this to be device independant + + let renderer = UIGraphicsImageRenderer(size: aspectRect.size, format: rendererFormat) + + return renderer.jpegData(withCompressionQuality: compression) { (context) in + image.draw(in: CGRect(origin: .zero, size: aspectRect.size)) + } + } +} diff --git a/ios/Runner/Info-dev.plist b/ios/Runner/Info-dev.plist new file mode 100644 index 0000000..485ea3a --- /dev/null +++ b/ios/Runner/Info-dev.plist @@ -0,0 +1,64 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Timy | dev + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + de + + ITSAppUsesNonExemptEncryption + + CFBundleName + Timy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + The user can upload pictures or video as message attachments + NSMicrophoneUsageDescription + The user can upload videos as message attachments + NSPhotoLibraryUsageDescription + The user can upload pictures as message attachments + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..5a11d4b --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Timy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + de + + ITSAppUsesNonExemptEncryption + CFBundleName + Timy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + The user can upload pictures or video as message attachments + NSMicrophoneUsageDescription + The user can upload videos as message attachments + NSPhotoLibraryUsageDescription + The user can upload pictures as message attachments + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/PermissionService.swift b/ios/Runner/PermissionService.swift new file mode 100644 index 0000000..9047ee4 --- /dev/null +++ b/ios/Runner/PermissionService.swift @@ -0,0 +1,75 @@ +import Foundation +import AVFoundation +import Photos + +final class PermissionService { + static let shared = PermissionService() + private var permissionChannel: FlutterMethodChannel? + + enum AccessStatus: String { + case denied = "DENIED" + case authorized = "AUTHORIZED" + } + + private init() {} + + func configureFlutterHandler(flutterBinaryMessenger: FlutterBinaryMessenger) { + permissionChannel = FlutterMethodChannel(name: "de.janoodle.timy/permission-ios", binaryMessenger: flutterBinaryMessenger) + + permissionChannel?.setMethodCallHandler { [weak self] (flutterCall, flutterResult) in + let flutterArguments = flutterCall.arguments as? NSDictionary + guard let permissionType = flutterArguments?.value(forKey: "permissionType") as? String else { return } + + switch flutterCall.method { + case "requestPermission": + switch permissionType { + case "CAMERA": + self?.requestCamera(completion: { (accessStatus) in + flutterResult(accessStatus.rawValue) + }) + case "PHOTOS": + self?.requestPhotos(completion: { (accessStatus) in + flutterResult(accessStatus.rawValue) + }) + default: flutterResult(FlutterMethodNotImplemented) + } + + default: flutterResult(FlutterMethodNotImplemented) + } + } + } +} + +// MARK: Private PermissionService + +private extension PermissionService { + func requestPhotos(completion: @escaping (AccessStatus) -> Void) { + let photoAccessStatus = PHPhotoLibrary.authorizationStatus() + switch photoAccessStatus { + case .authorized: + completion(.authorized) + case .denied, .restricted: + completion(.denied) + case .notDetermined: + PHPhotoLibrary.requestAuthorization({status in + completion(status == .authorized ? .authorized : .denied) + }) + } + } + + func requestCamera(completion: @escaping (AccessStatus) -> Void) { + switch AVCaptureDevice.authorizationStatus(for: .video) { + // Previously granted + case .authorized: completion(.authorized) + + // Ask for access + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + completion(granted ? .authorized : .denied) + } + + // Denied or restricted (by device) access + case .denied, .restricted: completion(.denied) + } + } +} diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..7335fdf --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" \ No newline at end of file diff --git a/ios/Runner/Runner-dev.entitlements b/ios/Runner/Runner-dev.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/Runner-dev.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..28c29bf --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + production + + diff --git a/ios/Runner/UploadService.swift b/ios/Runner/UploadService.swift new file mode 100644 index 0000000..d21c8b6 --- /dev/null +++ b/ios/Runner/UploadService.swift @@ -0,0 +1,420 @@ +import Foundation +import Firebase +import Photos + +// MARK: Public UploadService API + +final class UploadService { + static let shared = UploadService() + + private var uploadChannel: FlutterMethodChannel! + + private init() {} + + /** + Registering uploadFiles method and upload_platform channel. + */ + func configureFlutterHandler(flutterBinaryMessenger: FlutterBinaryMessenger) { + uploadChannel = FlutterMethodChannel(name: Constants.channelName, binaryMessenger: flutterBinaryMessenger) + + uploadChannel.setMethodCallHandler { [weak self] (flutterCall, flutterResult) in + let flutterArguments = flutterCall.arguments as? NSDictionary + + switch flutterCall.method { + case Constants.uploadMethod: + guard let (groupId, channelId, paths, localIdentifiers) = self?.parseUploadFilesData( + flutterArguments: flutterArguments) else { return } + + DispatchQueue.global().async { + self?.uploadFiles( + groupId: groupId, + channelId: channelId, + paths: paths, + localIdentifiers: localIdentifiers, + flutterResult: flutterResult + ) + } + + default: flutterResult(FlutterMethodNotImplemented) + } + } + } +} + +// MARK: Private UploadService APIs + +private extension UploadService { + + func messageDocumentReference( + groupId: String, + channelId: String, + messageId: String + ) -> DocumentReference { + let db = Firestore.firestore() + return db.collection(Constants.groupsCollection) + .document(groupId) + .collection(Constants.channelsCollection) + .document(channelId) + .collection(Constants.messagesCollection) + .document(messageId) + } + + func handleFailedUpload( + flutterResult: FlutterResult, + groupId: String, + channelId: String, + messageId: String + ) { + addMediaMessageError( + groupId: groupId, + channelId: channelId, + messageId: messageId + ) + flutterResult(FlutterError()) + updateUI(ongoingBackgroundActivity: false) + } + + func updateUI(ongoingBackgroundActivity: Bool) { + DispatchQueue.main.async { + UIApplication.shared.isNetworkActivityIndicatorVisible = ongoingBackgroundActivity + } + } + + /** + Uploading an image which has just been taken in the app. + - Parameter groupId: Unique identifier of group the image should be uploaded to. + - Parameter channelId: Unique identifier of channel the image should be uploaded to. + - Parameter paths: Paths of images. + */ + func uploadPathImages( + groupId: String, + channelId: String, + paths: [String], + flutterResult: FlutterResult + ) { + guard paths.count > 0 else { return } + updateUI(ongoingBackgroundActivity: true) + + guard let messageId = createMediaMessage( + groupId: groupId, + channelId: channelId, + media: paths + ) else { + // We're currently ignoring this case + return + } + + var downloadURLs = [String]() + + for path in paths { + let fileURL = URL(fileURLWithPath: path) + guard let imageData = ImageProcessingService.processImageToJPEG(for: fileURL) else { continue } + + let fileName = fileURL.lastPathComponent + if let downloadURL = uploadImage( + groupId: groupId, + channelId: channelId, + messageId: messageId, + data: imageData, + fileName: fileName) + { + downloadURLs.append(downloadURL) + } else { + handleFailedUpload(flutterResult: flutterResult, groupId: groupId, channelId: channelId, messageId: messageId) + return; + } + } + + addMediaToMessage(groupId: groupId, channelId: channelId, messageId: messageId, urls: downloadURLs) + updateUI(ongoingBackgroundActivity: false) + } + + /** + Locally retrieves, processes and uploads images to firebase. + + - Parameter groupId: The unique group identifier. + - Parameter channelId: The unique channel identifier. + - Parameter paths: Paths for photos taken within the app. + - Parameter localIdentifiers: Local unique identifiers for image assets stored on the iOS device. + */ + func uploadFiles( + groupId: String, + channelId: String, + paths: [String], + localIdentifiers: [String], + flutterResult: FlutterResult + ) { + // Attempt to upload image taken in-app. No message needs to be created. + if paths.count > 0 { + uploadPathImages( + groupId: groupId, + channelId: channelId, + paths: paths, + flutterResult: flutterResult + ) + return + } + + // Upload selected images + let assetsFetchResult = PHAsset.fetchAssets(withLocalIdentifiers: localIdentifiers, options: nil) + + guard assetsFetchResult.count > 0 else { + return + } + + updateUI(ongoingBackgroundActivity: true) + + // Create new message + guard let messageId = createMediaMessage( + groupId: groupId, + channelId: channelId, + media: localIdentifiers + ) else { + // We're currently ignoring this case + return + } + + let imageManager = PHImageManager.default() + let imageRequestOptions = PHImageRequestOptions() + imageRequestOptions.isSynchronous = true + var downloadURLs = [String]() + + for i in 0 ..< assetsFetchResult.count { + let asset = assetsFetchResult[i] + + // Load actual image data for selected local assets + imageManager.requestImageData( + for: asset, + options: imageRequestOptions, + resultHandler: { [weak self] (data, _, _, _) in + guard let imageData = data else { return } + + // Start uploading selected local asset + // Replace slashes to avoid creating additional folders in firebase. + let fileName = asset.localIdentifier.replacingOccurrences(of: "/", with: "-") + Constants.jpgExtension + if let downloadURL = self?.uploadImage( + groupId: groupId, + channelId: channelId, + messageId: messageId, + data: imageData, + fileName: fileName + ) { + downloadURLs.append(downloadURL) + } + } + ) + } + + // Check and handle case where not all images have been uploaded + if downloadURLs.count != localIdentifiers.count { + handleFailedUpload( + flutterResult: flutterResult, + groupId: groupId, + channelId: channelId, + messageId: messageId + ) + } else { + // Add uploaded asset url to message + addMediaToMessage( + groupId: groupId, + channelId: channelId, + messageId: messageId, + urls: downloadURLs + ) + updateUI(ongoingBackgroundActivity: false) + } + } + + func parseUploadFilesData(flutterArguments: NSDictionary?) -> ( + groupId: String, + channelId: String, + paths: [String], + localIdentifiers: [String])? + { + guard let groupId = flutterArguments?.value(forKey: Constants.keyGroupId) as? String, + let channelId = flutterArguments?.value(forKey: Constants.keyChannelId) as? String, + let paths = flutterArguments?.value(forKey: Constants.keyFilePaths) as? [String], + let localIdentifiers = flutterArguments?.value(forKey: Constants.keyLocalIdentifiers) as? [String] + else { + return nil + } + + return (groupId, channelId, paths, localIdentifiers) + } + + func uploadImage( + groupId: String, + channelId: String, + messageId: String, + data: Data, + fileName: String + ) -> String? { + let storage = Storage.storage() + var downloadableURL: String? = nil + + guard let imageData = ImageProcessingService.processImageToJPEG(for: data) else { + return nil + } + + // Files are stored inside the same path as the original message is but in Storages + let storageRef = storage.reference() + let fileRef = storageRef.child( + Constants.messagePath( + groupId: groupId, + channelId: channelId, + messageId: messageId) + + fileName) + + // Since we run inside a dispatch queue, we want to wait for the callbacks to finish to continue + let semaphore = DispatchSemaphore(value: 0) + _ = fileRef.putData(imageData, metadata: nil) { _, error in + guard error == nil else { + semaphore.signal() + return + } + + fileRef.downloadURL { (url, error) in + guard let downloadURL: URL = url else { + semaphore.signal() + return + } + + downloadableURL = downloadURL.absoluteString + semaphore.signal() + } + } + + // Wait for uploads to finish + semaphore.wait() + return downloadableURL + } + + /** + This method updates the existing media message and adds the uploaded files + */ + func addMediaToMessage( + groupId: String, + channelId: String, + messageId: String, + urls: [String] + ) { + let semaphore = DispatchSemaphore(value: 0) + messageDocumentReference(groupId: groupId, channelId: channelId, messageId: messageId) + .updateData([ + Message.Keys.media: urls, + Message.Keys.mediaStatus: Message.MediaStatus.done.rawValue, + Message.Keys.timestamp: String(Int64(Date().timeIntervalSince1970 * 1000)), + Message.Keys.body: "[media message done]" + ]) { err in + semaphore.signal() + } + // Wait for document creation + semaphore.wait() + } + + /** + Mark eror if upload fails. Currently this will mark a message as failed if just one out of all uploads fails. + */ + func addMediaMessageError( + groupId: String, + channelId: String, + messageId: String + ) { + messageDocumentReference( + groupId: groupId, + channelId: channelId, + messageId: messageId + ).updateData([ + Message.Keys.mediaStatus: Message.MediaStatus.error.rawValue + ]) + } + + /** + Creates a message that will only contain media files with the status *UPLOADING* + */ + func createMediaMessage( + groupId: String, + channelId: String, + media: [String] + ) -> String? { + let uid = self.getUserUid() + let db = Firestore.firestore() + let semaphore = DispatchSemaphore(value: 0) + let ref: DocumentReference? = db.collection(Constants.groupsCollection) + .document(groupId) + .collection(Constants.channelsCollection) + .document(channelId) + .collection(Constants.messagesCollection) + .addDocument(data: [ + Message.Keys.author: uid, + Message.Keys.type: Message.Values.mediaType, + Message.Keys.mediaStatus: Message.MediaStatus.uploading.rawValue, + Message.Keys.media: media, + Message.Keys.timestamp: String(Int64(Date().timeIntervalSince1970 * 1000)), + Message.Keys.body: "[media message uploading]" + ]) { err in + semaphore.signal() + } + // Wait for document creation + semaphore.wait() + return ref?.documentID + } + + // MARK: - USER + + /** + Gets the user id from firebase + */ + func getUserUid() -> String { + let firebaseAuth = Auth.auth() + let uid = firebaseAuth.currentUser!.uid + return uid + } +} + +// MARK: Constants + +private enum Constants { + static let channelName = "de.janoodle.timy/upload_platform" + static let uploadMethod = "uploadFiles" + static let jpgExtension = ".jpg" + + static let keyGroupId = "groupId" + static let keyChannelId = "channelId" + static let keyFilePaths = "filePaths" + static let keyLocalIdentifiers = "localIdentifiers" + + static let groupsCollection = "groups" + static let channelsCollection = "channels" + static let messagesCollection = "messages" + + static func messagePath(groupId: String, channelId: String, messageId: String) -> String { + return Constants.groupsCollection + + "\(groupId)/" + + Constants.channelsCollection + + "\(channelId)/" + + Constants.messagesCollection + + "/\(messageId)/" + } +} + +private enum Message { + enum Keys { + static let media = "media" + static let mediaStatus = "media_status" + static let timestamp = "timestamp" + static let body = "body" + static let type = "type" + static let author = "author" + } + + enum Values { + static let mediaType = "MEDIA" + } + + enum MediaStatus: String { + case uploading = "UPLOADING" + case done = "DONE" + case error = "ERROR" + } +} diff --git a/ios/Runner/de.lproj/InfoPlist.strings b/ios/Runner/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..f89c12d --- /dev/null +++ b/ios/Runner/de.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +NSCameraUsageDescription = "Für Bild- und Videonachrichten benötigten wir Zugriff auf deine Kamera"; +NSMicrophoneUsageDescription = "Dein Mikrofon wird für Videonachrichten benötigt"; +NSPhotoLibraryUsageDescription = "Für Bildnachrichten benötigen wir Zugriff auf deine Fotos"; diff --git a/ios/Runner/de.lproj/LaunchScreen.strings b/ios/Runner/de.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/de.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/de.lproj/Main.strings b/ios/Runner/de.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/de.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..516ef1f --- /dev/null +++ b/ios/Runner/en.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +NSCameraUsageDescription = "The user can upload pictures or video as message attachments"; +NSMicrophoneUsageDescription = "The user can upload videos as message attachments"; +NSPhotoLibraryUsageDescription = "The user can upload pictures as message attachments"; diff --git a/lib/circles_app.dart b/lib/circles_app.dart new file mode 100644 index 0000000..8778350 --- /dev/null +++ b/lib/circles_app.dart @@ -0,0 +1,182 @@ +import "package:circles_app/data/calendar_repository.dart"; +import "package:circles_app/data/group_repository.dart"; +import "package:circles_app/data/file_repository.dart"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/attachment/attachment_middleware.dart"; +import "package:circles_app/domain/redux/attachment/image_processor.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/authentication/auth_middleware.dart"; +import "package:circles_app/domain/redux/calendar/calendar_middleware.dart"; +import "package:circles_app/domain/redux/channel/channel_middleware.dart"; +import "package:circles_app/data/message_repository.dart"; +import "package:circles_app/domain/redux/message/message_middleware.dart"; +import "package:circles_app/data/channel_repository.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:circles_app/domain/redux/push/push_middleware.dart"; +import "package:circles_app/domain/redux/user/user_middleware.dart"; +import "package:circles_app/presentation/channel/create/create_channel.dart"; +import "package:circles_app/presentation/channel/event/create_event.dart"; +import "package:circles_app/presentation/channel/invite/invite_to_channel_screen.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_details.dart"; +import "package:circles_app/domain/redux/app_middleware.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/presentation/home/main_screen.dart"; +import "package:circles_app/presentation/image/file_picker_screen.dart"; +import "package:circles_app/presentation/image/image_pinch_screen.dart"; +import "package:circles_app/presentation/image/image_screen.dart"; +import "package:circles_app/presentation/login/loginscreen.dart"; +import "package:circles_app/presentation/settings/settings_screen.dart"; +import "package:circles_app/presentation/user/user_screen.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/logger.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_auth/firebase_auth.dart"; +import "package:firebase_storage/firebase_storage.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:redux/redux.dart"; +import "package:firebase_messaging/firebase_messaging.dart"; +import "domain/redux/user/user_actions.dart"; + +class CirclesApp extends StatefulWidget { + const CirclesApp({ + Key key, + }) : super(key: key); + + @override + _CirclesAppState createState() => _CirclesAppState(); +} + +class _CirclesAppState extends State { + Store store; + static final _navigatorKey = GlobalKey(); + final FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); + final userRepo = UserRepository(FirebaseAuth.instance, Firestore.instance); + final channelRepository = ChannelRepository(Firestore.instance); + final groupRepository = GroupRepository(Firestore.instance); + final calendarRepository = CalendarRepository(Firestore.instance); + + @override + void initState() { + super.initState(); + store = Store( + appReducer, + initialState: AppState.init(), + middleware: createStoreMiddleware( + groupRepository, + ) + ..addAll(createAuthenticationMiddleware( + userRepo, + _navigatorKey, + )) + ..addAll(createCalendarMiddleware(calendarRepository)) + ..addAll(createUserMiddleware(userRepo)) + ..addAll(createChannelsMiddleware( + channelRepository, + _navigatorKey, + )) + ..addAll(createMessagesMiddleware( + MessageRepository(Firestore.instance), + )) + ..addAll(createPushMiddleware( + userRepo, + _firebaseMessaging, + groupRepository, + channelRepository, + )) + ..addAll(createAttachmentMiddleware( + FileRepository(FirebaseStorage.instance), + ImageProcessor(), + userRepo, + )), + ); + store.dispatch(VerifyAuthenticationState()); + _firebaseMessaging.configure( + onMessage: (Map message) async { + store.dispatch(OnPushNotificationReceivedAction(message)); + }, + onLaunch: (Map message) async { + store.dispatch(OnPushNotificationOpenAction(message)); + }, + onResume: (Map message) async { + store.dispatch(OnPushNotificationOpenAction(message)); + }, + ); + _firebaseMessaging.requestNotificationPermissions( + const IosNotificationSettings(sound: true, badge: true, alert: true)); + _firebaseMessaging.onIosSettingsRegistered + .listen((IosNotificationSettings settings) { + Logger.d("Settings registered: $settings"); + }); + _firebaseMessaging.getToken().then((String token) { + assert(token != null); + Logger.d("Push Messaging token: $token"); + store.dispatch(UpdateUserTokenAction(token)); + }); + } + + // Used to propagate this users current locale to our backend (which then can send localized notifications). + _updateUserLocale(context) { + final localeCode = CirclesLocalizations.of(context).locale.languageCode; + StoreProvider.of(context) + .dispatch(UpdateUserLocaleAction(localeCode)); + } + + @override + Widget build(BuildContext context) { + + return StoreProvider( + store: store, + child: MaterialApp( + localizationsDelegates: localizationsDelegates, + supportedLocales: [ + const Locale("de", "DE"), + const Locale("en", "EN"), + ], + title: "Circles App", + navigatorKey: _navigatorKey, + theme: AppTheme.theme, + routes: { + Routes.login: (context) { + return LoginScreen(); + }, + Routes.home: (context) { + // We need a context and a user. Both are present when loading MainScreen. + _updateUserLocale(context); + return MainScreen(); + }, + Routes.channelNew: (context) { + return CreateChannelScreen(); + }, + Routes.channelInvite: (context) { + return InviteToChannelScreen(); + }, + Routes.eventNew: (context) { + return CreateEventScreen(); + }, + Routes.image: (context) { + return ImageScreen(); + }, + Routes.imagePicker: (context) { + return FilePickerScreen(); + }, + Routes.imagePinch: (context) { + return ImagePinchScreen(); + }, + Routes.reaction: (context) { + return ReactionDetails(); + }, + Routes.user: (context) { + return UserScreen(); + }, + Routes.settings: (context) { + return SettingsScreen(); + } + }, + ), + ); + } +} diff --git a/lib/circles_localization.dart b/lib/circles_localization.dart new file mode 100644 index 0000000..7d081c8 --- /dev/null +++ b/lib/circles_localization.dart @@ -0,0 +1,621 @@ +import "package:circles_app/presentation/common/platform_alerts.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter_localizations/flutter_localizations.dart"; + +import "cupertinoLocalizationDelegate.dart"; + +final localizationsDelegates = [ + const CirclesLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + const FallbackCupertinoLocalisationsDelegate() +]; + +class CirclesLocalizations { + CirclesLocalizations(this.locale); + + final Locale locale; + + static CirclesLocalizations of(BuildContext context) { + return Localizations.of( + context, CirclesLocalizations); + } + + static final Map> _localizedValues = { + "en": { + "log_out": "Log out", + "log_in": "Log in", + "hello_name": "Hello {name}!", + "login_fail_user_not_found": "Login failed. No such user exists.", + "login_fail": "Login failed. Code: '{code}'", + "calendar_title": "Calendar", + "calendar_text_today": "Today:", + "calendar_text_all_day": "all day", + "channel_join": "Join Topic", + "channel_join_message": "Join the topic to send messages.", + "channel_joined_postfix_message": "joined channel", + "channel_joined_event_postfix_message": "joined event", + "channel_left_postfix_message": "left channel", + "channel_left_event_postfix_message": "left event", + "channel_leave_alert_title": "Leave channel", + "channel_leave_alert_message": "Do you really want to leave?", + "channel_create_title": "Create Topic", + "channel_create_button": "Create", + "channel_input_hint": "Message", + "channel_input_send": "Send", + "channel_title": "Topics", + "channel_list_pending": "UNJOINED", + "channel_list_joined": "JOINED", + "channel_list_previous": "PREVIOUS", + "channel_list_upcoming": "UPCOMING", + "channel_list_events": "Events", + "channel_list_unread": "Ungelesen", + "channel_form_topic_name": "Enter topic name", + "channel_form_topic_description": "Enter purpose (optional)", + "channel_form_topic_description_helper": + "Briefly describe the purpose of this channel", + "channel_form_create_topic": "Create Topic", + "channel_form_create_topic_empty_error": "Maximum 30 characters", + "channel_form_create_topic_exists_error": "This topic already exists.", + "channel_form_create_topic_public": "Open", + "channel_form_create_topic_private": "Private", + "channel_form_create_topic_public_helper": + "Everyone in this group can join", + "channel_form_create_topic_private_helper": + "Invite group members to this topic:", + "channel_form_topic_exists": + "There already exists a topic with this name.", + "channel_form_select_members_error": "Please select at least one member.", + "channel_form_select_members": + "Select the members who can join the topic:", + "channel_invite_title": "Invite more members", + "channel_invite_button": "Add more members", + "channel_rsvp_yes_postfix": "is going", + "channel_rsvp_no_postfix": "is not going", + "channel_rsvp_maybe_postfix": "is maybe going", + "attach_modal_title": "Attach Picture", + "attach_modal_subtitle": "Select from", + "attach_modal_camera": "Camera", + "attach_modal_gallery": "Photo Library", + "attach_error": "Error attaching picture", + "generic_soon_alert_title": "We are working on it", + "generic_soon_alert_message": + "This functionality will be available soon.", + "generic_yes": "Yes", + "generic_cancel": "Cancel", + "generic_next": "Next", + "generic_ok": "OK", + "generic_back": "Back", + "generic_at": "at", + "generic_you": "You", + "generic_save": "Save", + "generic_edit": "Edit", + "generic_delete": "Delete", + "generic_invite": "Invite", + "event_edit_title": "Edit Event", + "event_form_name": "Event name", + "event_form_date": "Select date", + "event_form_date_empty": "Missing date", + "event_form_date_past": "Past date", + "event_form_time": "Select time (optional)", + "event_form_venue": "Venue (optional)", + "event_form_venue_helper": "Help your guests to find the venue", + "event_form_purpose": "Enter event purpose (optional)", + "event_form_purpose_helper": "Briefly describe the purpose of this event", + "event_form_title": "Create Event", + "event_host": "Host", + "event_guest_count": "Guests ({count})", + "event_details": "Event Details", + "event_guests": "Guests", + "event_leave": "Leave Event", + "event_rsvp_yes_dialog": "You're going!", + "event_rsvp_maybe_dialog": "You replied maybe", + "event_rsvp_no_dialog": "You've declined!", + "event_rsvp_yes": "Going", + "event_rsvp_maybe": "Maybe", + "event_rsvp_no": "Not Going", + "event_rsvp_user": "Your response:", + "event_rsvp_change": "Change", + "event_private": "Private Event", + "generic_create": "Create", + "settings_title": "Settings", + "topic_details": "Topic Details", + "topic_members": "Members", + "topic_leave": "Leave Topic", + "topic_private": "Private Topic", + "topic_members_count": "Members {count}", + "platform_alert_access_title": "Please grant Timy access", + "platform_alert_access_body": "For this feature to work the app need access to your {RESOURCE}", + "platform_alert_access_resource_camera": "camera", + "platform_alert_access_resource_photos": "photos", + "privacy_button": "Privacy policy", + "privacy_link": "https://www.iubenda.com/", + "user_send_direct_message": "Send direct message", + "user_edit_name_label": "Your name", + "user_edit_name_helper": "Maximum 30 characters", + "user_edit_name_error": "Name cannot be empty", + "user_edit_status_label": "Your status", + "user_edit_status_helper": "Maximum 200 characters", + "user_deleted": "[deleted]", + }, + "de": { + "log_out": "Abmelden", + "log_in": "Anmelden", + "hello_name": "Hallo {name}!", + "login_fail_user_not_found": + "Login fehlgeschlagen. Wir konnten keinen Nutzer finden.", + "login_fail": "Login fehlgeschlagen. Fehlercode: '{code}'", + "calendar_title": "Kalender", + "calendar_text_today": "Heute:", + "calendar_text_all_day": "ganztägig", + "channel_join": "Thema beitreten", + "channel_join_message": "Tritt dem Thema bei um Nachrichten zu senden.", + "channel_joined_postfix_message": "ist dem Thema beigetreten", + "channel_joined_event_postfix_message": "ist dem Event beigetreten", + "channel_left_postfix_message": "hat das Thema verlassen", + "channel_left_event_postfix_message": "hat das Event verlassen", + "channel_leave_alert_title": "Thema verlassen", + "channel_leave_alert_message": + "Möchtest du das Thema wirklich verlassen?", + "channel_create_title": "Thema erstellen", + "channel_create_button": "Erstellen", + "channel_input_hint": "Nachricht", + "channel_input_send": "Senden", + "channel_title": "Themen", + "channel_list_pending": "OFFEN", + "channel_list_joined": "BEIGETRETEN", + "channel_list_previous": "VERGANGEN", + "channel_list_upcoming": "BEVORSTEHEND", + "channel_list_events": "Events", + "channel_list_unread": "Ungelesen", + "channel_form_topic_name": "Thema", + "channel_form_topic_description": "Beschreibung", + "channel_form_topic_description_helper": + "Beschreibe den Zweck des Themas", + "channel_form_create_topic": "Thema erstellen", + "channel_form_create_topic_empty_error": "Maximal 30 Zeichen", + "channel_form_create_topic_exists_error": "Das Thema gibt es bereits.", + "channel_form_create_topic_public": "Öffentlich", + "channel_form_create_topic_private": "Privat", + "channel_form_create_topic_public_helper": + "Jeder in deiner Gruppe kann das Thema sehen", + "channel_form_create_topic_private_helper": "Thema ist privat", + "channel_form_topic_exists": + "Es gibt bereits ein Thema mit diesem Namen.", + "channel_form_select_members_error": + "Bitte wähle mindestens ein Mitglied aus.", + "channel_form_select_members": "Wähle Mitglieder für dein Thema aus:", + "channel_invite_title": "Mitglieder einladen", + "channel_invite_button": "Mitglieder hinzufügen", + "channel_rsvp_yes_postfix": "nimmt teil", + "channel_rsvp_no_postfix": "nimmt nicht teil", + "channel_rsvp_maybe_postfix": "nimmt vielleicht teil", + "attach_modal_title": "Bild anhängen", + "attach_modal_subtitle": "Auswhählen von", + "attach_modal_camera": "Kamera", + "attach_modal_gallery": "Fotogalerie", + "attach_error": "Fehler beim Anhängen des Bildes", + "generic_soon_alert_title": "Wir arbeiten daran", + "generic_soon_alert_message": "Diese Funktion wird bald verfügbar sein.", + "generic_at": "at", + "generic_back": "Zurück", + "generic_cancel": "Abbrechen", + "generic_delete": "Löschen", + "generic_edit": "Bearbeiten", + "generic_save": "Speichern", + "generic_invite": "Einladen", + "generic_next": "Weiter", + "generic_ok": "OK", + "generic_yes": "Ja", + "generic_you": "Du", + "event_edit_title": "Event bearbeiten", + "event_form_name": "Eventname", + "event_form_date": "Datum auswählen", + "event_form_date_empty": "Fehlendes Datum", + "event_form_date_past": "Vergangenes Datum", + "event_form_time": "Zeit auswählen (optional)", + "event_form_venue": "Ort (optional)", + "event_form_venue_helper": "Helfe deinen Gästen den Ort zu finden", + "event_form_purpose": "Eventbeschreibung (optional)", + "event_form_purpose_helper": "Beschreibe den Zweck dieses Events", + "event_form_title": "Event erstellen", + "event_host": "Gastgeber", + "event_guest_count": "Gäste ({count})", + "event_details": "Eventdetails", + "event_guests": "Gäste", + "event_leave": "Event verlassen", + "event_rsvp_yes_dialog": "Du hast zugesagt!", + "event_rsvp_maybe_dialog": "Du gehst vielleicht hin!", + "event_rsvp_no_dialog": "Du hast abgesagt!", + "event_rsvp_yes": "Zusagen", + "event_rsvp_maybe": "Vielleicht", + "event_rsvp_no": "Absagen", + "event_private": "Privates Event", + "generic_create": "Erstellen", + "settings_title": "Einstellungen", + "topic_details": "Details", + "topic_members": "Mitglieder", + "topic_leave": "Thema verlassen", + "topic_private": "Privates Thema", + "topic_members_count": "Mitglieder {count}", + "platform_alert_access_title": "Timy benötig Zugriff", + "platform_alert_access_body": "Damit du diese Funktion nutzen kannst benötigen wir Zugriff auf deine {RESOURCE}", + "platform_alert_access_resource_camera": "Kamera", + "platform_alert_access_resource_photos": "Fotos", + "privacy_button": "Datenschutzbestimmungen", + "privacy_link": "https://www.iubenda.com/", + "user_edit_name_label": "Dein Name", + "user_edit_name_helper": "Maximal 30 Zeichen", + "user_edit_name_error": "Name kann nicht leer sein", + "user_edit_status_label": "Dein Status", + "user_edit_status_helper": "Maximal 200 Zeichen", + "user_send_direct_message": "Direktnachricht senden", + "user_deleted": "[gelöscht]", + } + }; + + /// This method returns the localized value of the passed id + /// it defaults to english if the locale is missing + String _localizedValue(String id) => + _localizedValues[locale.languageCode][id] ?? _localizedValues["en"][id]; + + /// Calendar + + String get calendarTitle => _localizedValue("calendar_title"); + + String get calendarStringToday => _localizedValue("calendar_text_today"); + + String get calendarStringAllDay => _localizedValue("calendar_text_all_day"); + + // Channel + String get channelJoinMessage { + return _localizedValue("channel_join_message"); + } + + String get channelLeaveAlertTitle { + return _localizedValue("channel_leave_alert_title"); + } + + String get channelLeaveAlertMessage { + return _localizedValue("channel_leave_alert_message"); + } + + String get channelJoin { + return _localizedValue("channel_join"); + } + + String get channelCreateTitle { + return _localizedValue("channel_create_title"); + } + + String get channelCreateButton { + return _localizedValue("channel_create_button"); + } + + String get channelTitle { + return _localizedValue("channel_title"); + } + + String get channelListPending { + return _localizedValue("channel_list_pending"); + } + + String get channelListJoined { + return _localizedValue("channel_list_joined"); + } + + String get channelListPrevious { + return _localizedValue("channel_list_previous"); + } + + String get channelListUpcoming { + return _localizedValue("channel_list_upcoming"); + } + + String get channelListEvents { + return _localizedValue("channel_list_events"); + } + + String get channelListUnread { + return _localizedValue("channel_list_unread"); + } + + String get channelFormTopicName { + return _localizedValue("channel_form_topic_name"); + } + + String get channelFormTopicDescription { + return _localizedValue("channel_form_topic_description"); + } + + String get channelFormTopicDescriptionHelper { + return _localizedValue("channel_form_topic_description_helper"); + } + + String get channelFormCreateTopic { + return _localizedValue("channel_form_create_topic"); + } + + String get channelFormCreateTopicEmptyError { + return _localizedValue("channel_form_create_topic_empty_error"); + } + + String get channelFormCreateTopicExistsError { + return _localizedValue("channel_form_create_topic_exists_error"); + } + + String get channelFormCreateTopicPrivate { + return _localizedValue("channel_form_create_topic_private"); + } + + String get channelFormCreateTopicPublic { + return _localizedValue("channel_form_create_topic_public"); + } + + String get channelFormCreateTopicPrivateHelper { + return _localizedValue("channel_form_create_topic_private_helper"); + } + + String get channelFormCreateTopicPublicHelper { + return _localizedValue("channel_form_create_topic_public_helper"); + } + + String get channelFormTopicExists { + return _localizedValue("channel_form_topic_exists"); + } + + String get channelFormSelectMembersError { + return _localizedValue("channel_form_select_members_error"); + } + + String get channelFormSelectMembers { + return _localizedValue("channel_form_select_members"); + } + + String get channelInputHint { + return _localizedValue("channel_input_hint"); + } + + String get channelInputSend { + return _localizedValue("channel_input_send"); + } + + String get channelInviteTitle => + _localizedValues[locale.languageCode]["channel_invite_title"]; + + String get invite => _localizedValues[locale.languageCode]["generic_invite"]; + + get channelInviteButton => + _localizedValues[locale.languageCode]["channel_invite_button"]; + + // Generic + String get genericSoonAlertTitle { + return _localizedValue("generic_soon_alert_title"); + } + + String get genericSoonAlertMessage { + return _localizedValue("generic_soon_alert_message"); + } + + String get yes { + return _localizedValue("generic_yes"); + } + + String get cancel { + return _localizedValue("generic_cancel"); + } + + String get next { + return _localizedValue("generic_next"); + } + + String get ok { + return _localizedValue("generic_ok"); + } + + String get back { + return _localizedValue("generic_back"); + } + + String get at => _localizedValue("generic_at"); + + String get you => _localizedValue("generic_you"); + + String get save => _localizedValue("generic_save"); + + String get edit => _localizedValue("generic_edit"); + + String get delete => _localizedValue("generic_delete"); + + String get sendDirectMessage => _localizedValue("user_send_direct_message"); + + String get userEditNameLabel => _localizedValue("user_edit_name_label"); + + String get userEditNameHelper => _localizedValue("user_edit_name_helper"); + + String get userEditNameError => _localizedValue("user_edit_name_error"); + + String get userEditStatusLabel => _localizedValue("user_edit_status_label"); + + String get userEditStatusHelper => _localizedValue("user_edit_status_helper"); + + String get deletedUser => _localizedValue("user_deleted"); + + // Auth + String get logIn { + return _localizedValue("log_in"); + } + + String get logOut { + return _localizedValue("log_out"); + } + + String hello(String name) { + return _localizedValue("hello_name").replaceAll("{name}", name); + } + + String authErrorMessage(String code) { + switch (code) { + case "ERROR_USER_NOT_FOUND": + return _localizedValue("login_fail_user_not_found"); + default: + return _localizedValue("login_fail").replaceAll("{code}", code); + } + } + + String get attachModalTitle => _localizedValue("attach_modal_title"); + + String get attachModalSubtitle => _localizedValue("attach_modal_subtitle"); + + String get attachModalCamera => _localizedValue("attach_modal_camera"); + + String get attachModalGallery => _localizedValue("attach_modal_gallery"); + + String get attachError => _localizedValue("attach_error"); + + String get eventEditTitle => _localizedValue("event_edit_title"); + + String get eventFormName => _localizedValue("event_form_name"); + + String get eventFormDate => _localizedValue("event_form_date"); + + String get eventFormDateEmpty => _localizedValue("event_form_date_empty"); + + String get eventFormDatePast => _localizedValue("event_form_date_past"); + + String get eventFormTime => _localizedValue("event_form_time"); + + String get eventFormVenue => _localizedValue("event_form_venue"); + + String get eventFormVenueHelper => _localizedValue("event_form_venue_helper"); + + String get eventFormPurpose => _localizedValue("event_form_purpose"); + + String get eventFormPurposeHelper => + _localizedValue("event_form_purpose_helper"); + + String get eventCreateTitle => _localizedValue("event_form_title"); + + String eventGuestCount(String count) { + return _localizedValue("event_guest_count").replaceAll("{count}", count); + } + + String rsvpSystemMessage(String rsvpMessage) { + return rsvpMessage + .replaceAll("{RSVP_YES}", _channelRSVPYesPostfix) + .replaceAll("{RSVP_MAYBE}", _channelRSVPMaybePostfix) + .replaceAll("{RSVP_NO}", _channelRSVPNoPostfix); + } + + String get _channelRSVPYesPostfix => + _localizedValue("channel_rsvp_yes_postfix"); + + String get _channelRSVPNoPostfix => + _localizedValue("channel_rsvp_no_postfix"); + + String get _channelRSVPMaybePostfix => + _localizedValue("channel_rsvp_maybe_postfix"); + + String channelSystemMessage(String joinedChannelMessage) { + return joinedChannelMessage + .replaceAll("{JOINED_CHANNEL}", _joinChannelPostfix) + .replaceAll("{JOINED_EVENT}", _joinEventPostfix) + .replaceAll("{LEFT_EVENT}", _leftEventPostfix) + .replaceAll("{LEFT_CHANNEL}", _leftChannelPostfix); + } + + String get _joinChannelPostfix => + _localizedValue("channel_joined_postfix_message"); + + String get _joinEventPostfix => + _localizedValue("channel_joined_event_postfix_message"); + + String get _leftChannelPostfix => + _localizedValue("channel_left_postfix_message"); + + String get _leftEventPostfix => + _localizedValue("channel_left_event_postfix_message"); + + String get eventHost => _localizedValue("event_host"); + + String get eventDetails => _localizedValue("event_details"); + + String get eventGuests => _localizedValue("event_guests"); + + String get eventLeave => _localizedValue("event_leave"); + + String get eventRsvpDialogYes => _localizedValue("event_rsvp_yes_dialog"); + + String get eventRsvpDialogMaybe => _localizedValue("event_rsvp_maybe_dialog"); + + String get eventRsvpDialogNo => _localizedValue("event_rsvp_no_dialog"); + + get eventRsvpYes => _localizedValue("event_rsvp_yes"); + + get eventRsvpMaybe => _localizedValue("event_rsvp_maybe"); + + get eventRsvpNo => _localizedValue("event_rsvp_no"); + + get eventRsvpUser => _localizedValue("event_rsvp_user"); + + get eventRsvpChange => _localizedValue("event_rsvp_change"); + + get eventPrivate => _localizedValue("event_private"); + + String get create => _localizedValue("generic_create"); + + String get privacyButton => _localizedValue("privacy_button"); + + String get privacyLink => _localizedValue("privacy_link"); + + get settingsTitle => _localizedValue("settings_title"); + + String get topicDetails { + return _localizedValue("topic_details"); + } + + String get topicMembers { + return _localizedValue("topic_members"); + } + + String get topicLeave { + return _localizedValue("topic_leave"); + } + + String get topicPrivate { + return _localizedValue("topic_private"); + } + + String topicMembersCount(String count) { + return _localizedValue("topic_members_count").replaceAll("{count}", count); + } + + /// Platform + String get platformAlertAccessTitle => _localizedValue("platform_alert_access_title"); + + String platformAlertAccessBody(AccessResourceType type) { + final typeString = type == AccessResourceType.CAMERA ? platformAlertAccessResourceCamera : platformAlertAccessResourcePhotos; + return _localizedValue("platform_alert_access_body").replaceAll("{RESOURCE}", typeString); + } + + String get platformAlertAccessResourceCamera => _localizedValue("platform_alert_access_resource_camera"); + String get platformAlertAccessResourcePhotos => _localizedValue("platform_alert_access_resource_photos"); +} + +class CirclesLocalizationsDelegate + extends LocalizationsDelegate { + const CirclesLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => ["en", "de"].contains(locale.languageCode); + + @override + Future load(Locale locale) { + return SynchronousFuture( + CirclesLocalizations(locale)); + } + + @override + bool shouldReload(CirclesLocalizationsDelegate old) => false; +} diff --git a/lib/cupertinoLocalizationDelegate.dart b/lib/cupertinoLocalizationDelegate.dart new file mode 100644 index 0000000..ca79fff --- /dev/null +++ b/lib/cupertinoLocalizationDelegate.dart @@ -0,0 +1,19 @@ +import "package:flutter/cupertino.dart"; + +// This delegate fixes an issue which caused alerts on iOS to fail. +// https://github.com/flutter/flutter/issues/23047 + +class FallbackCupertinoLocalisationsDelegate + extends LocalizationsDelegate { + const FallbackCupertinoLocalisationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => + DefaultCupertinoLocalizations.load(locale); + + @override + bool shouldReload(FallbackCupertinoLocalisationsDelegate old) => false; +} diff --git a/lib/data/calendar_repository.dart b/lib/data/calendar_repository.dart new file mode 100644 index 0000000..4ab1270 --- /dev/null +++ b/lib/data/calendar_repository.dart @@ -0,0 +1,41 @@ +import "package:circles_app/data/firestore_paths.dart"; +import "package:circles_app/model/calendar_entry.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +class CalendarRepository { + final Firestore _firestore; + const CalendarRepository(this._firestore); + + Future> getCalendarEntries(String userId) async { + final snapshot = await _firestore + .collection(FirestorePaths.PATH_CALENDAR) + .where(_Constants.USERS, arrayContains: userId) + .orderBy(_Constants.EVENTDATE, descending: false) + .limit(100) + .getDocuments(); + + return snapshot.documents.map((d) => _fromDoc(d)).toList(); + } + + static CalendarEntry _fromDoc(DocumentSnapshot doc) { + return CalendarEntry((calendarEntry) => calendarEntry + ..channelId = doc[_Constants.CHANNELID] + ..groupId = doc[_Constants.GROUPID] + ..groupName = doc[_Constants.GROUPNAME] + ..channelName = doc[_Constants.CHANNELNAME] + ..eventDate = doc[_Constants.EVENTDATE].toDate() + ..hasStartTime = doc[_Constants.HASSTARTTIME] != null + ? doc[_Constants.HASSTARTTIME] + : false); + } +} + +class _Constants { + static const String CHANNELID = "channel_id"; + static const String GROUPID = "group_id"; + static const String GROUPNAME = "group_name"; + static const String CHANNELNAME = "channel_name"; + static const String EVENTDATE = "event_date"; + static const String HASSTARTTIME = "has_start_time"; + static const String USERS = "users"; +} diff --git a/lib/data/channel_repository.dart b/lib/data/channel_repository.dart new file mode 100644 index 0000000..2a91a91 --- /dev/null +++ b/lib/data/channel_repository.dart @@ -0,0 +1,343 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/util/logger.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:flutter/widgets.dart"; + +import "firestore_paths.dart"; + +class ChannelExistsError extends Error {} + +class ChannelRepository { + static const String NAME = "name"; + static const String VISIBILITY = "visibility"; + static const String DESCRIPTION = "description"; + static const String USERS = "users"; + static const String USERID = "uid"; + static const String HASUPDATES = "hasUpdates"; + static const String AUTHORID = "authorId"; + static const String TYPE = "type"; + static const String VENUE = "venue"; + static const String START_DATE = "start_date"; + static const String HAS_START_TIME = "has_start_time"; + static const String RSVP_FIELD = "rsvp"; + // The offset is used on backend side to send out notifications + // with the correct time since it's stored in utc and we need + // the offset of the event creator. + static const String TIMEZONE_SECONDS_OFFSET = "timezone_seconds_offset"; + static const String INVITATION = "invitation"; + static const String CHANNELNAME = "channel_name"; + static const String INVITINGUSER = "inviting_user"; + static const String GROUPNAME = "group_name"; + static const String INVITEDMEMBERS = "invited_members"; + static const String METADATA = "metadata"; + + final Firestore _firestore; + + const ChannelRepository(this._firestore); + + Stream> getChannelsStream(String groupId, String userId) { + return _firestore + .collection(FirestorePaths.channelsPath(groupId)) + .snapshots() + .asyncMap((channelDocuments) { + return Future.wait(channelDocuments.documents.map((document) async { + return await documentToChannel(groupId, userId, document); + })); + }); + } + + Stream getStreamForChannel( + String groupId, String channelId, String userId) { + return _firestore + .document(FirestorePaths.channelPath(groupId, channelId)) + .snapshots() + .asyncMap((document) async { + return await documentToChannel(groupId, userId, document); + }); + } + + Future markChannelRead( + String groupId, String channelId, String userId) async { + final channelUsersPath = + FirestorePaths.channelUsersPath(groupId, channelId); + + // We're removing the indicator for the group then the channel. + try { + await _firestore + .collection(FirestorePaths.PATH_USERS) + .document(userId) + .updateData({ + groupId: FieldValue.arrayRemove([channelId]) + }); + + return await _firestore + .collection(channelUsersPath) + .document(userId) + .updateData({HASUPDATES: false}); + } catch (e) { + Logger.e( + "Couldn't mark read status for user: $userId ", + e: e, + s: StackTrace.current, + ); + + return Future.error("Error marking channel as read"); + } + } + + Future leaveChannel( + String groupId, + String channelId, + String userId, + ) async { + final channelUsersPath = + FirestorePaths.channelUsersPath(groupId, channelId); + await _firestore.collection(channelUsersPath).document(userId).delete(); + } + + Future joinChannel( + String groupId, + Channel channel, + String userId, + ) async { + final channelUser = ChannelUser((c) => c + ..id = userId + ..rsvp = RSVP.UNSET); + final channelUsersPath = + FirestorePaths.channelUsersPath(groupId, channel.id); + final data = toChannelUserMap(channelUser); + + await _firestore + .collection(channelUsersPath) + .document(userId) + .setData(data); + + return channel.rebuild((c) => c..users.add(channelUser)); + } + + Future inviteToChannel({ + String groupId, + String groupName, + Channel channel, + List members, + String invitingUsername, + }) async { + final channelUsersPath = + FirestorePaths.channelUsersPath(groupId, channel.id); + + final users = members.map((userId) { + return ChannelUser((c) => c + ..id = userId + ..rsvp = RSVP.UNSET); + }); + + for (final user in users) { + final data = toChannelUserInviteMap( + user: user, + channel: channel, + invitingUsername: invitingUsername, + groupName: groupName); + await _firestore + .collection(channelUsersPath) + .document(user.id) + .setData(data); + } + + return channel.rebuild((c) => c..users.addAll(users)); + } + + Future createChannel( + String groupId, Channel channel, List members, String authorUid) async { + final data = toMap(channel, members); + final snapshot = await _firestore + .collection(FirestorePaths.channelsPath(groupId)) + .getDocuments(); + + final channelExists = snapshot.documents + .any((doc) => doc[NAME].toLowerCase() == channel.name.toLowerCase()); + if (channelExists) { + return Future.error(ChannelExistsError()); + } + + final reference = await _firestore + .collection(FirestorePaths.channelsPath(groupId)) + .add(data); + final doc = await reference.get(); + + final users = members.map((userId) { + return ChannelUser((c) => c + ..id = userId + ..rsvp = userId == authorUid ? RSVP.YES : RSVP.UNSET); + }).toList(); + + return fromDocWithUsers(doc: doc, users: BuiltList(users)); + } + + Future documentToChannel( + String groupId, + String userId, + DocumentSnapshot document, + ) async { + final snapshot = await document.reference.collection(USERS).getDocuments(); + final usersDocuments = snapshot.documents; + + final users = usersDocuments.map((data) => channelUserFromDoc(data)); + + final userDocument = usersDocuments + .firstWhere((doc) => doc.documentID == userId, orElse: () => null); + + final hasUpdates = + (userDocument == null || userDocument.data[HASUPDATES] == null) + ? false + : userDocument.data[HASUPDATES]; + + return fromDocWithUsers( + doc: document, + users: BuiltList.of(users), + hasUpdates: hasUpdates); + } + + ChannelUser channelUserFromDoc(DocumentSnapshot data) { + return ChannelUser((c) => c + ..id = data[USERID] + ..rsvp = RSVPHelper.valueOf(data[RSVP_FIELD])); + } + + Future updateChannel(String groupId, Channel channel) async { + await _firestore + .document(FirestorePaths.channelPath(groupId, channel.id)) + .updateData({ + DESCRIPTION: channel.description, + VENUE: channel.venue, + START_DATE: _formatToTimestamp(channel), + HAS_START_TIME: channel.hasStartTime, + }); + } + + Future getChannel( + String groupId, + String channelId, + String userId, + ) async { + final document = await _firestore + .document(FirestorePaths.channelPath(groupId, channelId)) + .get(); + return await documentToChannel(groupId, userId, document); + } + + Future rsvp( + String groupId, + String channelId, + String userId, + RSVP rsvp, + ) async { + final channelUsersPath = + FirestorePaths.channelUsersPath(groupId, channelId); + + await _firestore + .collection(channelUsersPath) + .document(userId) + .updateData({RSVP_FIELD: RSVPHelper.stringOf(rsvp)}); + } + + static toChannelUserMap(ChannelUser user, {bool isInvite = false}) { + return { + USERID: user.id, + RSVP_FIELD: RSVPHelper.stringOf(user.rsvp), + INVITATION: isInvite, + }; + } + + static toChannelUserInviteMap({ + ChannelUser user, + Channel channel, + String invitingUsername, + String groupName, + }) { + final Map channelUserInviteMap = + toChannelUserMap(user, isInvite: true); + channelUserInviteMap.addAll({ + METADATA: { + CHANNELNAME: channel.name, + TYPE: ChannelTypeHelper.stringOf(channel.type), + VISIBILITY: ChannelVisibilityHelper.stringOf(channel.visibility), + INVITINGUSER: invitingUsername, + GROUPNAME: groupName, + } + }); + + return channelUserInviteMap; + } + + static Map toMap(Channel channel, List members) { + return { + NAME: channel.name, + DESCRIPTION: channel.description, + VISIBILITY: ChannelVisibilityHelper.stringOf(channel.visibility), + AUTHORID: channel.authorId ?? "", + VENUE: channel.venue, + START_DATE: _formatToTimestamp(channel), + TIMEZONE_SECONDS_OFFSET: channel.startDate != null + ? channel.startDate.timeZoneOffset.inSeconds + : null, + HAS_START_TIME: channel.hasStartTime, + TYPE: ChannelTypeHelper.stringOf(channel.type), + INVITEDMEMBERS: members, + }; + } + + static Timestamp _formatToTimestamp(Channel channel) { + if (channel.startDate == null) { + return null; + } + + return Timestamp.fromDate(channel.startDate); + } + + static Channel fromDocWithUsers({ + @required DocumentSnapshot doc, + @required BuiltList users, + bool hasUpdates = false, + }) { + final docType = ChannelTypeHelper.valueOf(doc[TYPE]) ?? ChannelType.TOPIC; + + DateTime date; + if (docType == ChannelType.EVENT) { + try { + date = doc[START_DATE].toDate(); + } catch (e) { + date = null; + } + + // Fallback parsing old dates which have previously been stored as strings. + if (date == null) { + date = _parseStringDate(doc); + } + } + + return Channel((c) => c + ..id = doc.documentID + ..name = doc[NAME] + ..description = doc[DESCRIPTION] + ..visibility = ChannelVisibilityHelper.valueOf(doc[VISIBILITY]) ?? + ChannelVisibility.OPEN + ..hasUpdates = hasUpdates + ..users = users.toBuilder() + ..type = docType + ..venue = doc[VENUE] + ..authorId = doc[AUTHORID] + ..startDate = date + ..hasStartTime = doc[HAS_START_TIME]); + } + + static DateTime _parseStringDate(doc) { + try { + return DateTime.parse(doc[START_DATE]).toLocal(); + } catch (error) { + return null; + } + } +} diff --git a/lib/data/file_repository.dart b/lib/data/file_repository.dart new file mode 100644 index 0000000..6217b3f --- /dev/null +++ b/lib/data/file_repository.dart @@ -0,0 +1,17 @@ +import "dart:io"; + +import "package:firebase_storage/firebase_storage.dart"; + +class FileRepository { + final FirebaseStorage _firebaseStorage; + + FileRepository(this._firebaseStorage); + + Future uploadFile(File file) async { + final String fileName = DateTime.now().millisecondsSinceEpoch.toString(); + final StorageReference reference = _firebaseStorage.ref().child(fileName); + final StorageUploadTask uploadTask = reference.putFile(file); + final StorageTaskSnapshot storageTaskSnapshot = await uploadTask.onComplete; + return storageTaskSnapshot.ref.getDownloadURL(); + } +} diff --git a/lib/data/firestore_paths.dart b/lib/data/firestore_paths.dart new file mode 100644 index 0000000..5f7fb0b --- /dev/null +++ b/lib/data/firestore_paths.dart @@ -0,0 +1,39 @@ +class FirestorePaths { + static const PATH_GROUPS = "groups"; + static const PATH_CHANNELS = "channels"; + static const PATH_MESSAGES = "messages"; + static const PATH_USERS = "users"; + static const PATH_CALENDAR = "calendar"; + + static String groupPath(String groupId) { + return "$PATH_GROUPS/$groupId"; + } + + static String channelsPath(String groupId) { + return "$PATH_GROUPS/$groupId/$PATH_CHANNELS"; + } + + static String channelPath(String groupId, String channelId) { + return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId"; + } + + static String channelUsersPath(String groupId, String channelId) { + return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_USERS"; + } + + static String messagesPath(String groupId, String channelId) { + return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_MESSAGES"; + } + + static String messagePath( + String groupId, + String channelId, + String messageId, + ) { + return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_MESSAGES/$messageId"; + } + + static String userPath(String userId) { + return "$PATH_USERS/$userId"; + } +} diff --git a/lib/data/group_repository.dart b/lib/data/group_repository.dart new file mode 100644 index 0000000..30e2fa7 --- /dev/null +++ b/lib/data/group_repository.dart @@ -0,0 +1,40 @@ +import "dart:core"; +import "package:circles_app/data/firestore_paths.dart"; +import "package:circles_app/model/group.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +class GroupRepository { + static const String NAME = "name"; + static const String ABBREVIATION = "abbreviation"; + static const String IMAGE = "image"; + static const String COLOR = "color"; + static const String MEMBERS = "members"; + + final Firestore firestore; + + const GroupRepository(this.firestore); + + Future getGroup(String id) async { + final doc = await firestore.document(FirestorePaths.groupPath(id)).get(); + return fromDoc(doc); + } + + Stream> getGroupStream(userId) { + return firestore + .collection(FirestorePaths.PATH_GROUPS) + .where(MEMBERS, arrayContains: userId) + .snapshots() + .map((snapShot) { + return snapShot.documents.map((doc) => fromDoc(doc)).toList(); + }); + } + + static Group fromDoc(DocumentSnapshot doc) { + return Group((c) => c + ..id = doc.documentID + ..name = doc[NAME] + ..image = doc[IMAGE] + ..abbreviation = doc[ABBREVIATION] + ..hexColor = doc[COLOR]); + } +} diff --git a/lib/data/message_repository.dart b/lib/data/message_repository.dart new file mode 100644 index 0000000..b2d5bc8 --- /dev/null +++ b/lib/data/message_repository.dart @@ -0,0 +1,205 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/data/firestore_paths.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:flutter/foundation.dart"; + +class MessageRepository { + static const BODY = "body"; + static const AUTHOR = "author"; + static const REACTION = "reaction"; + static const TYPE = "type"; + static const TIMESTAMP = "timestamp"; + static const EMOJI = "emoji"; + static const USER_ID = "user_id"; + static const USER_NAME = "user_name"; + static const MEDIA = "media"; + static const MEDIA_STATUS = "media_status"; + static const MEDIA_ASPECT_RATIO = "media_aspect_ratio"; + + final Firestore _firestore; + + MessageRepository(this._firestore); + + Future sendMessage(String groupId, + String channelId, + Message message,) async { + final messagesPath = FirestorePaths.messagesPath(groupId, channelId); + final data = toMap(message); + final reference = await _firestore.collection(messagesPath).add(data); + final doc = await reference.get(); + return fromDoc(doc); + } + + Stream> getMessagesStream(String groupId, + String channelId, + String userId,) { + return _firestore + .collection(FirestorePaths.messagesPath(groupId, channelId)) + .orderBy(TIMESTAMP, descending: true) + .snapshots(includeMetadataChanges: true) + .map((querySnapshot) { + return querySnapshot.documents + .where((documentSnapshot) => + isValidDocument(documentSnapshot, userId)) + .map((documentSnapshot) => fromDoc(documentSnapshot)) + .toList(); + }); + } + + Future addReaction({ + @required String groupId, + @required String channelId, + @required String messageId, + @required Reaction reaction, + }) async { + final path = FirestorePaths.messagePath(groupId, channelId, messageId); + final snapshot = await _firestore.document(path).get(); + final message = fromDoc(snapshot); + // Cannot add reactions to their own message + if (message.authorId == reaction.userId) { + return; + } + final reactions = message.reactions.toBuilder(); + reactions[reaction.userId] = reaction; + await _firestore.document(path).updateData({ + REACTION: _reactionsToMap(reactions.build()), + }); + } + + Future removeReaction({ + @required String groupId, + @required String channelId, + @required String messageId, + @required String userId, + }) async { + final path = FirestorePaths.messagePath(groupId, channelId, messageId); + final snapshot = await _firestore.document(path).get(); + final reaction = fromDoc(snapshot).reactions; + final builder = reaction.toBuilder(); + builder.remove(userId); + return await _firestore + .document(path) + .updateData({REACTION: _reactionsToMap(builder.build())}); + } + + Future deleteMessage(String groupId, String channelId, String messageId) async { + final path = FirestorePaths.messagePath(groupId, channelId, messageId); + return await _firestore + .document(path) + .delete(); + } + + static Message fromDoc(DocumentSnapshot document) { + final messageType = MessageTypeHelper.valueOf(document[TYPE]); + + return Message((m) => + m + ..id = document.documentID + ..body = document[BODY] + ..authorId = messageType == MessageType.SYSTEM ? null : document[AUTHOR] + ..reactions = _parseReactions(document) + ..messageType = messageType + ..media = ListBuilder(document[MEDIA] ?? []) + ..mediaStatus = MediaStatusHelper.valueOf(document[MEDIA_STATUS]) + ..mediaAspectRatio = + double.tryParse(document[MEDIA_ASPECT_RATIO] ?? "1.0") + ..timestamp = DateTime.fromMillisecondsSinceEpoch( + int.tryParse(document[TIMESTAMP]) ?? 0) + ..pending = document.metadata.hasPendingWrites); + } + + static MapBuilder _parseReactions( + DocumentSnapshot document) { + final map = MapBuilder(); + if (document[REACTION] == null) { + return map; + } + for (final key in document[REACTION].keys) { + final value = document[REACTION][key]; + try { + map[key] = _parseReaction(value); + } catch (e) { + // Ignore reactions in old format + } + } + return map; + } + + static Reaction _parseReaction(data) { + return Reaction((r) => + r + ..emoji = data[EMOJI] + ..userId = data[USER_ID] + ..userName = data[USER_NAME] + ..timestamp = DateTime.fromMillisecondsSinceEpoch( + int.tryParse(data[TIMESTAMP]) ?? 0)); + } + + static Map _reactionsToMap( + BuiltMap reactions) { + return reactions + .map((k, v) => + MapEntry(k, { + EMOJI: v.emoji, + USER_ID: v.userId, + USER_NAME: v.userName, + TIMESTAMP: v.timestamp.millisecondsSinceEpoch.toString(), + })) + .toMap(); + } + + static toMap(Message message) { + return { + BODY: message.body, + AUTHOR: message.authorId, + REACTION: _reactionsToMap(message.reactions), + TYPE: MessageTypeHelper.stringOf(message.messageType), + TIMESTAMP: message.timestamp.millisecondsSinceEpoch.toString(), + }; + } + + static bool isValidDocument(DocumentSnapshot documentSnapshot, [String userId = ""]) { + final docType = MessageTypeHelper.valueOf(documentSnapshot[TYPE]); + switch (docType) { + case MessageType.SYSTEM: + return true; + break; + case MessageType.RSVP: + return true; + break; + case MessageType.USER: + return _hasValidAuthor(documentSnapshot); + break; + case MessageType.MEDIA: + return _hasValidAuthor(documentSnapshot) && _isVisibleToUser(documentSnapshot, userId); + break; + default: + return false; + break; + } + } + + static bool _hasValidAuthor(DocumentSnapshot documentSnapshot) { + return documentSnapshot[AUTHOR] != null && + // Legacy messages have a different author payload + documentSnapshot[AUTHOR] is String; + } + + static bool _isVisibleToUser(DocumentSnapshot documentSnapshot, String userId) { + final mediaStatus = MediaStatusHelper.valueOf(documentSnapshot[MEDIA_STATUS]); + final author = documentSnapshot[AUTHOR]; + switch (mediaStatus) { + case MediaStatus.DONE: + return true; + break; + case MediaStatus.UPLOADING: + case MediaStatus.ERROR: + default: + return author == userId; + break; + } + } + +} diff --git a/lib/data/user_repository.dart b/lib/data/user_repository.dart new file mode 100644 index 0000000..8998807 --- /dev/null +++ b/lib/data/user_repository.dart @@ -0,0 +1,174 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/data/firestore_paths.dart"; +import "package:circles_app/model/user.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_auth/firebase_auth.dart"; + +class UserRepository { + static const NAME = "name"; + static const EMAIL = "email"; + static const IMAGE = "image"; + static const UID = "uid"; + static const TOKEN = "token"; + static const LOCALE = "locale"; + static const UPDATEDGROUPS = "updatedGroups"; + static const JOINEDGROUPS = "joinedGroups"; + static const STATUS = "status"; + + final FirebaseAuth _firebaseAuth; + final Firestore _firestore; + + const UserRepository( + this._firebaseAuth, + this._firestore, + ); + + Stream getUserStream(userId) { + return _firestore + .collection(FirestorePaths.PATH_USERS) + .document(userId) + .snapshots() + .map((userSnapshot) { + return fromDoc(userSnapshot); + }); + } + + Stream> getUsersStream(groupId) { + return _firestore + .collection(FirestorePaths.PATH_USERS) + .where(JOINEDGROUPS, arrayContains: groupId) + .snapshots() + .map((userSnapshot) { + final users = userSnapshot.documents.map(fromDoc).toList(); + users.sort((a, b) => a.name.compareTo(b.name)); + return users; + }); + } + + Stream getAuthenticationStateChange() { + return _firebaseAuth.onAuthStateChanged.asyncMap((firebaseUser) { + return _fromFirebaseUser(firebaseUser); + }); + } + + Future signIn(String email, String password) async { + final firebaseUser = await _firebaseAuth.signInWithEmailAndPassword( + email: email, password: password); + + return await _fromFirebaseUser(firebaseUser.user); + } + + Future _fromFirebaseUser(FirebaseUser firebaseUser) async { + if (firebaseUser == null) return Future.value(null); + + final documentReference = + _firestore.document(FirestorePaths.userPath(firebaseUser.uid)); + final snapshot = await documentReference.get(); + + User user; + if (snapshot.data == null) { + user = User((u) => u + ..uid = firebaseUser.uid + ..email = firebaseUser.email + ..name = firebaseUser + .email // Default name will be the email, let user change later + ); + await documentReference.setData(toMap(user)); + } else { + user = fromDoc(snapshot); + } + return user; + } + + Future logOut() async { + await updateUserToken(null); + await _firebaseAuth.signOut(); + } + + Future updateUserToken(String token) async { + final firebaseUser = await _firebaseAuth.currentUser(); + if (firebaseUser != null) { + final documentReference = + _firestore.document(FirestorePaths.userPath(firebaseUser.uid)); + return documentReference.updateData({ + TOKEN: token, + }); + } + } + + /// + /// Allows to update the User, but only the following fields: + /// - name + /// - status + /// - image + /// + Future updateUser(User user) async { + final firebaseUser = await _firebaseAuth.currentUser(); + if (firebaseUser != null) { + final documentReference = + _firestore.document(FirestorePaths.userPath(firebaseUser.uid)); + return documentReference.updateData({ + STATUS: user.status, + NAME: user.name, + IMAGE: user.image, + }); + } + } + + // Sets a users locale on our backend. + // The locale is used to send localized notifications. + Future updateUserLocale(String locale) async { + final firebaseUser = await _firebaseAuth.currentUser(); + if (firebaseUser != null) { + final documentReference = + _firestore.document(FirestorePaths.userPath(firebaseUser.uid)); + return documentReference.updateData({ + LOCALE: locale, + }); + } + } + + static toMap(User user) { + return { + UID: user.uid, + NAME: user.name, + EMAIL: user.email, + }; + } + + static User fromDoc(DocumentSnapshot document) { + return User((u) => u + ..uid = document.documentID + ..name = document[NAME] + ..email = document[EMAIL] + ..image = document[IMAGE] + ..status = document[STATUS] + ..unreadUpdates = MapBuilder(_parseUnreadChannels(document))); + } + + // We keep an updated list of groups in each document which can be accessed via `UPDATEDGROUPS`. + // This list is used to access all updated channels for a group via the `groupId`. + // This method returns a map which represents the updated channels and groups. + // Its values can be used to update the UI for a logged in user accordingly. + static Map _parseUnreadChannels(document) { + final groupsList = document[UPDATEDGROUPS]; + final groupIds = groupsList != null ? List.from(groupsList) : []; + + final unreadChannelsMap = Map>(); + groupIds.forEach((groupId) { + final unreadChannels = document[groupId]; + if (unreadChannels != null) { + unreadChannelsMap[groupId] = BuiltList(unreadChannels); + } + }); + + return unreadChannelsMap; + } + + static User fromMessageAuthor(document) { + return User((u) => u + ..uid = document[UID] + ..name = document[NAME] + ..email = document[EMAIL]); + } +} diff --git a/lib/domain/redux/app_actions.dart b/lib/domain/redux/app_actions.dart new file mode 100644 index 0000000..3a83b92 --- /dev/null +++ b/lib/domain/redux/app_actions.dart @@ -0,0 +1,38 @@ +import "package:circles_app/model/group.dart"; +import "package:meta/meta.dart"; + +/// Actions are payloads of information that send data from your application to +/// your store. They are the only source of information for the store. +/// +/// They are PODOs (Plain Old Dart Objects). +/// +class ConnectToDataSource { + @override + String toString() { + return "ConnectToDataSource{}"; + } +} + +@immutable +class OnGroupsLoaded { + final List groups; + + const OnGroupsLoaded(this.groups); + + @override + String toString() { + return "OnGroupsLoaded{groups: $groups}"; + } +} + +@immutable +class SelectGroup { + final String groupId; + + const SelectGroup(this.groupId); + + @override + String toString() { + return "SelectGroup{groupId: $groupId}"; + } +} diff --git a/lib/domain/redux/app_middleware.dart b/lib/domain/redux/app_middleware.dart new file mode 100644 index 0000000..15ed63d --- /dev/null +++ b/lib/domain/redux/app_middleware.dart @@ -0,0 +1,74 @@ +import "package:circles_app/data/group_repository.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/stream_subscriptions.dart"; +import "package:circles_app/util/logger.dart"; +import "package:redux/redux.dart"; + +/// Middleware is used for a variety of things. +/// Including: +/// - Logging +/// - Async calls (database, network) +/// - Calling to system frameworks +/// +/// These are performed when actions are dispatched to the Store +/// +/// The output of an action can perform another action using the [NextDispatcher] +/// +List> createStoreMiddleware( + GroupRepository groupRepository, +) { + return [ + LoggerMiddleware(), + TypedMiddleware(_selectGroup()), + TypedMiddleware(_loadData(groupRepository)), + ]; +} + +void Function(Store store, SelectGroup action, NextDispatcher next) + _selectGroup() { + return (store, action, next) { + next(action); + + // We're no longer loading all channels of all groups initially + // (but just for the group selected). + // + // This saves us data on one hand and reduces side effects. + // + // Bringing it back will require more attention in terms of subscribing + // to the proper channel updates. + // + // Currently a LoadChannel also causes a necessary subscription to channel + // updates in the channel middleware. + store.dispatch(LoadChannels( + action.groupId, + )); + }; +} + +void Function( + Store store, + ConnectToDataSource action, + NextDispatcher next, +) _loadData( + GroupRepository groupRepository, +) { + return (store, action, next) { + next(action); + + try { + groupsSubscription?.cancel(); + groupsSubscription = + groupRepository.getGroupStream(store.state.user.uid).listen((group) { + store.dispatch(OnGroupsLoaded(group)); + + if (store.state.selectedGroupId == null && group.isNotEmpty) { + store.dispatch(SelectGroup(group.first.id)); + } + }); + } catch (e) { + Logger.e("Failed to subscribe to groups", e: e, s: StackTrace.current); + } + }; +} diff --git a/lib/domain/redux/app_reducer.dart b/lib/domain/redux/app_reducer.dart new file mode 100644 index 0000000..d17f9ae --- /dev/null +++ b/lib/domain/redux/app_reducer.dart @@ -0,0 +1,52 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_reducer.dart"; +import "package:circles_app/domain/redux/calendar/calendar_reducer.dart"; +import "package:circles_app/domain/redux/channel/channel_reducer.dart"; +import "package:circles_app/domain/redux/ui/ui_reducer.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/domain/redux/message/message_reducer.dart"; +import "package:circles_app/domain/redux/push/push_reducer.dart"; +import "package:circles_app/domain/redux/user/user_reducer.dart"; +import "package:circles_app/model/group.dart"; +import "package:redux/redux.dart"; + +/// Reducers specify how the application"s state changes in response to actions +/// sent to the store. +/// +/// Each reducer returns a new [AppState]. +/// +final appReducer = combineReducers([ + TypedReducer(_onGroupsLoaded), + TypedReducer(_onSelectGroup), + ...authReducers, + ...userReducers, + ...calendarReducer, + ...channelReducers, + ...messageReducers, + ...pushReducers, + ...uiReducers, +]); + +AppState _onGroupsLoaded(AppState state, OnGroupsLoaded action) { + if (action.groups.isNotEmpty) { + final selectedGroup = state.selectedGroupId; + final Map groups = Map.fromIterable( + action.groups, + key: (item) => item.id, + value: (item) => item, + ); + return state.rebuild((a) => a + ..selectedGroupId = selectedGroup + ..groups = MapBuilder(groups)); + } else { + return state.rebuild((a) => a + ..channelState = ChannelState.init().toBuilder() + ..groups = MapBuilder()); + } +} + +AppState _onSelectGroup(AppState state, SelectGroup action) { + return state.rebuild((a) => a..selectedGroupId = action.groupId); +} diff --git a/lib/domain/redux/app_selector.dart b/lib/domain/redux/app_selector.dart new file mode 100644 index 0000000..a4fa3f5 --- /dev/null +++ b/lib/domain/redux/app_selector.dart @@ -0,0 +1,9 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; + +Channel getSelectedChannel(AppState state) { + if (state.selectedGroupId == null) return null; + if (state.channelState.selectedChannel == null) return null; + return state.groups[state.selectedGroupId] + .channels[state.channelState.selectedChannel]; +} diff --git a/lib/domain/redux/app_state.dart b/lib/domain/redux/app_state.dart new file mode 100644 index 0000000..acf633e --- /dev/null +++ b/lib/domain/redux/app_state.dart @@ -0,0 +1,66 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/ui/ui_state.dart"; +import "package:circles_app/model/calendar_entry.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/model/in_app_notification.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; + +// ignore: prefer_double_quotes +part 'app_state.g.dart'; + +/// This class holds the whole application state. +/// Which can include: +/// - user calendar +/// - current user profile +/// - joined channels +/// - received messages +/// - etc. +/// +abstract class AppState implements Built { + BuiltList get userCalendar; + + BuiltMap get groups; + + @nullable + String get selectedGroupId; + + @nullable + User get user; + + BuiltList get groupUsers; + + ChannelState get channelState; + + BuiltList get messagesOnScreen; + + @nullable + String get fcmToken; + + @nullable + InAppNotification get inAppNotification; + + UiState get uiState; + + AppState._(); + + factory AppState([void Function(AppStateBuilder) updates]) = _$AppState; + + factory AppState.init() => AppState((a) => a + ..groups = MapBuilder() + ..channelState = ChannelState.init().toBuilder() + ..messagesOnScreen = ListBuilder() + ..groupUsers = ListBuilder() + ..userCalendar = ListBuilder() + ..uiState = UiState().toBuilder()); + + AppState clear() { + // keep the temporal fcm token even when clearing state + // so it can be set again on login. + // + // Add here anything else that also needs to be carried over. + return AppState.init().rebuild((s) => s..fcmToken = fcmToken); + } +} diff --git a/lib/domain/redux/app_state.g.dart b/lib/domain/redux/app_state.g.dart new file mode 100644 index 0000000..f9374ab --- /dev/null +++ b/lib/domain/redux/app_state.g.dart @@ -0,0 +1,263 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_state.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$AppState extends AppState { + @override + final BuiltList userCalendar; + @override + final BuiltMap groups; + @override + final String selectedGroupId; + @override + final User user; + @override + final BuiltList groupUsers; + @override + final ChannelState channelState; + @override + final BuiltList messagesOnScreen; + @override + final String fcmToken; + @override + final InAppNotification inAppNotification; + @override + final UiState uiState; + + factory _$AppState([void Function(AppStateBuilder) updates]) => + (new AppStateBuilder()..update(updates)).build(); + + _$AppState._( + {this.userCalendar, + this.groups, + this.selectedGroupId, + this.user, + this.groupUsers, + this.channelState, + this.messagesOnScreen, + this.fcmToken, + this.inAppNotification, + this.uiState}) + : super._() { + if (userCalendar == null) { + throw new BuiltValueNullFieldError('AppState', 'userCalendar'); + } + if (groups == null) { + throw new BuiltValueNullFieldError('AppState', 'groups'); + } + if (groupUsers == null) { + throw new BuiltValueNullFieldError('AppState', 'groupUsers'); + } + if (channelState == null) { + throw new BuiltValueNullFieldError('AppState', 'channelState'); + } + if (messagesOnScreen == null) { + throw new BuiltValueNullFieldError('AppState', 'messagesOnScreen'); + } + if (uiState == null) { + throw new BuiltValueNullFieldError('AppState', 'uiState'); + } + } + + @override + AppState rebuild(void Function(AppStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + AppStateBuilder toBuilder() => new AppStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is AppState && + userCalendar == other.userCalendar && + groups == other.groups && + selectedGroupId == other.selectedGroupId && + user == other.user && + groupUsers == other.groupUsers && + channelState == other.channelState && + messagesOnScreen == other.messagesOnScreen && + fcmToken == other.fcmToken && + inAppNotification == other.inAppNotification && + uiState == other.uiState; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc($jc(0, userCalendar.hashCode), + groups.hashCode), + selectedGroupId.hashCode), + user.hashCode), + groupUsers.hashCode), + channelState.hashCode), + messagesOnScreen.hashCode), + fcmToken.hashCode), + inAppNotification.hashCode), + uiState.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('AppState') + ..add('userCalendar', userCalendar) + ..add('groups', groups) + ..add('selectedGroupId', selectedGroupId) + ..add('user', user) + ..add('groupUsers', groupUsers) + ..add('channelState', channelState) + ..add('messagesOnScreen', messagesOnScreen) + ..add('fcmToken', fcmToken) + ..add('inAppNotification', inAppNotification) + ..add('uiState', uiState)) + .toString(); + } +} + +class AppStateBuilder implements Builder { + _$AppState _$v; + + ListBuilder _userCalendar; + ListBuilder get userCalendar => + _$this._userCalendar ??= new ListBuilder(); + set userCalendar(ListBuilder userCalendar) => + _$this._userCalendar = userCalendar; + + MapBuilder _groups; + MapBuilder get groups => + _$this._groups ??= new MapBuilder(); + set groups(MapBuilder groups) => _$this._groups = groups; + + String _selectedGroupId; + String get selectedGroupId => _$this._selectedGroupId; + set selectedGroupId(String selectedGroupId) => + _$this._selectedGroupId = selectedGroupId; + + UserBuilder _user; + UserBuilder get user => _$this._user ??= new UserBuilder(); + set user(UserBuilder user) => _$this._user = user; + + ListBuilder _groupUsers; + ListBuilder get groupUsers => + _$this._groupUsers ??= new ListBuilder(); + set groupUsers(ListBuilder groupUsers) => + _$this._groupUsers = groupUsers; + + ChannelStateBuilder _channelState; + ChannelStateBuilder get channelState => + _$this._channelState ??= new ChannelStateBuilder(); + set channelState(ChannelStateBuilder channelState) => + _$this._channelState = channelState; + + ListBuilder _messagesOnScreen; + ListBuilder get messagesOnScreen => + _$this._messagesOnScreen ??= new ListBuilder(); + set messagesOnScreen(ListBuilder messagesOnScreen) => + _$this._messagesOnScreen = messagesOnScreen; + + String _fcmToken; + String get fcmToken => _$this._fcmToken; + set fcmToken(String fcmToken) => _$this._fcmToken = fcmToken; + + InAppNotificationBuilder _inAppNotification; + InAppNotificationBuilder get inAppNotification => + _$this._inAppNotification ??= new InAppNotificationBuilder(); + set inAppNotification(InAppNotificationBuilder inAppNotification) => + _$this._inAppNotification = inAppNotification; + + UiStateBuilder _uiState; + UiStateBuilder get uiState => _$this._uiState ??= new UiStateBuilder(); + set uiState(UiStateBuilder uiState) => _$this._uiState = uiState; + + AppStateBuilder(); + + AppStateBuilder get _$this { + if (_$v != null) { + _userCalendar = _$v.userCalendar?.toBuilder(); + _groups = _$v.groups?.toBuilder(); + _selectedGroupId = _$v.selectedGroupId; + _user = _$v.user?.toBuilder(); + _groupUsers = _$v.groupUsers?.toBuilder(); + _channelState = _$v.channelState?.toBuilder(); + _messagesOnScreen = _$v.messagesOnScreen?.toBuilder(); + _fcmToken = _$v.fcmToken; + _inAppNotification = _$v.inAppNotification?.toBuilder(); + _uiState = _$v.uiState?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(AppState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$AppState; + } + + @override + void update(void Function(AppStateBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$AppState build() { + _$AppState _$result; + try { + _$result = _$v ?? + new _$AppState._( + userCalendar: userCalendar.build(), + groups: groups.build(), + selectedGroupId: selectedGroupId, + user: _user?.build(), + groupUsers: groupUsers.build(), + channelState: channelState.build(), + messagesOnScreen: messagesOnScreen.build(), + fcmToken: fcmToken, + inAppNotification: _inAppNotification?.build(), + uiState: uiState.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'userCalendar'; + userCalendar.build(); + _$failedField = 'groups'; + groups.build(); + + _$failedField = 'user'; + _user?.build(); + _$failedField = 'groupUsers'; + groupUsers.build(); + _$failedField = 'channelState'; + channelState.build(); + _$failedField = 'messagesOnScreen'; + messagesOnScreen.build(); + + _$failedField = 'inAppNotification'; + _inAppNotification?.build(); + _$failedField = 'uiState'; + uiState.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'AppState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/domain/redux/attachment/attachment_actions.dart b/lib/domain/redux/attachment/attachment_actions.dart new file mode 100644 index 0000000..986f708 --- /dev/null +++ b/lib/domain/redux/attachment/attachment_actions.dart @@ -0,0 +1,23 @@ +import "dart:io"; + +import "package:circles_app/model/user.dart"; +import "package:flutter/cupertino.dart"; +import "package:meta/meta.dart"; + +@immutable +class NewMessageWithMultipleFilesAction { + final List fileIdentifiers; // File paths in case of Android, localIdentifier in case of iOS multiselect & path in case of camera image + final bool isPath; + const NewMessageWithMultipleFilesAction(this.fileIdentifiers, this.isPath); +} + +@immutable +class ChangeAvatarAction { + final File file; + final User user; + + const ChangeAvatarAction({ + @required this.file, + @required this.user, + }); +} diff --git a/lib/domain/redux/attachment/attachment_middleware.dart b/lib/domain/redux/attachment/attachment_middleware.dart new file mode 100644 index 0000000..18e955d --- /dev/null +++ b/lib/domain/redux/attachment/attachment_middleware.dart @@ -0,0 +1,71 @@ +import "package:circles_app/data/file_repository.dart"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/attachment/attachment_actions.dart"; +import "package:circles_app/domain/redux/attachment/image_processor.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/native_channels/upload_platform.dart"; +import "package:circles_app/util/logger.dart"; +import "package:redux/redux.dart"; + +List> createAttachmentMiddleware( + FileRepository fileRepository, + ImageProcessor imageProcessor, + UserRepository userRepository, +) { + return [ + TypedMiddleware( + _newMessageWithMultipleFiles()), + TypedMiddleware(_changeAvatar( + fileRepository, + imageProcessor, + userRepository, + )), + ]; +} + +void Function( + Store store, + NewMessageWithMultipleFilesAction action, + NextDispatcher next, +) _newMessageWithMultipleFiles() { + return (store, action, next) { + next(action); + + UploadPlatform().uploadFiles( + filePaths: action.isPath ? action.fileIdentifiers : [], + localIdentifiers: action.isPath ? [] : action.fileIdentifiers, + groupId: store.state.selectedGroupId, + channelId: store.state.channelState.selectedChannel, + ); + }; +} + +void Function( + Store store, + ChangeAvatarAction action, + NextDispatcher next, +) _changeAvatar( + FileRepository repository, + ImageProcessor imageProcessor, + UserRepository userRepository, +) { + return (store, action, next) async { + next(action); + // TODO: proper error handling when the Avatar upload screens are + // implemented + if (store.state.user.uid != action.user.uid) { + Logger.w("Cannot change other user's pictures"); + return; + } + try { + final file = await imageProcessor.cropAndResizeAvatar(action.file); + final url = await repository.uploadFile(file); + final user = action.user.rebuild((u) => u..image = url); + await userRepository.updateUser(user); + store.dispatch(OnUserUpdateAction(user)); + } catch (error) { + Logger.e(error.toString(), s: StackTrace.current); + } + }; +} diff --git a/lib/domain/redux/attachment/image_processor.dart b/lib/domain/redux/attachment/image_processor.dart new file mode 100644 index 0000000..dd26a9e --- /dev/null +++ b/lib/domain/redux/attachment/image_processor.dart @@ -0,0 +1,20 @@ +import "dart:math"; + +import "package:flutter_native_image/flutter_native_image.dart"; +import "dart:io"; + +const avatarSize = 200; + +class ImageProcessor { + Future cropAndResizeAvatar(File file) async { + final ImageProperties properties = + await FlutterNativeImage.getImageProperties(file.path); + final squareSize = min(properties.width, properties.height); + final originX = ((properties.width / 2) - (squareSize / 2)).toInt(); + final originY = ((properties.height / 2) - (squareSize / 2)).toInt(); + final cropped = await FlutterNativeImage.cropImage( + file.path, originX, originY, squareSize, squareSize); + return await FlutterNativeImage.compressImage(cropped.path, + quality: 95, targetWidth: avatarSize, targetHeight: avatarSize); + } +} diff --git a/lib/domain/redux/authentication/auth_actions.dart b/lib/domain/redux/authentication/auth_actions.dart new file mode 100644 index 0000000..c6b59fd --- /dev/null +++ b/lib/domain/redux/authentication/auth_actions.dart @@ -0,0 +1,49 @@ +import "dart:async"; +import "package:circles_app/model/user.dart"; +import "package:meta/meta.dart"; + +// Authentication +class VerifyAuthenticationState {} + +class LogIn { + final String email; + final String password; + final Completer completer; + + LogIn({this.email, this.password, Completer completer}) + : completer = completer ?? Completer(); +} + +@immutable +class OnAuthenticated { + final User user; + + const OnAuthenticated({@required this.user}); + + @override + String toString() { + return "OnAuthenticated{user: $user}"; + } +} + +class LogOutAction {} + +class OnLogoutSuccess { + OnLogoutSuccess(); + + @override + String toString() { + return "LogOut{user: null}"; + } +} + +class OnLogoutFail { + final dynamic error; + + OnLogoutFail(this.error); + + @override + String toString() { + return "OnLogoutFail{There was an error logging in: $error}"; + } +} diff --git a/lib/domain/redux/authentication/auth_middleware.dart b/lib/domain/redux/authentication/auth_middleware.dart new file mode 100644 index 0000000..ddacfb5 --- /dev/null +++ b/lib/domain/redux/authentication/auth_middleware.dart @@ -0,0 +1,95 @@ +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/stream_subscriptions.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; +import "package:redux/redux.dart"; +import "package:flutter/services.dart"; + +/// Authentication Middleware +/// LogIn: Logging user in +/// LogOut: Logging user out +/// VerifyAuthenticationState: Verify if user is logged in + +List> createAuthenticationMiddleware( + UserRepository userRepository, + GlobalKey navigatorKey, +) { + return [ + TypedMiddleware( + _verifyAuthState(userRepository, navigatorKey)), + TypedMiddleware(_authLogin(userRepository, navigatorKey)), + TypedMiddleware( + _authLogout(userRepository, navigatorKey)), + ]; +} + +void Function( + Store store, + VerifyAuthenticationState action, + NextDispatcher next, +) _verifyAuthState( + UserRepository userRepository, + GlobalKey navigatorKey, +) { + return (store, action, next) { + next(action); + + userRepository.getAuthenticationStateChange().listen((user) { + if (user == null) { + navigatorKey.currentState.pushReplacementNamed(Routes.login); + } else { + store.dispatch(OnAuthenticated(user: user)); + store.dispatch(ConnectToDataSource()); + } + }); + }; +} + +void Function( + Store store, + dynamic action, + NextDispatcher next, +) _authLogout( + UserRepository userRepository, + GlobalKey navigatorKey, +) { + return (store, action, next) async { + next(action); + try { + await userRepository.logOut(); + cancelAllSubscriptions(); + store.dispatch(OnLogoutSuccess()); + } catch (e) { + Logger.w("Failed logout", e: e); + store.dispatch(OnLogoutFail(e)); + } + }; +} + +void Function( + Store store, + dynamic action, + NextDispatcher next, +) _authLogin( + UserRepository userRepository, + GlobalKey navigatorKey, +) { + return (store, action, next) async { + next(action); + try { + final user = await userRepository.signIn(action.email, action.password); + store.dispatch(OnAuthenticated(user: user)); + + await navigatorKey.currentState.pushReplacementNamed(Routes.home); + action.completer.complete(); + } on PlatformException catch (e) { + Logger.w("Failed login", e: e); + action.completer.completeError(e); + } + }; +} diff --git a/lib/domain/redux/authentication/auth_reducer.dart b/lib/domain/redux/authentication/auth_reducer.dart new file mode 100644 index 0000000..1526fd0 --- /dev/null +++ b/lib/domain/redux/authentication/auth_reducer.dart @@ -0,0 +1,16 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:redux/redux.dart"; + +final authReducers = [ + TypedReducer(_onAuthenticated), + TypedReducer(_onLogout), +]; + +AppState _onAuthenticated(AppState state, OnAuthenticated action) { + return state.rebuild((a) => a..user = action.user.toBuilder()); +} + +AppState _onLogout(AppState state, OnLogoutSuccess action) { + return state.clear(); +} diff --git a/lib/domain/redux/calendar/calendar_actions.dart b/lib/domain/redux/calendar/calendar_actions.dart new file mode 100644 index 0000000..5b7458c --- /dev/null +++ b/lib/domain/redux/calendar/calendar_actions.dart @@ -0,0 +1,14 @@ +import "package:circles_app/model/calendar_entry.dart"; +import "package:flutter/foundation.dart"; + +@immutable +class CalendarUpdatedAction { + final List calendarEntries; + + const CalendarUpdatedAction({this.calendarEntries}); + + @override + String toString() { + return "CalendarUpdatedAction{calendarEntries: $calendarEntries}"; + } +} diff --git a/lib/domain/redux/calendar/calendar_middleware.dart b/lib/domain/redux/calendar/calendar_middleware.dart new file mode 100644 index 0000000..8b1a240 --- /dev/null +++ b/lib/domain/redux/calendar/calendar_middleware.dart @@ -0,0 +1,35 @@ +import "package:circles_app/data/calendar_repository.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/calendar/calendar_actions.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:redux/redux.dart"; + +List> createCalendarMiddleware( + CalendarRepository calendarRepository, +) { + return [ + TypedMiddleware(_loadCalendar( + calendarRepository, + )), + ]; +} + +/// Load calendarEntries when channel list updates +void Function( + Store store, + OnChannelsLoaded action, + NextDispatcher next, +) _loadCalendar( + CalendarRepository calendarRepository, +) { + return (store, action, next) async { + next(action); + + final calendarEntries = + await calendarRepository.getCalendarEntries(store.state.user.uid); + + store.dispatch( + CalendarUpdatedAction(calendarEntries: calendarEntries), + ); + }; +} diff --git a/lib/domain/redux/calendar/calendar_reducer.dart b/lib/domain/redux/calendar/calendar_reducer.dart new file mode 100644 index 0000000..cc94e1e --- /dev/null +++ b/lib/domain/redux/calendar/calendar_reducer.dart @@ -0,0 +1,13 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/calendar/calendar_actions.dart"; +import "package:redux/redux.dart"; + +final calendarReducer = [ + TypedReducer(_onCalendarUpdate), +]; + +AppState _onCalendarUpdate(AppState state, CalendarUpdatedAction action) { + return state.rebuild((appState) => + appState..userCalendar = ListBuilder(action.calendarEntries)); +} diff --git a/lib/domain/redux/channel/channel_actions.dart b/lib/domain/redux/channel/channel_actions.dart new file mode 100644 index 0000000..47cb9f5 --- /dev/null +++ b/lib/domain/redux/channel/channel_actions.dart @@ -0,0 +1,208 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter/widgets.dart"; +import "package:meta/meta.dart"; + +@immutable +class LoadChannels { + final String groupId; + + const LoadChannels(this.groupId); + + @override + String toString() { + return "LoadChannels{groupId: $groupId}"; + } +} + +@immutable +class OnChannelsLoaded { + final String groupId; + final List channels; + + const OnChannelsLoaded(this.groupId, this.channels); + + @override + String toString() { + return "OnChannelsLoaded{groupId: $groupId, channels: $channels}"; + } +} + +@immutable +class CreateChannel { + final Channel channel; + final BuiltList invitedIds; + final Completer completer; + + const CreateChannel( + this.channel, + this.invitedIds, + this.completer, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CreateChannel && + runtimeType == other.runtimeType && + channel == other.channel && + invitedIds == other.invitedIds; + + @override + int get hashCode => + channel.hashCode ^ + invitedIds.hashCode; + + @override + String toString() { + return "CreateChannel{channel: $channel, invitedIds: $invitedIds}"; + } +} + +@immutable +class OnChannelCreated { + final Channel channel; + + const OnChannelCreated( + this.channel, + ); +} + +@immutable +class EditChannelAction { + final Channel channel; + final Completer completer; + + const EditChannelAction( + this.channel, + this.completer, + ); +} + +@immutable +class OnUpdatedChannelAction { + final String groupId; + final Channel selectedChannel; + + const OnUpdatedChannelAction(this.groupId, this.selectedChannel); +} + +@immutable +class SelectChannelIdAction { + final String previousChannelId; + final String channelId; + final String userId; + final String groupId; + + const SelectChannelIdAction({ + this.previousChannelId, + this.channelId, + this.groupId, + this.userId, + }); +} + +@immutable +class SelectChannel { + final String previousChannelId; + final Channel channel; + final String userId; + final String groupId; + + const SelectChannel({ + this.previousChannelId, + this.channel, + this.groupId, + this.userId, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SelectChannel && + runtimeType == other.runtimeType && + previousChannelId == other.previousChannelId && + channel == other.channel && + userId == other.userId && + groupId == other.groupId; + + @override + int get hashCode => + previousChannelId.hashCode ^ + channel.hashCode ^ + userId.hashCode ^ + groupId.hashCode; + + @override + String toString() { + return "SelectChannel{previousChannelId: $previousChannelId, channel: $channel, userId: $userId, groupId: $groupId}"; + } +} + +@immutable +class JoinChannelAction { + final String groupId; + final Channel channel; + final User user; + + const JoinChannelAction({ + @required this.groupId, + @required this.channel, + @required this.user, + }); +} + +@immutable +class JoinedChannelAction { + final String groupId; + final Channel channel; + + const JoinedChannelAction( + this.groupId, + this.channel, + ); +} + +@immutable +class JoinChannelFailedAction {} + +@immutable +class ClearFailedJoinAction {} + +@immutable +class LeaveChannelAction { + final String groupId; + final Channel channel; + final String userId; + + const LeaveChannelAction(this.groupId, this.channel, this.userId); +} + +@immutable +class LeftChannelAction { + final String groupId; + final String channelId; + final String userId; + + const LeftChannelAction(this.groupId, this.channelId, this.userId); +} + +@immutable +class RsvpAction { + final RSVP rsvp; + final Completer completer; + + const RsvpAction(this.rsvp, this.completer); +} + +@immutable +class InviteToChannelAction { + final Iterable users; + final Channel channel; + final Completer completer; + + const InviteToChannelAction(this.users, this.channel, this.completer); +} diff --git a/lib/domain/redux/channel/channel_middleware.dart b/lib/domain/redux/channel/channel_middleware.dart new file mode 100644 index 0000000..c49dc5f --- /dev/null +++ b/lib/domain/redux/channel/channel_middleware.dart @@ -0,0 +1,408 @@ +import "package:circles_app/data/channel_repository.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/stream_subscriptions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; +import "package:redux/redux.dart"; + +List> createChannelsMiddleware( + ChannelRepository channelsRepository, + GlobalKey navigatorKey, +) { + return [ + TypedMiddleware( + _selectChannelId(channelsRepository), + ), + TypedMiddleware( + _markChannelReadAndListenToChannelUpdates(channelsRepository), + ), + TypedMiddleware( + _joinChannel(channelsRepository), + ), + TypedMiddleware( + _leaveChannel(channelsRepository), + ), + TypedMiddleware( + _listenToChannels(channelsRepository), + ), + TypedMiddleware( + _createChannel( + channelsRepository, + navigatorKey, + ), + ), + TypedMiddleware( + _editChannel( + channelsRepository, + navigatorKey, + ), + ), + TypedMiddleware( + _rsvp(channelsRepository), + ), + TypedMiddleware( + _inviteToChannel(channelsRepository), + ), + ]; +} + +/// Fetches and selects channel based on its id +void Function( + Store store, + SelectChannelIdAction action, + NextDispatcher next, +) _selectChannelId(ChannelRepository channelRepository) { + return (store, action, next) { + channelRepository + .getChannel(action.groupId, action.channelId, action.userId) + .then((channel) { + store.dispatch(SelectChannel( + channel: channel, groupId: action.groupId, userId: action.userId)); + }).catchError((error) { + Logger.e( + "Failed to fetch and select channel", + e: error, + s: StackTrace.current, + ); + }); + }; +} + +void Function( + Store store, + LeaveChannelAction action, + NextDispatcher next, +) _leaveChannel( + ChannelRepository channelsRepository, +) { + return (store, action, next) async { + next(action); + await _leaveChannelInternal( + channelsRepository: channelsRepository, + groupId: action.groupId, + channelId: action.channel.id, + userId: action.userId, + store: store, + ); + }; +} + +Future _leaveChannelInternal({ + @required ChannelRepository channelsRepository, + @required String groupId, + @required String channelId, + @required String userId, + @required Store store, +}) async { + try { + await channelsRepository.leaveChannel(groupId, channelId, userId); + store.dispatch(LeftChannelAction(groupId, channelId, userId)); + } catch (e) { + Logger.e("Failed to leave channel", e: e, s: StackTrace.current); + } +} + +_listenToChannelUpdates( + {Store store, + SelectChannel action, + ChannelRepository channelRepository}) { + selectedChannelSubscription?.cancel(); + // ignore: cancel_subscriptions + selectedChannelSubscription = channelRepository + .getStreamForChannel(action.groupId, action.channel.id, action.userId) + .listen((updatedChannel) { + store.dispatch(OnUpdatedChannelAction(action.groupId, updatedChannel)); + }); +} + +/// Does two things: +/// 1. Marks a channel read (in case there are updates) +/// 2. Subscribes to channel updates +/// +/// We're handling both cases here is necessary since the subscription +/// to the channel was overriding our local state change for a channel (e.g. hasUpdates = false). +void Function( + Store store, + SelectChannel action, + NextDispatcher next, +) _markChannelReadAndListenToChannelUpdates( + ChannelRepository channelRepository) { + return (store, action, next) { + next(action); + + try { + if (action.channel.users.any((u) => u.id == action.userId)) { + channelRepository + .markChannelRead(action.groupId, action.channel.id, action.userId) + .then((_) { + _listenToChannelUpdates( + action: action, + store: store, + channelRepository: channelRepository); + }); + } else { + _listenToChannelUpdates( + action: action, store: store, channelRepository: channelRepository); + } + } catch (e) { + Logger.e("Failed to mark as read", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + JoinChannelAction action, + NextDispatcher next, +) _joinChannel( + ChannelRepository channelsRepository, +) { + return (store, action, next) async { + next(action); + try { + final channel = await channelsRepository.joinChannel( + action.groupId, + action.channel, + action.user.uid, + ); + store.dispatch(JoinedChannelAction( + action.groupId, + channel, + )); + } catch (error) { + Logger.e("Failed join channel", e: error, s: StackTrace.current); + store.dispatch(JoinChannelFailedAction()); + } + }; +} + +void Function( + Store store, + LoadChannels action, + NextDispatcher next, +) _listenToChannels( + ChannelRepository channelsRepository, +) { + return (store, action, next) { + next(action); + listOfChannelsSubscription?.cancel(); + // ignore: cancel_subscriptions + listOfChannelsSubscription = channelsRepository + .getChannelsStream(action.groupId, store.state.user.uid) + .listen((channels) { + if (channels.isNotEmpty) { + store.dispatch(OnChannelsLoaded(action.groupId, channels)); + + final selectedChannel = getSelectedChannel(store.state); + + // If the selected channel is null + // Or the selected channel does NOT belong to this group + // (e.g. user selected a different group) + if (selectedChannel == null || + !_isChannelInList(channels, selectedChannel)) { + // Select a channel based on this logic + final channel = _pickChannelToSelect(store, action, channels); + if (channel != null) { + store.dispatch(SelectChannel( + previousChannelId: null, + channel: channel, + groupId: action.groupId, + userId: store.state.user.uid)); + } + } + } + }); + }; +} + +Channel _pickChannelToSelect( + Store store, LoadChannels action, List channels) { + // Channel to select automatically + Channel channel; + + // Select the previously selected channel (if still exists) + final channelId = + store.state.uiState.groupUiState[action.groupId]?.lastSelectedChannel; + if (channelId != null) { + channel = channels.firstWhere((c) => c.id == channelId, orElse: null); + } + + // If no previously selected channel for a group + if (channel == null) { + // Select a default OPEN channel if there are channels available + channel = _defaultChannel(channels); + } + return channel; +} + +Channel _defaultChannel(List channels) => + channels.firstWhere((c) => c.visibility == ChannelVisibility.OPEN); + +bool _isChannelInList(List channels, Channel channel) { + if (channel == null) { + return false; + } + return channels.any((c) => c.id == channel?.id); +} + +void Function( + Store store, + CreateChannel action, + NextDispatcher next, +) _createChannel( + ChannelRepository channelsRepository, + GlobalKey navigatorKey, +) { + return (store, action, next) async { + next(action); + + try { + // Create Channel + final createdChannel = await channelsRepository.createChannel( + store.state.selectedGroupId, + action.channel, + [store.state.user.uid, ...action.invitedIds.toList()], + store.state.user.uid, + ); + + store.dispatch(OnChannelCreated(createdChannel)); + + // Select the newly created channel. + // Adding delay to allow backend to add invited members + Future.delayed(const Duration(milliseconds: 1000), () { + store.dispatch(SelectChannel( + previousChannelId: store.state.channelState.selectedChannel, + channel: createdChannel, + groupId: store.state.selectedGroupId, + userId: store.state.user.uid, + )); + }); + + action.completer.complete(); + } catch (error) { + Logger.e("Failed create channel", e: error, s: StackTrace.current); + action.completer.completeError(error); + } + }; +} + +void Function( + Store store, + EditChannelAction action, + NextDispatcher next, +) _editChannel( + ChannelRepository channelsRepository, + GlobalKey navigatorKey, +) { + return (store, action, next) async { + next(action); + + try { + await channelsRepository.updateChannel( + store.state.selectedGroupId, + action.channel, + ); + store.dispatch( + OnUpdatedChannelAction(store.state.selectedGroupId, action.channel)); + action.completer.complete(); + } catch (error) { + action.completer.completeError(error); + } + }; +} + +void Function( + Store store, + RsvpAction action, + NextDispatcher next, +) _rsvp( + ChannelRepository channelsRepository, +) { + return (store, action, next) async { + try { + // Event has passed + if (getSelectedChannel(store.state).startDate.isBefore(DateTime.now())) { + throw Exception( + "RSVP after event passed ${getSelectedChannel(store.state).startDate} vs. now: ${DateTime.now()}"); + } + + // Allow users to RSVP even when they are not members + if (_userIsNotChannelMember(store) && _rsvpYesOrMaybe(action.rsvp)) { + // That causes them to join the channel + final channel = await channelsRepository.joinChannel( + store.state.selectedGroupId, + getSelectedChannel(store.state), + store.state.user.uid, + ); + store.dispatch(JoinedChannelAction( + store.state.selectedGroupId, + channel, + )); + } + + // Ignore when a user that is not member clicks on NO + if (_userIsNotChannelMember(store) && _rsvpNo(action.rsvp)) { + return; + } + + await channelsRepository.rsvp( + store.state.selectedGroupId, + store.state.channelState.selectedChannel, + store.state.user.uid, + action.rsvp, + ); + + if (_rsvpNo(action.rsvp)) { + await _leaveChannelInternal( + channelsRepository: channelsRepository, + groupId: store.state.selectedGroupId, + channelId: store.state.channelState.selectedChannel, + userId: store.state.user.uid, + store: store, + ); + } + + action.completer.complete(action.rsvp); + } catch (e) { + Logger.e("Failed RSVP", e: e, s: StackTrace.current); + } + next(action); + }; +} + +bool _rsvpYesOrMaybe(RSVP rsvp) => [RSVP.YES, RSVP.MAYBE].contains(rsvp); + +bool _rsvpNo(RSVP rsvp) => rsvp == RSVP.NO; + +bool _userIsNotChannelMember(Store store) { + return !getSelectedChannel(store.state) + .users + .any((cu) => cu.id == store.state.user.uid); +} + +void Function( + Store store, + InviteToChannelAction action, + NextDispatcher next, +) _inviteToChannel( + ChannelRepository channelsRepository, +) { + return (store, action, next) async { + next(action); + try { + await channelsRepository.inviteToChannel( + groupId: store.state.selectedGroupId, + channel: action.channel, + members: action.users, + invitingUsername: store.state.user.name, + groupName: store.state.groups[store.state.selectedGroupId].name); + action.completer.complete(); + } catch (error) { + Logger.e("Failed invite to channel", e: error, s: StackTrace.current); + action.completer.completeError(error); + } + }; +} diff --git a/lib/domain/redux/channel/channel_reducer.dart b/lib/domain/redux/channel/channel_reducer.dart new file mode 100644 index 0000000..2f91136 --- /dev/null +++ b/lib/domain/redux/channel/channel_reducer.dart @@ -0,0 +1,128 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/ui/ui_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/group.dart"; +import "package:redux/redux.dart"; + +final channelReducers = [ + TypedReducer(_onChannelsLoaded), + TypedReducer(_onChannelCreated), + TypedReducer(_selectChannel), + TypedReducer(_onUpdateSelectedChannel), + TypedReducer(_onJoinedChannel), + TypedReducer(_onLeftChannel), + TypedReducer(_onJoinChannelFailed), + TypedReducer(_onClearFailedJoin), + TypedReducer(_rsvp), +]; + +AppState _onJoinChannelFailed(AppState state, JoinChannelFailedAction action) { + return state.rebuild( + (a) => a..channelState.update((s) => s..joinChannelFailed = true)); +} + +AppState _onClearFailedJoin(AppState state, ClearFailedJoinAction action) { + return state.rebuild( + (a) => a..channelState.update((s) => s..joinChannelFailed = false)); +} + +AppState _onJoinedChannel(AppState state, JoinedChannelAction action) { + return _updateChannel(state, action.channel, action.groupId); +} + +AppState _onLeftChannel(AppState state, LeftChannelAction action) { + final channel = getSelectedChannel(state).rebuild((c) => + c..users.update((uid) => uid.removeWhere((u) => u.id == action.userId))); + + return _updateChannel(state, channel, action.groupId); +} + +AppState _onUpdateSelectedChannel( + AppState state, OnUpdatedChannelAction action) { + return _updateChannel(state, action.selectedChannel, action.groupId); +} + +AppState _updateChannel( + AppState state, + Channel channel, + String groupId, +) { + final groupChannels = (GroupBuilder c) { + return c + ..channels.update((channels) { + channels[channel.id] = channel; + }); + }; + + return state.rebuild((s) { + return s + ..groups.update((groups) { + groups[groupId] = groups[groupId].rebuild(groupChannels); + }); + }); +} + +AppState _onChannelsLoaded(AppState state, OnChannelsLoaded action) { + final groupId = action.groupId; + final Map channels = Map.fromIterable( + action.channels, + key: (item) => item.id, + value: (item) => item, + ); + final updateChannels = (GroupBuilder c) => c..channels = MapBuilder(channels); + return _updateCircle(state, groupId, updateChannels); +} + +AppState _updateCircle( + AppState state, + String groupId, + update(GroupBuilder c), +) { + return state.rebuild((a) => a + ..groups.update((groups) { + groups[groupId] = groups[groupId].rebuild(update); + })); +} + +AppState _onChannelCreated(AppState state, OnChannelCreated action) { + final groupId = state.selectedGroupId; + final updateChannels = (GroupBuilder c) => c + ..channels.update((channels) { + channels[action.channel.id] = action.channel; + }); + return _updateCircle(state, groupId, updateChannels); +} + +AppState _selectChannel(AppState state, SelectChannel action) { + final GroupUiStateBuilder Function(GroupUiStateBuilder value) + updateLastSelectedChannel = + (g) => g..lastSelectedChannel = action.channel.id; + + final updatedState = state.rebuild( + (s) => s + ..channelState.selectedChannel = action.channel.id + ..uiState.groupUiState.updateValue( + action.groupId, + (g) => g.rebuild(updateLastSelectedChannel), + ifAbsent: () => GroupUiState(updateLastSelectedChannel), + ), + ); + + // Mark channel as read + final channel = action.channel.rebuild((c) => c..hasUpdates = false); + return _updateChannel(updatedState, channel, action.groupId); +} + +AppState _rsvp(AppState state, RsvpAction action) { + final users = getSelectedChannel(state).users; + final channel = getSelectedChannel(state).toBuilder(); + final channelUser = users.firstWhere((u) => u.id == state.user.uid); + final channelUserRsvp = channelUser.rebuild((c) => c..rsvp = action.rsvp); + channel.update((c) => c + ..users.remove(channelUser) + ..users.add(channelUserRsvp)); + return _updateChannel(state, channel.build(), state.selectedGroupId); +} diff --git a/lib/domain/redux/message/message_actions.dart b/lib/domain/redux/message/message_actions.dart new file mode 100644 index 0000000..cfd2360 --- /dev/null +++ b/lib/domain/redux/message/message_actions.dart @@ -0,0 +1,45 @@ +import "package:circles_app/model/message.dart"; +import "package:meta/meta.dart"; + +@immutable +class SendMessage { + final String message; + + const SendMessage( + this.message, + ); + + @override + String toString() { + return "SendMessage{message: $message}"; + } +} + +@immutable +class UpdateAllMessages { + final List data; + + const UpdateAllMessages(this.data); +} + +@immutable +class DeleteMessage { + final String messageId; + + const DeleteMessage(this.messageId); +} + +@immutable +class EmojiReaction { + final String emoji; + final String messageId; + + const EmojiReaction(this.messageId, this.emoji); +} + +@immutable +class RemoveEmojiReaction { + final String messageId; + + const RemoveEmojiReaction(this.messageId); +} diff --git a/lib/domain/redux/message/message_middleware.dart b/lib/domain/redux/message/message_middleware.dart new file mode 100644 index 0000000..a778509 --- /dev/null +++ b/lib/domain/redux/message/message_middleware.dart @@ -0,0 +1,164 @@ +import "package:circles_app/data/message_repository.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/domain/redux/stream_subscriptions.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:circles_app/util/logger.dart"; +import "package:redux/redux.dart"; + +List> createMessagesMiddleware( + MessageRepository messagesRepository, +) { + return [ + TypedMiddleware(_sendMessage(messagesRepository)), + TypedMiddleware( + _deleteMessage(messagesRepository)), + TypedMiddleware( + _listenMessages(messagesRepository)), + TypedMiddleware( + _reactWithEmoji(messagesRepository)), + TypedMiddleware( + _removeReaction(messagesRepository)), + ]; +} + +void Function( + Store store, + SendMessage action, + NextDispatcher next, +) _sendMessage( + MessageRepository messageRepository, +) { + return (store, action, next) async { + next(action); + final groupId = store.state.selectedGroupId; + final channelId = store.state.channelState.selectedChannel; + final message = Message((m) => m + ..body = action.message + ..authorId = store.state.user.uid); + try { + await messageRepository.sendMessage(groupId, channelId, message); + } catch (e) { + Logger.e("Failed to send message", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + DeleteMessage action, + NextDispatcher next, +) _deleteMessage( + MessageRepository messageRepository, +) { + return (store, action, next) async { + next(action); + final groupId = store.state.selectedGroupId; + final channelId = store.state.channelState.selectedChannel; + try { + await messageRepository.deleteMessage(groupId, channelId, action.messageId); + } catch (e) { + Logger.e("Failed to delete message", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + SelectChannel action, + NextDispatcher next, +) _listenMessages( + MessageRepository messageRepository, +) { + return (store, action, next) { + next(action); + try { + // Do not update subscription if there's already a valid subscription to it. + // This is necessary since we'll update the channel as well (e.g. when users join/leave etc). + if (action.channel.id == action.previousChannelId) { + return; + } + + // cancel previous message subscription + messagesSubscription?.cancel(); + + final groupId = store.state.selectedGroupId; + final channelId = store.state.channelState.selectedChannel; + final userId = store.state.user.uid; + + // ignore: cancel_subscriptions + messagesSubscription = messageRepository + .getMessagesStream( + groupId, + channelId, + userId, + ) + .listen((data) { + store.dispatch(UpdateAllMessages(data)); + }); + } catch (e) { + Logger.e("Failed to listen to messages", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + EmojiReaction action, + NextDispatcher next, +) _reactWithEmoji( + MessageRepository messageRepository, +) { + return (store, action, next) async { + next(action); + try { + final groupId = store.state.selectedGroupId; + final channelId = store.state.channelState.selectedChannel; + final messageId = action.messageId; + final userId = store.state.user.uid; + final userName = store.state.user.name; + final emoji = action.emoji; + final reaction = Reaction((r) => r + ..userId = userId + ..userName = userName + ..emoji = emoji + ..timestamp = DateTime.now()); + await messageRepository.addReaction( + groupId: groupId, + channelId: channelId, + messageId: messageId, + reaction: reaction, + ); + } catch (e) { + Logger.e("Failed to add emoji reaction", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + RemoveEmojiReaction action, + NextDispatcher next, +) _removeReaction( + MessageRepository messageRepository, +) { + return (store, action, next) async { + next(action); + try { + final groupId = store.state.selectedGroupId; + final channelId = store.state.channelState.selectedChannel; + final messageId = action.messageId; + final userId = store.state.user.uid; + await messageRepository.removeReaction( + groupId: groupId, + channelId: channelId, + messageId: messageId, + userId: userId, + ); + } catch (e) { + Logger.e("Failed to remove emoji reaction", e: e, s: StackTrace.current); + } + }; +} diff --git a/lib/domain/redux/message/message_reducer.dart b/lib/domain/redux/message/message_reducer.dart new file mode 100644 index 0000000..2df4531 --- /dev/null +++ b/lib/domain/redux/message/message_reducer.dart @@ -0,0 +1,12 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:redux/redux.dart"; + +final messageReducers = [ + TypedReducer(_onMessageUpdated), +]; + +AppState _onMessageUpdated(AppState state, UpdateAllMessages action) { + return state.rebuild((a) => a..messagesOnScreen = ListBuilder(action.data)); +} diff --git a/lib/domain/redux/push/push_actions.dart b/lib/domain/redux/push/push_actions.dart new file mode 100644 index 0000000..510821d --- /dev/null +++ b/lib/domain/redux/push/push_actions.dart @@ -0,0 +1,37 @@ +import "package:circles_app/model/in_app_notification.dart"; + +class UpdateUserTokenAction { + final String token; + + UpdateUserTokenAction(this.token); +} + +class OnPushNotificationOpenAction { + final Map message; + + OnPushNotificationOpenAction(this.message); +} + +class OnPushNotificationReceivedAction { + final Map message; + + OnPushNotificationReceivedAction(this.message); +} + +class ShowPushNotificationAction { + InAppNotification inAppNotification; + + ShowPushNotificationAction(this.inAppNotification); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ShowPushNotificationAction && + runtimeType == other.runtimeType && + inAppNotification == other.inAppNotification; + + @override + int get hashCode => inAppNotification.hashCode; +} + +class OnPushNotificationDismissedAction {} diff --git a/lib/domain/redux/push/push_middleware.dart b/lib/domain/redux/push/push_middleware.dart new file mode 100644 index 0000000..37fbf0f --- /dev/null +++ b/lib/domain/redux/push/push_middleware.dart @@ -0,0 +1,190 @@ +import "dart:io"; + +import "package:circles_app/data/channel_repository.dart"; +import "package:circles_app/data/group_repository.dart"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:circles_app/model/in_app_notification.dart"; +import "package:circles_app/util/logger.dart"; +import "package:firebase_messaging/firebase_messaging.dart"; +import "package:redux/redux.dart"; + +List> createPushMiddleware( + UserRepository userRespository, + FirebaseMessaging firebaseMessaging, + GroupRepository groupRepository, + ChannelRepository channelRepository, +) { + return [ + TypedMiddleware( + _updateUserAction(userRespository)), + TypedMiddleware( + _setTokenAfterLogin(userRespository)), + TypedMiddleware( + _onPushNotificationOpen(groupRepository, channelRepository)), + TypedMiddleware( + _onPushNotificationReceived( + groupRepository, + channelRepository, + )), + ]; +} + +void Function( + Store store, + UpdateUserTokenAction action, + NextDispatcher next, +) _updateUserAction(UserRepository userRepository) { + return (store, action, next) async { + next(action); + try { + await userRepository.updateUserToken(action.token); + } catch (e) { + Logger.e("Failed to update token", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + OnAuthenticated action, + NextDispatcher next, +) _setTokenAfterLogin(UserRepository userRepository) { + return (store, action, next) async { + next(action); + try { + /// Set the token after the user is authenticated if the token exists + if (store.state.fcmToken != null) { + await userRepository.updateUserToken(store.state.fcmToken); + } + } catch (e) { + Logger.e("Failed to update token", e: e, s: StackTrace.current); + } + }; +} + +void Function(Store store, OnPushNotificationOpenAction action, + NextDispatcher next) + _onPushNotificationOpen( + GroupRepository groupRepository, ChannelRepository channelRepository) { + return (store, action, next) async { + next(action); + try { + final message = _verifyedMessage(action.message, store); + if (message == null) { + return; + } + final data = message["data"]; + final groupId = data["groupId"]; + final channelId = data["channelId"]; + final previousChannelId = store.state.channelState.selectedChannel; + final channel = await channelRepository.getChannel( + groupId, + channelId, + store.state.user.uid, + ); + + store.dispatch(SelectGroup(groupId)); + store.dispatch(SelectChannel( + previousChannelId: previousChannelId, + channel: channel, + groupId: data["groupId"], + userId: store.state.user.uid, + )); + } catch (e) { + Logger.e("Failed to open push notification", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + OnPushNotificationReceivedAction action, + NextDispatcher next, +) _onPushNotificationReceived( + GroupRepository groupRepository, + ChannelRepository channelRepository, +) { + return (store, action, next) async { + next(action); + + try { + final message = _verifyedMessage(action.message, store); + if (message == null) { + return; + } + + final notification = message["notification"]; + final data = message["data"]; + final groupId = data["groupId"]; + final channelId = data["channelId"]; + final group = await groupRepository.getGroup(groupId); + final channel = await channelRepository.getChannel( + groupId, + channelId, + store.state.user.uid, + ); + final userName = data["username"]; + + final inAppNotification = InAppNotification((n) => + n + ..groupId = groupId + ..channel = channel.toBuilder() + ..groupName = group.name + ..message = notification["body"] + ..userName = userName); + + store.dispatch(ShowPushNotificationAction(inAppNotification)); + } catch (e) { + Logger.e("Failed to display push notification", e: e, s: StackTrace.current); + } + }; +} + +Map _verifyedMessage( + Map message, Store store) { + var notification = message["notification"]; + var data = message["data"]; + + // Necessary because the payload format is different per platform + // See: https://github.com/flutter/flutter/issues/29027 + if (Platform.isIOS) { + data = message; + final aps = (data != null) ? data["aps"] : null; + notification = (aps != null) ? aps["alert"] : null; + } + + final results = {"data": data, "notification": notification}; + + if (notification == null || data == null) { + Logger.d("Empty message payload"); + return null; + } + + final groupId = data["groupId"]; + final channelId = data["channelId"]; + + if (groupId == null || channelId == null) { + Logger.d("Missing properties channelId and groupId"); + return null; + } + + final messageType = data["type"]; + + if (messageType != "message") { + Logger.d("No action required for type: $messageType"); + return null; + } + + if (store.state.selectedGroupId == groupId && + store.state.channelState.selectedChannel == channelId) { + Logger.d("User is already in the channel: $channelId"); + return null; + } + + return results; +} diff --git a/lib/domain/redux/push/push_reducer.dart b/lib/domain/redux/push/push_reducer.dart new file mode 100644 index 0000000..7f86a87 --- /dev/null +++ b/lib/domain/redux/push/push_reducer.dart @@ -0,0 +1,28 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:redux/redux.dart"; + +final pushReducers = [ + TypedReducer(_updateUserAction), + TypedReducer( + _showPushNotificationAction), + TypedReducer( + _onPushNotificationDismissed), +]; + +AppState _updateUserAction(AppState state, UpdateUserTokenAction action) { + return state.rebuild((s) => s..fcmToken = action.token); +} + +AppState _showPushNotificationAction( + AppState state, ShowPushNotificationAction action) { + return state.rebuild( + (s) => s..inAppNotification = action.inAppNotification.toBuilder()); +} + +AppState _onPushNotificationDismissed( + AppState state, + OnPushNotificationDismissedAction action, +) { + return state.rebuild((s) => s..inAppNotification = null); +} diff --git a/lib/domain/redux/stream_subscriptions.dart b/lib/domain/redux/stream_subscriptions.dart new file mode 100644 index 0000000..7274190 --- /dev/null +++ b/lib/domain/redux/stream_subscriptions.dart @@ -0,0 +1,31 @@ +import "dart:async"; + +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; + +// App user +StreamSubscription userUpdateSubscription; +// List of user's groups +StreamSubscription> groupsSubscription; +// List of users of the current selected group +StreamSubscription> groupUsersSubscription; +// List of channels of the current selected group +StreamSubscription> listOfChannelsSubscription; +// Selected channel +StreamSubscription selectedChannelSubscription; +// Messages from selected channel +StreamSubscription> messagesSubscription; + +/// Cancels all active subscriptions +/// +/// Called on successful logout. +cancelAllSubscriptions() { + userUpdateSubscription?.cancel(); + groupsSubscription?.cancel(); + groupUsersSubscription?.cancel(); + listOfChannelsSubscription?.cancel(); + selectedChannelSubscription?.cancel(); + messagesSubscription?.cancel(); +} \ No newline at end of file diff --git a/lib/domain/redux/ui/ui_actions.dart b/lib/domain/redux/ui/ui_actions.dart new file mode 100644 index 0000000..c16147d --- /dev/null +++ b/lib/domain/redux/ui/ui_actions.dart @@ -0,0 +1,18 @@ +import "package:meta/meta.dart"; + +class UpdatedChatDraftAction { + final String text; + final String groupId; + final String channelId; + + const UpdatedChatDraftAction({ + @required this.text, + @required this.groupId, + @required this.channelId, + }); + + @override + String toString() { + return "UpdatedChatDraftAction{text: $text, groupId: $groupId, channelId: $channelId}"; + } +} diff --git a/lib/domain/redux/ui/ui_reducer.dart b/lib/domain/redux/ui/ui_reducer.dart new file mode 100644 index 0000000..41a43a6 --- /dev/null +++ b/lib/domain/redux/ui/ui_reducer.dart @@ -0,0 +1,26 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/ui/ui_actions.dart"; +import "package:circles_app/domain/redux/ui/ui_state_selector.dart"; +import "package:circles_app/util/logger.dart"; +import "package:redux/redux.dart"; + +final uiReducers = [ + TypedReducer(_onUpdatedChatDraft), +]; + +AppState _onUpdatedChatDraft(AppState state, UpdatedChatDraftAction action) { + Logger.d(action.toString()); + final out = state.rebuild( + (s) => s + ..uiState.update( + (u) => updateInputDraft( + state: u, + groupId: action.groupId, + channelId: action.channelId, + value: action.text, + ), + ), + ); + Logger.d(out.uiState.toString()); + return out; +} diff --git a/lib/domain/redux/ui/ui_state.dart b/lib/domain/redux/ui/ui_state.dart new file mode 100644 index 0000000..5cb10cf --- /dev/null +++ b/lib/domain/redux/ui/ui_state.dart @@ -0,0 +1,46 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'ui_state.g.dart'; + +/// +/// Store different UI related data (last selected channel, channel input text, etc.) +/// +/// +abstract class UiState implements Built { + + + // Group UI state per group id + BuiltMap get groupUiState; + + UiState._(); + factory UiState([void Function(UiStateBuilder) updates]) = _$UiState; +} + +/// +/// Store UI related data per group +/// +abstract class GroupUiState implements Built { + // When a user changes groups, pick the last selected channel if present + @nullable + String get lastSelectedChannel; + + // Channel UI state per channel id + BuiltMap get channelUiState; + + GroupUiState._(); + factory GroupUiState([void Function(GroupUiStateBuilder) updates]) = _$GroupUiState; +} + +/// +/// Store UI related data per channel +/// +abstract class ChannelUiState implements Built { + + @nullable + String get inputDraft; + + ChannelUiState._(); + factory ChannelUiState([void Function(ChannelUiStateBuilder) updates]) = _$ChannelUiState; +} diff --git a/lib/domain/redux/ui/ui_state.g.dart b/lib/domain/redux/ui/ui_state.g.dart new file mode 100644 index 0000000..58c3cb2 --- /dev/null +++ b/lib/domain/redux/ui/ui_state.g.dart @@ -0,0 +1,285 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ui_state.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$UiState extends UiState { + @override + final BuiltMap groupUiState; + + factory _$UiState([void Function(UiStateBuilder) updates]) => + (new UiStateBuilder()..update(updates)).build(); + + _$UiState._({this.groupUiState}) : super._() { + if (groupUiState == null) { + throw new BuiltValueNullFieldError('UiState', 'groupUiState'); + } + } + + @override + UiState rebuild(void Function(UiStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UiStateBuilder toBuilder() => new UiStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is UiState && groupUiState == other.groupUiState; + } + + @override + int get hashCode { + return $jf($jc(0, groupUiState.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('UiState') + ..add('groupUiState', groupUiState)) + .toString(); + } +} + +class UiStateBuilder implements Builder { + _$UiState _$v; + + MapBuilder _groupUiState; + MapBuilder get groupUiState => + _$this._groupUiState ??= new MapBuilder(); + set groupUiState(MapBuilder groupUiState) => + _$this._groupUiState = groupUiState; + + UiStateBuilder(); + + UiStateBuilder get _$this { + if (_$v != null) { + _groupUiState = _$v.groupUiState?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(UiState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$UiState; + } + + @override + void update(void Function(UiStateBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$UiState build() { + _$UiState _$result; + try { + _$result = _$v ?? new _$UiState._(groupUiState: groupUiState.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'groupUiState'; + groupUiState.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'UiState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$GroupUiState extends GroupUiState { + @override + final String lastSelectedChannel; + @override + final BuiltMap channelUiState; + + factory _$GroupUiState([void Function(GroupUiStateBuilder) updates]) => + (new GroupUiStateBuilder()..update(updates)).build(); + + _$GroupUiState._({this.lastSelectedChannel, this.channelUiState}) + : super._() { + if (channelUiState == null) { + throw new BuiltValueNullFieldError('GroupUiState', 'channelUiState'); + } + } + + @override + GroupUiState rebuild(void Function(GroupUiStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + GroupUiStateBuilder toBuilder() => new GroupUiStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is GroupUiState && + lastSelectedChannel == other.lastSelectedChannel && + channelUiState == other.channelUiState; + } + + @override + int get hashCode { + return $jf( + $jc($jc(0, lastSelectedChannel.hashCode), channelUiState.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('GroupUiState') + ..add('lastSelectedChannel', lastSelectedChannel) + ..add('channelUiState', channelUiState)) + .toString(); + } +} + +class GroupUiStateBuilder + implements Builder { + _$GroupUiState _$v; + + String _lastSelectedChannel; + String get lastSelectedChannel => _$this._lastSelectedChannel; + set lastSelectedChannel(String lastSelectedChannel) => + _$this._lastSelectedChannel = lastSelectedChannel; + + MapBuilder _channelUiState; + MapBuilder get channelUiState => + _$this._channelUiState ??= new MapBuilder(); + set channelUiState(MapBuilder channelUiState) => + _$this._channelUiState = channelUiState; + + GroupUiStateBuilder(); + + GroupUiStateBuilder get _$this { + if (_$v != null) { + _lastSelectedChannel = _$v.lastSelectedChannel; + _channelUiState = _$v.channelUiState?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(GroupUiState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$GroupUiState; + } + + @override + void update(void Function(GroupUiStateBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$GroupUiState build() { + _$GroupUiState _$result; + try { + _$result = _$v ?? + new _$GroupUiState._( + lastSelectedChannel: lastSelectedChannel, + channelUiState: channelUiState.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'channelUiState'; + channelUiState.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'GroupUiState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$ChannelUiState extends ChannelUiState { + @override + final String inputDraft; + + factory _$ChannelUiState([void Function(ChannelUiStateBuilder) updates]) => + (new ChannelUiStateBuilder()..update(updates)).build(); + + _$ChannelUiState._({this.inputDraft}) : super._(); + + @override + ChannelUiState rebuild(void Function(ChannelUiStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelUiStateBuilder toBuilder() => + new ChannelUiStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChannelUiState && inputDraft == other.inputDraft; + } + + @override + int get hashCode { + return $jf($jc(0, inputDraft.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChannelUiState') + ..add('inputDraft', inputDraft)) + .toString(); + } +} + +class ChannelUiStateBuilder + implements Builder { + _$ChannelUiState _$v; + + String _inputDraft; + String get inputDraft => _$this._inputDraft; + set inputDraft(String inputDraft) => _$this._inputDraft = inputDraft; + + ChannelUiStateBuilder(); + + ChannelUiStateBuilder get _$this { + if (_$v != null) { + _inputDraft = _$v.inputDraft; + _$v = null; + } + return this; + } + + @override + void replace(ChannelUiState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChannelUiState; + } + + @override + void update(void Function(ChannelUiStateBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChannelUiState build() { + final _$result = _$v ?? new _$ChannelUiState._(inputDraft: inputDraft); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/domain/redux/ui/ui_state_selector.dart b/lib/domain/redux/ui/ui_state_selector.dart new file mode 100644 index 0000000..763dde3 --- /dev/null +++ b/lib/domain/redux/ui/ui_state_selector.dart @@ -0,0 +1,80 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/ui/ui_state.dart"; +import "package:flutter/foundation.dart"; + + +/// Returns the stored channel input given the current AppState +/// +/// Returns null if: +/// * there is no selected group (unlikely) +/// * there is no selected channel (unlikely) +/// * there's no previous group UI state (likely) +/// * there's no previous channel UI state (likely) +String getInputDraftSelectedChannel(AppState appState) { + if (appState.selectedGroupId == null) return null; + if (appState.channelState.selectedChannel == null) return null; + final groupUiState = appState.uiState.groupUiState[appState.selectedGroupId]; + if (groupUiState == null) return null; + final channelUiState = + groupUiState.channelUiState[appState.channelState.selectedChannel]; + if (channelUiState == null) return null; + return channelUiState.inputDraft; +} + +/// Rebuilds the [UiState] for a [Group] in the [UiState] +/// +/// Applies the given [update] to the existing [GroupUiState] +/// Otherwise creates a new [GroupUiState] +UiStateBuilder updateGroupUiState({ + @required UiStateBuilder state, + @required String groupId, + @required GroupUiStateBuilder Function(GroupUiStateBuilder) update, +}) { + state.groupUiState.updateValue( + groupId, + (g) => g.rebuild(update), + ifAbsent: () => GroupUiState(update), + ); + return state; +} + +/// Rebuilds the [ChannelUiState] for a [Channel] in a [GroupUiState] +/// +/// Applies the given [update] to the existing [ChannelUiState] +/// Otherwise creates a new [ChannelUiState] +GroupUiStateBuilder updateChannelUiState({ + @required GroupUiStateBuilder state, + @required String channelId, + @required ChannelUiStateBuilder Function(ChannelUiStateBuilder) update, +}) { + state.channelUiState.updateValue( + channelId, + (g) => g.rebuild(update), + ifAbsent: () => ChannelUiState(update), + ); + return state; +} + +/// Updates the text input for a [ChannelUiState] +/// +/// Applies the given text input value to the existing [ChannelUiState] +/// Otherwise creates a new [ChannelUiState] +UiStateBuilder updateInputDraft({ + @required UiStateBuilder state, + @required String groupId, + @required String channelId, + @required String value, +}) { + final GroupUiStateBuilder Function(GroupUiStateBuilder value) updateGroup = + (g) => updateChannelUiState( + state: g, + channelId: channelId, + update: (c) => c..inputDraft = value, + ); + + return updateGroupUiState( + state: state, + groupId: groupId, + update: updateGroup, + ); +} diff --git a/lib/domain/redux/user/user_actions.dart b/lib/domain/redux/user/user_actions.dart new file mode 100644 index 0000000..1cd6652 --- /dev/null +++ b/lib/domain/redux/user/user_actions.dart @@ -0,0 +1,33 @@ +import "dart:async"; + +import "package:circles_app/model/user.dart"; +import "package:meta/meta.dart"; + +@immutable +class UsersUpdateAction { + final List users; + + const UsersUpdateAction(this.users); +} + +@immutable +class OnUserUpdateAction { + final User user; + + const OnUserUpdateAction(this.user); +} + +@immutable +class UpdateUserLocaleAction { + final String locale; + + const UpdateUserLocaleAction(this.locale); +} + +@immutable +class UpdateUserAction { + final User user; + final Completer completer; + + const UpdateUserAction(this.user, this.completer); +} diff --git a/lib/domain/redux/user/user_middleware.dart b/lib/domain/redux/user/user_middleware.dart new file mode 100644 index 0000000..9669c17 --- /dev/null +++ b/lib/domain/redux/user/user_middleware.dart @@ -0,0 +1,113 @@ +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/stream_subscriptions.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/util/logger.dart"; +import "package:redux/redux.dart"; + + +List> createUserMiddleware( + UserRepository userRepository, +) { + return [ + TypedMiddleware(_listenToUser(userRepository)), + TypedMiddleware(_listenToUsers(userRepository)), + TypedMiddleware( + _updateUserLocale(userRepository)), + TypedMiddleware(_updateUser(userRepository)), + ]; +} + +// Updates locale for logged in user. +void Function( + Store store, + UpdateUserLocaleAction action, + NextDispatcher next, +) _updateUserLocale( + UserRepository userRepository, +) { + return (store, action, next) async { + next(action); + + try { + // Updates user locale after login. + await userRepository.updateUserLocale(action.locale); + } catch (e) { + Logger.e("Failed to update locale", e: e, s: StackTrace.current); + } + }; +} + +// Receives updates for the logged in user. +void Function( + Store store, + OnAuthenticated action, + NextDispatcher next, +) _listenToUser( + UserRepository userRepository, +) { + return (store, action, next) { + next(action); + try { + userUpdateSubscription?.cancel(); + userUpdateSubscription = + userRepository.getUserStream(action.user.uid).listen((user) { + store.dispatch(OnUserUpdateAction(user)); + }); + } catch (e) { + Logger.e("Failed to listen user", e: e, s: StackTrace.current); + } + }; +} + +// This listener will only fire when relevant (= matching groupId) updates are performed +// on the `joinedGroups` for a user. Note +// - we store members for a group +// - we also store joinedGroup membership for a user +void Function( + Store store, + SelectGroup action, + NextDispatcher next, +) _listenToUsers( + UserRepository userRepository, +) { + return (store, action, next) { + next(action); + try { + groupUsersSubscription?.cancel(); + groupUsersSubscription = + userRepository.getUsersStream(action.groupId).listen((users) { + store.dispatch(UsersUpdateAction(users)); + }); + } catch (e) { + Logger.e("Failed to listen to users", e: e, s: StackTrace.current); + } + }; +} + +void Function( + Store store, + UpdateUserAction action, + NextDispatcher next, +) _updateUser( + UserRepository userRepository, +) { + return (store, action, next) async { + next(action); + if (store.state.user.uid != action.user.uid) { + action.completer + .completeError(Exception("You can't update other users!")); + return; + } + try { + await userRepository.updateUser(action.user); + store.dispatch(OnUserUpdateAction(action.user)); + action.completer.complete(action.user); + } catch (error) { + Logger.e("Failed to update user", e: error, s: StackTrace.current); + action.completer.completeError(error); + } + }; +} diff --git a/lib/domain/redux/user/user_reducer.dart b/lib/domain/redux/user/user_reducer.dart new file mode 100644 index 0000000..83263f9 --- /dev/null +++ b/lib/domain/redux/user/user_reducer.dart @@ -0,0 +1,23 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:redux/redux.dart"; + +final userReducers = [ + TypedReducer(_onUsersUpdate), + TypedReducer(_onUserUpdate), +]; + +AppState _onUserUpdate(AppState state, OnUserUpdateAction action) { + return state.rebuild((a) => a + // Update the app user + ..user = action.user.toBuilder() + // Update the user in the groupUsers + ..groupUsers.removeWhere((u) => u.uid == action.user.uid) + ..groupUsers.add(action.user)); +} + +AppState _onUsersUpdate(AppState state, UsersUpdateAction action) { + return state.rebuild((a) => a..groupUsers = ListBuilder(action.users)); +} + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..038a311 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,8 @@ +import "package:circles_app/circles_app.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; + +void main() { + configureLogger(); + runApp(CirclesApp()); +} diff --git a/lib/model/calendar_entry.dart b/lib/model/calendar_entry.dart new file mode 100644 index 0000000..44e5058 --- /dev/null +++ b/lib/model/calendar_entry.dart @@ -0,0 +1,22 @@ +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'calendar_entry.g.dart'; + +abstract class CalendarEntry implements Built { + String get channelId; + + String get channelName; + + String get groupId; + + String get groupName; + + DateTime get eventDate; + + bool get hasStartTime; + + CalendarEntry._(); + + factory CalendarEntry([void Function(CalendarEntryBuilder) updates]) = _$CalendarEntry; +} diff --git a/lib/model/calendar_entry.g.dart b/lib/model/calendar_entry.g.dart new file mode 100644 index 0000000..f9687bb --- /dev/null +++ b/lib/model/calendar_entry.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'calendar_entry.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$CalendarEntry extends CalendarEntry { + @override + final String channelId; + @override + final String channelName; + @override + final String groupId; + @override + final String groupName; + @override + final DateTime eventDate; + @override + final bool hasStartTime; + + factory _$CalendarEntry([void Function(CalendarEntryBuilder) updates]) => + (new CalendarEntryBuilder()..update(updates)).build(); + + _$CalendarEntry._( + {this.channelId, + this.channelName, + this.groupId, + this.groupName, + this.eventDate, + this.hasStartTime}) + : super._() { + if (channelId == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'channelId'); + } + if (channelName == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'channelName'); + } + if (groupId == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'groupId'); + } + if (groupName == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'groupName'); + } + if (eventDate == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'eventDate'); + } + if (hasStartTime == null) { + throw new BuiltValueNullFieldError('CalendarEntry', 'hasStartTime'); + } + } + + @override + CalendarEntry rebuild(void Function(CalendarEntryBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + CalendarEntryBuilder toBuilder() => new CalendarEntryBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is CalendarEntry && + channelId == other.channelId && + channelName == other.channelName && + groupId == other.groupId && + groupName == other.groupName && + eventDate == other.eventDate && + hasStartTime == other.hasStartTime; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc($jc($jc(0, channelId.hashCode), channelName.hashCode), + groupId.hashCode), + groupName.hashCode), + eventDate.hashCode), + hasStartTime.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('CalendarEntry') + ..add('channelId', channelId) + ..add('channelName', channelName) + ..add('groupId', groupId) + ..add('groupName', groupName) + ..add('eventDate', eventDate) + ..add('hasStartTime', hasStartTime)) + .toString(); + } +} + +class CalendarEntryBuilder + implements Builder { + _$CalendarEntry _$v; + + String _channelId; + String get channelId => _$this._channelId; + set channelId(String channelId) => _$this._channelId = channelId; + + String _channelName; + String get channelName => _$this._channelName; + set channelName(String channelName) => _$this._channelName = channelName; + + String _groupId; + String get groupId => _$this._groupId; + set groupId(String groupId) => _$this._groupId = groupId; + + String _groupName; + String get groupName => _$this._groupName; + set groupName(String groupName) => _$this._groupName = groupName; + + DateTime _eventDate; + DateTime get eventDate => _$this._eventDate; + set eventDate(DateTime eventDate) => _$this._eventDate = eventDate; + + bool _hasStartTime; + bool get hasStartTime => _$this._hasStartTime; + set hasStartTime(bool hasStartTime) => _$this._hasStartTime = hasStartTime; + + CalendarEntryBuilder(); + + CalendarEntryBuilder get _$this { + if (_$v != null) { + _channelId = _$v.channelId; + _channelName = _$v.channelName; + _groupId = _$v.groupId; + _groupName = _$v.groupName; + _eventDate = _$v.eventDate; + _hasStartTime = _$v.hasStartTime; + _$v = null; + } + return this; + } + + @override + void replace(CalendarEntry other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$CalendarEntry; + } + + @override + void update(void Function(CalendarEntryBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$CalendarEntry build() { + final _$result = _$v ?? + new _$CalendarEntry._( + channelId: channelId, + channelName: channelName, + groupId: groupId, + groupName: groupName, + eventDate: eventDate, + hasStartTime: hasStartTime); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/channel.dart b/lib/model/channel.dart new file mode 100644 index 0000000..d53df08 --- /dev/null +++ b/lib/model/channel.dart @@ -0,0 +1,131 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'channel.g.dart'; + +abstract class Channel implements Built { + @nullable + String get id; + + String get name; + + @nullable + String get description; + + ChannelVisibility get visibility; + + BuiltList get users; + + @nullable + String get authorId; + + @nullable + bool get hasUpdates; + + ChannelType get type; + + @nullable + String get venue; + + @nullable + DateTime get startDate; + + @nullable + bool get hasStartTime; + + Channel._(); + + factory Channel([void Function(ChannelBuilder) updates]) = _$Channel; +} + +abstract class ChannelUser implements Built { + String get id; + + RSVP get rsvp; + + ChannelUser._(); + + factory ChannelUser([void Function(ChannelUserBuilder) updates]) = + _$ChannelUser; +} + +enum RSVP { YES, MAYBE, NO, UNSET } + +class RSVPHelper { + static String stringOf(RSVP rsvp) { + switch (rsvp) { + case RSVP.YES: + return "YES"; + case RSVP.MAYBE: + return "MAYBE"; + case RSVP.NO: + return "NO"; + case RSVP.UNSET: + default: + return "UNSET"; + } + } + + static RSVP valueOf(String string) { + switch (string) { + case "YES": + return RSVP.YES; + case "MAYBE": + return RSVP.MAYBE; + case "NO": + return RSVP.NO; + case "UNSET": + default: + return RSVP.UNSET; + } + } +} + +enum ChannelVisibility { OPEN, CLOSED } + +class ChannelVisibilityHelper { + static String stringOf(ChannelVisibility visibility) { + switch (visibility) { + case ChannelVisibility.OPEN: + return "OPEN"; + case ChannelVisibility.CLOSED: + return "CLOSED"; + } + return null; + } + + static ChannelVisibility valueOf(String string) { + switch (string) { + case "OPEN": + return ChannelVisibility.OPEN; + case "CLOSED": + return ChannelVisibility.CLOSED; + } + return null; + } +} + +enum ChannelType { TOPIC, EVENT } + +class ChannelTypeHelper { + static String stringOf(ChannelType type) { + switch (type) { + case ChannelType.EVENT: + return "EVENT"; + case ChannelType.TOPIC: + return "TOPIC"; + } + return null; + } + + static ChannelType valueOf(String string) { + switch (string) { + case "EVENT": + return ChannelType.EVENT; + case "TOPIC": + return ChannelType.TOPIC; + } + return null; + } +} diff --git a/lib/model/channel.g.dart b/lib/model/channel.g.dart new file mode 100644 index 0000000..d89471d --- /dev/null +++ b/lib/model/channel.g.dart @@ -0,0 +1,330 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Channel extends Channel { + @override + final String id; + @override + final String name; + @override + final String description; + @override + final ChannelVisibility visibility; + @override + final BuiltList users; + @override + final String authorId; + @override + final bool hasUpdates; + @override + final ChannelType type; + @override + final String venue; + @override + final DateTime startDate; + @override + final bool hasStartTime; + + factory _$Channel([void Function(ChannelBuilder) updates]) => + (new ChannelBuilder()..update(updates)).build(); + + _$Channel._( + {this.id, + this.name, + this.description, + this.visibility, + this.users, + this.authorId, + this.hasUpdates, + this.type, + this.venue, + this.startDate, + this.hasStartTime}) + : super._() { + if (name == null) { + throw new BuiltValueNullFieldError('Channel', 'name'); + } + if (visibility == null) { + throw new BuiltValueNullFieldError('Channel', 'visibility'); + } + if (users == null) { + throw new BuiltValueNullFieldError('Channel', 'users'); + } + if (type == null) { + throw new BuiltValueNullFieldError('Channel', 'type'); + } + } + + @override + Channel rebuild(void Function(ChannelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelBuilder toBuilder() => new ChannelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Channel && + id == other.id && + name == other.name && + description == other.description && + visibility == other.visibility && + users == other.users && + authorId == other.authorId && + hasUpdates == other.hasUpdates && + type == other.type && + venue == other.venue && + startDate == other.startDate && + hasStartTime == other.hasStartTime; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc($jc($jc(0, id.hashCode), name.hashCode), + description.hashCode), + visibility.hashCode), + users.hashCode), + authorId.hashCode), + hasUpdates.hashCode), + type.hashCode), + venue.hashCode), + startDate.hashCode), + hasStartTime.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('Channel') + ..add('id', id) + ..add('name', name) + ..add('description', description) + ..add('visibility', visibility) + ..add('users', users) + ..add('authorId', authorId) + ..add('hasUpdates', hasUpdates) + ..add('type', type) + ..add('venue', venue) + ..add('startDate', startDate) + ..add('hasStartTime', hasStartTime)) + .toString(); + } +} + +class ChannelBuilder implements Builder { + _$Channel _$v; + + String _id; + String get id => _$this._id; + set id(String id) => _$this._id = id; + + String _name; + String get name => _$this._name; + set name(String name) => _$this._name = name; + + String _description; + String get description => _$this._description; + set description(String description) => _$this._description = description; + + ChannelVisibility _visibility; + ChannelVisibility get visibility => _$this._visibility; + set visibility(ChannelVisibility visibility) => + _$this._visibility = visibility; + + ListBuilder _users; + ListBuilder get users => + _$this._users ??= new ListBuilder(); + set users(ListBuilder users) => _$this._users = users; + + String _authorId; + String get authorId => _$this._authorId; + set authorId(String authorId) => _$this._authorId = authorId; + + bool _hasUpdates; + bool get hasUpdates => _$this._hasUpdates; + set hasUpdates(bool hasUpdates) => _$this._hasUpdates = hasUpdates; + + ChannelType _type; + ChannelType get type => _$this._type; + set type(ChannelType type) => _$this._type = type; + + String _venue; + String get venue => _$this._venue; + set venue(String venue) => _$this._venue = venue; + + DateTime _startDate; + DateTime get startDate => _$this._startDate; + set startDate(DateTime startDate) => _$this._startDate = startDate; + + bool _hasStartTime; + bool get hasStartTime => _$this._hasStartTime; + set hasStartTime(bool hasStartTime) => _$this._hasStartTime = hasStartTime; + + ChannelBuilder(); + + ChannelBuilder get _$this { + if (_$v != null) { + _id = _$v.id; + _name = _$v.name; + _description = _$v.description; + _visibility = _$v.visibility; + _users = _$v.users?.toBuilder(); + _authorId = _$v.authorId; + _hasUpdates = _$v.hasUpdates; + _type = _$v.type; + _venue = _$v.venue; + _startDate = _$v.startDate; + _hasStartTime = _$v.hasStartTime; + _$v = null; + } + return this; + } + + @override + void replace(Channel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$Channel; + } + + @override + void update(void Function(ChannelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$Channel build() { + _$Channel _$result; + try { + _$result = _$v ?? + new _$Channel._( + id: id, + name: name, + description: description, + visibility: visibility, + users: users.build(), + authorId: authorId, + hasUpdates: hasUpdates, + type: type, + venue: venue, + startDate: startDate, + hasStartTime: hasStartTime); + } catch (_) { + String _$failedField; + try { + _$failedField = 'users'; + users.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'Channel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$ChannelUser extends ChannelUser { + @override + final String id; + @override + final RSVP rsvp; + + factory _$ChannelUser([void Function(ChannelUserBuilder) updates]) => + (new ChannelUserBuilder()..update(updates)).build(); + + _$ChannelUser._({this.id, this.rsvp}) : super._() { + if (id == null) { + throw new BuiltValueNullFieldError('ChannelUser', 'id'); + } + if (rsvp == null) { + throw new BuiltValueNullFieldError('ChannelUser', 'rsvp'); + } + } + + @override + ChannelUser rebuild(void Function(ChannelUserBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelUserBuilder toBuilder() => new ChannelUserBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChannelUser && id == other.id && rsvp == other.rsvp; + } + + @override + int get hashCode { + return $jf($jc($jc(0, id.hashCode), rsvp.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChannelUser') + ..add('id', id) + ..add('rsvp', rsvp)) + .toString(); + } +} + +class ChannelUserBuilder implements Builder { + _$ChannelUser _$v; + + String _id; + String get id => _$this._id; + set id(String id) => _$this._id = id; + + RSVP _rsvp; + RSVP get rsvp => _$this._rsvp; + set rsvp(RSVP rsvp) => _$this._rsvp = rsvp; + + ChannelUserBuilder(); + + ChannelUserBuilder get _$this { + if (_$v != null) { + _id = _$v.id; + _rsvp = _$v.rsvp; + _$v = null; + } + return this; + } + + @override + void replace(ChannelUser other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChannelUser; + } + + @override + void update(void Function(ChannelUserBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChannelUser build() { + final _$result = _$v ?? new _$ChannelUser._(id: id, rsvp: rsvp); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/channel_state.dart b/lib/model/channel_state.dart new file mode 100644 index 0000000..af71c91 --- /dev/null +++ b/lib/model/channel_state.dart @@ -0,0 +1,21 @@ +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'channel_state.g.dart'; + +abstract class ChannelState + implements Built { + @nullable + String get selectedChannel; + + bool get joinChannelFailed; + + ChannelState._(); + + factory ChannelState([void Function(ChannelStateBuilder) updates]) = + _$ChannelState; + + factory ChannelState.init() => ChannelState((c) => c + ..selectedChannel = null + ..joinChannelFailed = false); +} diff --git a/lib/model/channel_state.g.dart b/lib/model/channel_state.g.dart new file mode 100644 index 0000000..12dc905 --- /dev/null +++ b/lib/model/channel_state.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_state.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ChannelState extends ChannelState { + @override + final String selectedChannel; + @override + final bool joinChannelFailed; + + factory _$ChannelState([void Function(ChannelStateBuilder) updates]) => + (new ChannelStateBuilder()..update(updates)).build(); + + _$ChannelState._({this.selectedChannel, this.joinChannelFailed}) : super._() { + if (joinChannelFailed == null) { + throw new BuiltValueNullFieldError('ChannelState', 'joinChannelFailed'); + } + } + + @override + ChannelState rebuild(void Function(ChannelStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelStateBuilder toBuilder() => new ChannelStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChannelState && + selectedChannel == other.selectedChannel && + joinChannelFailed == other.joinChannelFailed; + } + + @override + int get hashCode { + return $jf( + $jc($jc(0, selectedChannel.hashCode), joinChannelFailed.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChannelState') + ..add('selectedChannel', selectedChannel) + ..add('joinChannelFailed', joinChannelFailed)) + .toString(); + } +} + +class ChannelStateBuilder + implements Builder { + _$ChannelState _$v; + + String _selectedChannel; + String get selectedChannel => _$this._selectedChannel; + set selectedChannel(String selectedChannel) => + _$this._selectedChannel = selectedChannel; + + bool _joinChannelFailed; + bool get joinChannelFailed => _$this._joinChannelFailed; + set joinChannelFailed(bool joinChannelFailed) => + _$this._joinChannelFailed = joinChannelFailed; + + ChannelStateBuilder(); + + ChannelStateBuilder get _$this { + if (_$v != null) { + _selectedChannel = _$v.selectedChannel; + _joinChannelFailed = _$v.joinChannelFailed; + _$v = null; + } + return this; + } + + @override + void replace(ChannelState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChannelState; + } + + @override + void update(void Function(ChannelStateBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChannelState build() { + final _$result = _$v ?? + new _$ChannelState._( + selectedChannel: selectedChannel, + joinChannelFailed: joinChannelFailed); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/group.dart b/lib/model/group.dart new file mode 100644 index 0000000..77c569e --- /dev/null +++ b/lib/model/group.dart @@ -0,0 +1,25 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/model/channel.dart"; + +// ignore: prefer_double_quotes +part 'group.g.dart'; + +abstract class Group implements Built { + String get id; + + String get name; + + String get hexColor; + + @nullable + String get image; + + String get abbreviation; + + BuiltMap get channels; + + Group._(); + + factory Group([void Function(GroupBuilder) updates]) = _$Group; +} diff --git a/lib/model/group.g.dart b/lib/model/group.g.dart new file mode 100644 index 0000000..458ca49 --- /dev/null +++ b/lib/model/group.g.dart @@ -0,0 +1,178 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Group extends Group { + @override + final String id; + @override + final String name; + @override + final String hexColor; + @override + final String image; + @override + final String abbreviation; + @override + final BuiltMap channels; + + factory _$Group([void Function(GroupBuilder) updates]) => + (new GroupBuilder()..update(updates)).build(); + + _$Group._( + {this.id, + this.name, + this.hexColor, + this.image, + this.abbreviation, + this.channels}) + : super._() { + if (id == null) { + throw new BuiltValueNullFieldError('Group', 'id'); + } + if (name == null) { + throw new BuiltValueNullFieldError('Group', 'name'); + } + if (hexColor == null) { + throw new BuiltValueNullFieldError('Group', 'hexColor'); + } + if (abbreviation == null) { + throw new BuiltValueNullFieldError('Group', 'abbreviation'); + } + if (channels == null) { + throw new BuiltValueNullFieldError('Group', 'channels'); + } + } + + @override + Group rebuild(void Function(GroupBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + GroupBuilder toBuilder() => new GroupBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Group && + id == other.id && + name == other.name && + hexColor == other.hexColor && + image == other.image && + abbreviation == other.abbreviation && + channels == other.channels; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc($jc($jc($jc(0, id.hashCode), name.hashCode), hexColor.hashCode), + image.hashCode), + abbreviation.hashCode), + channels.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('Group') + ..add('id', id) + ..add('name', name) + ..add('hexColor', hexColor) + ..add('image', image) + ..add('abbreviation', abbreviation) + ..add('channels', channels)) + .toString(); + } +} + +class GroupBuilder implements Builder { + _$Group _$v; + + String _id; + String get id => _$this._id; + set id(String id) => _$this._id = id; + + String _name; + String get name => _$this._name; + set name(String name) => _$this._name = name; + + String _hexColor; + String get hexColor => _$this._hexColor; + set hexColor(String hexColor) => _$this._hexColor = hexColor; + + String _image; + String get image => _$this._image; + set image(String image) => _$this._image = image; + + String _abbreviation; + String get abbreviation => _$this._abbreviation; + set abbreviation(String abbreviation) => _$this._abbreviation = abbreviation; + + MapBuilder _channels; + MapBuilder get channels => + _$this._channels ??= new MapBuilder(); + set channels(MapBuilder channels) => + _$this._channels = channels; + + GroupBuilder(); + + GroupBuilder get _$this { + if (_$v != null) { + _id = _$v.id; + _name = _$v.name; + _hexColor = _$v.hexColor; + _image = _$v.image; + _abbreviation = _$v.abbreviation; + _channels = _$v.channels?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(Group other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$Group; + } + + @override + void update(void Function(GroupBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$Group build() { + _$Group _$result; + try { + _$result = _$v ?? + new _$Group._( + id: id, + name: name, + hexColor: hexColor, + image: image, + abbreviation: abbreviation, + channels: channels.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'channels'; + channels.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'Group', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/in_app_notification.dart b/lib/model/in_app_notification.dart new file mode 100644 index 0000000..951ed2f --- /dev/null +++ b/lib/model/in_app_notification.dart @@ -0,0 +1,21 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/model/channel.dart"; + +// ignore: prefer_double_quotes +part 'in_app_notification.g.dart'; + +abstract class InAppNotification implements Built { + + String get groupId; + + String get groupName; + + String get userName; + + String get message; + + Channel get channel; + + InAppNotification._(); + factory InAppNotification([void Function(InAppNotificationBuilder) updates]) = _$InAppNotification; +} \ No newline at end of file diff --git a/lib/model/in_app_notification.g.dart b/lib/model/in_app_notification.g.dart new file mode 100644 index 0000000..23d883c --- /dev/null +++ b/lib/model/in_app_notification.g.dart @@ -0,0 +1,164 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'in_app_notification.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$InAppNotification extends InAppNotification { + @override + final String groupId; + @override + final String groupName; + @override + final String userName; + @override + final String message; + @override + final Channel channel; + + factory _$InAppNotification( + [void Function(InAppNotificationBuilder) updates]) => + (new InAppNotificationBuilder()..update(updates)).build(); + + _$InAppNotification._( + {this.groupId, this.groupName, this.userName, this.message, this.channel}) + : super._() { + if (groupId == null) { + throw new BuiltValueNullFieldError('InAppNotification', 'groupId'); + } + if (groupName == null) { + throw new BuiltValueNullFieldError('InAppNotification', 'groupName'); + } + if (userName == null) { + throw new BuiltValueNullFieldError('InAppNotification', 'userName'); + } + if (message == null) { + throw new BuiltValueNullFieldError('InAppNotification', 'message'); + } + if (channel == null) { + throw new BuiltValueNullFieldError('InAppNotification', 'channel'); + } + } + + @override + InAppNotification rebuild(void Function(InAppNotificationBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + InAppNotificationBuilder toBuilder() => + new InAppNotificationBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is InAppNotification && + groupId == other.groupId && + groupName == other.groupName && + userName == other.userName && + message == other.message && + channel == other.channel; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc($jc($jc(0, groupId.hashCode), groupName.hashCode), + userName.hashCode), + message.hashCode), + channel.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('InAppNotification') + ..add('groupId', groupId) + ..add('groupName', groupName) + ..add('userName', userName) + ..add('message', message) + ..add('channel', channel)) + .toString(); + } +} + +class InAppNotificationBuilder + implements Builder { + _$InAppNotification _$v; + + String _groupId; + String get groupId => _$this._groupId; + set groupId(String groupId) => _$this._groupId = groupId; + + String _groupName; + String get groupName => _$this._groupName; + set groupName(String groupName) => _$this._groupName = groupName; + + String _userName; + String get userName => _$this._userName; + set userName(String userName) => _$this._userName = userName; + + String _message; + String get message => _$this._message; + set message(String message) => _$this._message = message; + + ChannelBuilder _channel; + ChannelBuilder get channel => _$this._channel ??= new ChannelBuilder(); + set channel(ChannelBuilder channel) => _$this._channel = channel; + + InAppNotificationBuilder(); + + InAppNotificationBuilder get _$this { + if (_$v != null) { + _groupId = _$v.groupId; + _groupName = _$v.groupName; + _userName = _$v.userName; + _message = _$v.message; + _channel = _$v.channel?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(InAppNotification other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$InAppNotification; + } + + @override + void update(void Function(InAppNotificationBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$InAppNotification build() { + _$InAppNotification _$result; + try { + _$result = _$v ?? + new _$InAppNotification._( + groupId: groupId, + groupName: groupName, + userName: userName, + message: message, + channel: channel.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'channel'; + channel.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'InAppNotification', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/message.dart b/lib/model/message.dart new file mode 100644 index 0000000..1035890 --- /dev/null +++ b/lib/model/message.dart @@ -0,0 +1,137 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'message.g.dart'; + +abstract class Message implements Built { + @nullable + String get id; + + @nullable + String get authorId; + + String get body; + + BuiltMap get reactions; + + MessageType get messageType; + + bool get pending; + + DateTime get timestamp; + + BuiltList get media; + + @nullable + MediaStatus get mediaStatus; + + @nullable + double get mediaAspectRatio; + + Message._(); + + factory Message([void Function(MessageBuilder) updates]) = _$Message; + + Map reactionsCount() { + if (reactions == null) { + return {}; + } + final map = Map(); + for (final reaction in reactions.values) { + map[reaction.emoji] = (map[reaction.emoji] ?? 0) + 1; + } + return map; + } +} + +/// Custom Builder to allow defaults +abstract class MessageBuilder implements Builder { + + @nullable + String id; + + @nullable + String authorId; + + String body; + + MapBuilder reactions; + + MessageType messageType = MessageType.USER; + + bool pending = false; + + ListBuilder media; + + MediaStatus mediaStatus = MediaStatus.ERROR; + + double mediaAspectRatio = 1.0; + + DateTime timestamp = DateTime.now(); + + factory MessageBuilder() = _$MessageBuilder; + MessageBuilder._(); +} + +enum MessageType { SYSTEM, RSVP, USER, MEDIA } + +class MessageTypeHelper { + static String stringOf(MessageType messageType) { + switch (messageType) { + case MessageType.SYSTEM: + return "SYSTEM"; + case MessageType.RSVP: + return "RSVP"; + case MessageType.MEDIA: + return "MEDIA"; + default: + return "USER"; + } + } + + static MessageType valueOf(String string) { + switch (string) { + case "SYSTEM": + return MessageType.SYSTEM; + case "RSVP": + return MessageType.RSVP; + case "MEDIA": + return MessageType.MEDIA; + default: + return MessageType.USER; + } + } +} + +enum MediaStatus { UPLOADING, DONE, ERROR } + +class MediaStatusHelper { + static String stringOf(MediaStatus mediaStatus) { + switch (mediaStatus) { + case MediaStatus.UPLOADING: + return "UPLOADING"; + break; + case MediaStatus.DONE: + return "DONE"; + break; + case MediaStatus.ERROR: + default: + return "ERROR"; + break; + } + } + + static MediaStatus valueOf(String string) { + switch (string) { + case "UPLOADING": + return MediaStatus.UPLOADING; + case "DONE": + return MediaStatus.DONE; + case "ERROR": + default: + return MediaStatus.ERROR; + } + } +} diff --git a/lib/model/message.g.dart b/lib/model/message.g.dart new file mode 100644 index 0000000..c929764 --- /dev/null +++ b/lib/model/message.g.dart @@ -0,0 +1,316 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Message extends Message { + @override + final String id; + @override + final String authorId; + @override + final String body; + @override + final BuiltMap reactions; + @override + final MessageType messageType; + @override + final bool pending; + @override + final DateTime timestamp; + @override + final BuiltList media; + @override + final MediaStatus mediaStatus; + @override + final double mediaAspectRatio; + + factory _$Message([void Function(MessageBuilder) updates]) => + (new MessageBuilder()..update(updates)).build() as _$Message; + + _$Message._( + {this.id, + this.authorId, + this.body, + this.reactions, + this.messageType, + this.pending, + this.timestamp, + this.media, + this.mediaStatus, + this.mediaAspectRatio}) + : super._() { + if (body == null) { + throw new BuiltValueNullFieldError('Message', 'body'); + } + if (reactions == null) { + throw new BuiltValueNullFieldError('Message', 'reactions'); + } + if (messageType == null) { + throw new BuiltValueNullFieldError('Message', 'messageType'); + } + if (pending == null) { + throw new BuiltValueNullFieldError('Message', 'pending'); + } + if (timestamp == null) { + throw new BuiltValueNullFieldError('Message', 'timestamp'); + } + if (media == null) { + throw new BuiltValueNullFieldError('Message', 'media'); + } + } + + @override + Message rebuild(void Function(MessageBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + _$MessageBuilder toBuilder() => new _$MessageBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Message && + id == other.id && + authorId == other.authorId && + body == other.body && + reactions == other.reactions && + messageType == other.messageType && + pending == other.pending && + timestamp == other.timestamp && + media == other.media && + mediaStatus == other.mediaStatus && + mediaAspectRatio == other.mediaAspectRatio; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc($jc($jc(0, id.hashCode), authorId.hashCode), + body.hashCode), + reactions.hashCode), + messageType.hashCode), + pending.hashCode), + timestamp.hashCode), + media.hashCode), + mediaStatus.hashCode), + mediaAspectRatio.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('Message') + ..add('id', id) + ..add('authorId', authorId) + ..add('body', body) + ..add('reactions', reactions) + ..add('messageType', messageType) + ..add('pending', pending) + ..add('timestamp', timestamp) + ..add('media', media) + ..add('mediaStatus', mediaStatus) + ..add('mediaAspectRatio', mediaAspectRatio)) + .toString(); + } +} + +class _$MessageBuilder extends MessageBuilder { + _$Message _$v; + + @override + String get id { + _$this; + return super.id; + } + + @override + set id(String id) { + _$this; + super.id = id; + } + + @override + String get authorId { + _$this; + return super.authorId; + } + + @override + set authorId(String authorId) { + _$this; + super.authorId = authorId; + } + + @override + String get body { + _$this; + return super.body; + } + + @override + set body(String body) { + _$this; + super.body = body; + } + + @override + MapBuilder get reactions { + _$this; + return super.reactions ??= new MapBuilder(); + } + + @override + set reactions(MapBuilder reactions) { + _$this; + super.reactions = reactions; + } + + @override + MessageType get messageType { + _$this; + return super.messageType; + } + + @override + set messageType(MessageType messageType) { + _$this; + super.messageType = messageType; + } + + @override + bool get pending { + _$this; + return super.pending; + } + + @override + set pending(bool pending) { + _$this; + super.pending = pending; + } + + @override + DateTime get timestamp { + _$this; + return super.timestamp; + } + + @override + set timestamp(DateTime timestamp) { + _$this; + super.timestamp = timestamp; + } + + @override + ListBuilder get media { + _$this; + return super.media ??= new ListBuilder(); + } + + @override + set media(ListBuilder media) { + _$this; + super.media = media; + } + + @override + MediaStatus get mediaStatus { + _$this; + return super.mediaStatus; + } + + @override + set mediaStatus(MediaStatus mediaStatus) { + _$this; + super.mediaStatus = mediaStatus; + } + + @override + double get mediaAspectRatio { + _$this; + return super.mediaAspectRatio; + } + + @override + set mediaAspectRatio(double mediaAspectRatio) { + _$this; + super.mediaAspectRatio = mediaAspectRatio; + } + + _$MessageBuilder() : super._(); + + MessageBuilder get _$this { + if (_$v != null) { + super.id = _$v.id; + super.authorId = _$v.authorId; + super.body = _$v.body; + super.reactions = _$v.reactions?.toBuilder(); + super.messageType = _$v.messageType; + super.pending = _$v.pending; + super.timestamp = _$v.timestamp; + super.media = _$v.media?.toBuilder(); + super.mediaStatus = _$v.mediaStatus; + super.mediaAspectRatio = _$v.mediaAspectRatio; + _$v = null; + } + return this; + } + + @override + void replace(Message other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$Message; + } + + @override + void update(void Function(MessageBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$Message build() { + _$Message _$result; + try { + _$result = _$v ?? + new _$Message._( + id: id, + authorId: authorId, + body: body, + reactions: reactions.build(), + messageType: messageType, + pending: pending, + timestamp: timestamp, + media: media.build(), + mediaStatus: mediaStatus, + mediaAspectRatio: mediaAspectRatio); + } catch (_) { + String _$failedField; + try { + _$failedField = 'reactions'; + reactions.build(); + + _$failedField = 'media'; + media.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'Message', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/reaction.dart b/lib/model/reaction.dart new file mode 100644 index 0000000..61a8e4c --- /dev/null +++ b/lib/model/reaction.dart @@ -0,0 +1,19 @@ + +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'reaction.g.dart'; + +abstract class Reaction implements Built { + + String get emoji; + + String get userId; + + String get userName; + + DateTime get timestamp; + + Reaction._(); + factory Reaction([void Function(ReactionBuilder) updates]) = _$Reaction; +} \ No newline at end of file diff --git a/lib/model/reaction.g.dart b/lib/model/reaction.g.dart new file mode 100644 index 0000000..f9ff1f2 --- /dev/null +++ b/lib/model/reaction.g.dart @@ -0,0 +1,131 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reaction.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Reaction extends Reaction { + @override + final String emoji; + @override + final String userId; + @override + final String userName; + @override + final DateTime timestamp; + + factory _$Reaction([void Function(ReactionBuilder) updates]) => + (new ReactionBuilder()..update(updates)).build(); + + _$Reaction._({this.emoji, this.userId, this.userName, this.timestamp}) + : super._() { + if (emoji == null) { + throw new BuiltValueNullFieldError('Reaction', 'emoji'); + } + if (userId == null) { + throw new BuiltValueNullFieldError('Reaction', 'userId'); + } + if (userName == null) { + throw new BuiltValueNullFieldError('Reaction', 'userName'); + } + if (timestamp == null) { + throw new BuiltValueNullFieldError('Reaction', 'timestamp'); + } + } + + @override + Reaction rebuild(void Function(ReactionBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ReactionBuilder toBuilder() => new ReactionBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Reaction && + emoji == other.emoji && + userId == other.userId && + userName == other.userName && + timestamp == other.timestamp; + } + + @override + int get hashCode { + return $jf($jc( + $jc($jc($jc(0, emoji.hashCode), userId.hashCode), userName.hashCode), + timestamp.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('Reaction') + ..add('emoji', emoji) + ..add('userId', userId) + ..add('userName', userName) + ..add('timestamp', timestamp)) + .toString(); + } +} + +class ReactionBuilder implements Builder { + _$Reaction _$v; + + String _emoji; + String get emoji => _$this._emoji; + set emoji(String emoji) => _$this._emoji = emoji; + + String _userId; + String get userId => _$this._userId; + set userId(String userId) => _$this._userId = userId; + + String _userName; + String get userName => _$this._userName; + set userName(String userName) => _$this._userName = userName; + + DateTime _timestamp; + DateTime get timestamp => _$this._timestamp; + set timestamp(DateTime timestamp) => _$this._timestamp = timestamp; + + ReactionBuilder(); + + ReactionBuilder get _$this { + if (_$v != null) { + _emoji = _$v.emoji; + _userId = _$v.userId; + _userName = _$v.userName; + _timestamp = _$v.timestamp; + _$v = null; + } + return this; + } + + @override + void replace(Reaction other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$Reaction; + } + + @override + void update(void Function(ReactionBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$Reaction build() { + final _$result = _$v ?? + new _$Reaction._( + emoji: emoji, + userId: userId, + userName: userName, + timestamp: timestamp); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/model/user.dart b/lib/model/user.dart new file mode 100644 index 0000000..5a5420c --- /dev/null +++ b/lib/model/user.dart @@ -0,0 +1,34 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'user.g.dart'; + +abstract class User implements Built { + String get uid; + + String get email; + + String get name; + + @nullable + String get status; + + // Keeps groupId : [channelId], marking the unread channels. + @nullable + BuiltMap get unreadUpdates; + + @nullable + String get image; + + User._(); + + factory User([void Function(UserBuilder) updates]) = _$User; +} + +class UserHelper { + static List userIds(List userIds) { + if (userIds == null) return []; + return userIds.whereType().toList(); + } +} diff --git a/lib/model/user.g.dart b/lib/model/user.g.dart new file mode 100644 index 0000000..6d33c84 --- /dev/null +++ b/lib/model/user.g.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$User extends User { + @override + final String uid; + @override + final String email; + @override + final String name; + @override + final String status; + @override + final BuiltMap unreadUpdates; + @override + final String image; + + factory _$User([void Function(UserBuilder) updates]) => + (new UserBuilder()..update(updates)).build(); + + _$User._( + {this.uid, + this.email, + this.name, + this.status, + this.unreadUpdates, + this.image}) + : super._() { + if (uid == null) { + throw new BuiltValueNullFieldError('User', 'uid'); + } + if (email == null) { + throw new BuiltValueNullFieldError('User', 'email'); + } + if (name == null) { + throw new BuiltValueNullFieldError('User', 'name'); + } + } + + @override + User rebuild(void Function(UserBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UserBuilder toBuilder() => new UserBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is User && + uid == other.uid && + email == other.email && + name == other.name && + status == other.status && + unreadUpdates == other.unreadUpdates && + image == other.image; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc($jc($jc($jc(0, uid.hashCode), email.hashCode), name.hashCode), + status.hashCode), + unreadUpdates.hashCode), + image.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('User') + ..add('uid', uid) + ..add('email', email) + ..add('name', name) + ..add('status', status) + ..add('unreadUpdates', unreadUpdates) + ..add('image', image)) + .toString(); + } +} + +class UserBuilder implements Builder { + _$User _$v; + + String _uid; + String get uid => _$this._uid; + set uid(String uid) => _$this._uid = uid; + + String _email; + String get email => _$this._email; + set email(String email) => _$this._email = email; + + String _name; + String get name => _$this._name; + set name(String name) => _$this._name = name; + + String _status; + String get status => _$this._status; + set status(String status) => _$this._status = status; + + MapBuilder _unreadUpdates; + MapBuilder get unreadUpdates => + _$this._unreadUpdates ??= new MapBuilder(); + set unreadUpdates(MapBuilder unreadUpdates) => + _$this._unreadUpdates = unreadUpdates; + + String _image; + String get image => _$this._image; + set image(String image) => _$this._image = image; + + UserBuilder(); + + UserBuilder get _$this { + if (_$v != null) { + _uid = _$v.uid; + _email = _$v.email; + _name = _$v.name; + _status = _$v.status; + _unreadUpdates = _$v.unreadUpdates?.toBuilder(); + _image = _$v.image; + _$v = null; + } + return this; + } + + @override + void replace(User other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$User; + } + + @override + void update(void Function(UserBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$User build() { + _$User _$result; + try { + _$result = _$v ?? + new _$User._( + uid: uid, + email: email, + name: name, + status: status, + unreadUpdates: _unreadUpdates?.build(), + image: image); + } catch (_) { + String _$failedField; + try { + _$failedField = 'unreadUpdates'; + _unreadUpdates?.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'User', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/native_channels/android_permission_channel.dart b/lib/native_channels/android_permission_channel.dart new file mode 100644 index 0000000..f953368 --- /dev/null +++ b/lib/native_channels/android_permission_channel.dart @@ -0,0 +1,39 @@ +import "package:circles_app/native_channels/ios_permission_channel.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/services.dart"; + +const ANDROID_PERMISSION_GRANTED = 0; + +class AndroidPermissionChannel { + static const MethodChannel channel = + MethodChannel("de.janoodle.timy/permission-android"); + + static Future requestPermission({ + @required PermissionType permissionType + }) async { + final result = await channel.invokeMethod( + "requestPermission", + { + "permissionType": _stringOf(permissionType), + }, + ); + final int granted = result[_stringOf(permissionType)]; + if (granted == ANDROID_PERMISSION_GRANTED) { + Logger.d("Permission granted"); + return true; + } else { + Logger.w("Permission denied for ${_stringOf(permissionType)}"); + return false; + } + } + + static String _stringOf(PermissionType permissionType) { + switch (permissionType) { + case PermissionType.Photos: + return "android.permission.READ_EXTERNAL_STORAGE"; + default: + return ""; + } + } +} \ No newline at end of file diff --git a/lib/native_channels/android_thumbnail_channel.dart b/lib/native_channels/android_thumbnail_channel.dart new file mode 100644 index 0000000..a7fa8bc --- /dev/null +++ b/lib/native_channels/android_thumbnail_channel.dart @@ -0,0 +1,20 @@ +import "dart:typed_data"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +const MethodChannel _channel = MethodChannel("de.janoodle.timy/thumbnails-android"); + +Future getThumbnailBitmap({ + @required String fileId, + @required int type, +}) async { + final Uint8List data = await _channel.invokeMethod( + "getThumbnailBitmap", + { + "fileId": fileId, + "type": type, + }, + ); + return data; +} diff --git a/lib/native_channels/ios_permission_channel.dart b/lib/native_channels/ios_permission_channel.dart new file mode 100644 index 0000000..046b8f8 --- /dev/null +++ b/lib/native_channels/ios_permission_channel.dart @@ -0,0 +1,31 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/services.dart"; + +enum PermissionType { Photos, Camera } + +class IOSPermissionChannel { + static const MethodChannel channel = + MethodChannel("de.janoodle.timy/permission-ios"); + + static Future requestPermission({ + @required PermissionType permissionType + }) { + return channel.invokeMethod( + "requestPermission", + { + "permissionType": _stringOf(permissionType), + }, + ); + } + + static String _stringOf(PermissionType permissionType) { + switch (permissionType) { + case PermissionType.Camera: + return "CAMERA"; + case PermissionType.Photos: + return "PHOTOS"; + default: + return ""; + } + } +} \ No newline at end of file diff --git a/lib/native_channels/upload_platform.dart b/lib/native_channels/upload_platform.dart new file mode 100644 index 0000000..361524a --- /dev/null +++ b/lib/native_channels/upload_platform.dart @@ -0,0 +1,24 @@ +import "package:flutter/services.dart"; +import "package:meta/meta.dart"; + +class UploadPlatform { + static const MethodChannel channel = + MethodChannel("de.janoodle.timy/upload_platform"); + + Future uploadFiles({ + @required List filePaths, // Path to files on Android and a photo just taken on iOS + @required List localIdentifiers, // iOS files localIdentifiers + @required String groupId, + @required String channelId, + }) { + return channel.invokeMethod( + "uploadFiles", + { + "filePaths": filePaths, + "localIdentifiers": localIdentifiers, + "groupId": groupId, + "channelId": channelId, + }, + ); + } +} diff --git a/lib/presentation/calendar/calendar_item.dart b/lib/presentation/calendar/calendar_item.dart new file mode 100644 index 0000000..439c912 --- /dev/null +++ b/lib/presentation/calendar/calendar_item.dart @@ -0,0 +1,37 @@ +import "package:circles_app/model/channel.dart"; + +abstract class CalendarItem {} + +class CalendarHeaderItem implements CalendarItem { + final DateTime date; + final bool isToday; + final bool isPast; + CalendarHeaderItem({ + this.date, + this.isToday, + this.isPast, + }); +} + +class CalendarEntryItem implements CalendarItem { + final String eventId; + final String groupId; + final String eventName; + final String groupName; + final DateTime date; + final RSVP rsvpStatus; + final bool isSelected; + final bool isAllDay; + final bool isPast; + CalendarEntryItem({ + this.eventId, + this.groupId, + this.eventName, + this.groupName, + this.date, + this.rsvpStatus, + this.isSelected, + this.isAllDay, + this.isPast, + }); +} diff --git a/lib/presentation/calendar/calendar_screen.dart b/lib/presentation/calendar/calendar_screen.dart new file mode 100644 index 0000000..38b434b --- /dev/null +++ b/lib/presentation/calendar/calendar_screen.dart @@ -0,0 +1,344 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/presentation/calendar/calendar_item.dart"; +import "package:circles_app/presentation/calendar/calendar_screen_viewmodel.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/date_formatting.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +@immutable +class CalendarScreen extends StatelessWidget { + final _scrollController = ScrollController(); + + /// Calendar header + + _buildHeaderItem( + context, + CalendarHeaderItem item, + ) { + final datePrefix = item.isToday + ? CirclesLocalizations.of(context).calendarStringToday + : ""; + return Opacity( + opacity: item.isPast ? _Style.pastItemOpacity : 1.0, + child: Container( + padding: _Style.defaultElementPadding, + alignment: _Style.calenderHeaderAlignment, + height: _Style.calenderHeaderHeight, + child: Text( + "$datePrefix ${formatCalendarDate(context, item.date)}", + style: AppTheme.calendarDayTitle, + maxLines: 1, + textScaleFactor: 1, + overflow: TextOverflow.clip, + ), + ), + ); + } + + /// Calendar entry + + _buildEntryItem( + context, + CalendarEntryItem item, + ) { + return Opacity( + opacity: item.isPast ? _Style.pastItemOpacity : 1.0, + child: Container( + margin: EdgeInsets.only( + left: DrawerStyle.defaultPadding, + right: DrawerStyle.defaultPadding, + ), + decoration: BoxDecoration( + color: item.isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + padding: _Style.defaultElementPadding, + height: _Style.calenderItemHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _timeWidget( + context, + item.date, + item.isAllDay, + ), + Container( + padding: EdgeInsets.only(left: DrawerStyle.defaultPadding), + width: + DrawerStyle.listWidth - (90 + DrawerStyle.defaultPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.eventName, + style: AppTheme.calendarListEventName, + maxLines: 1, + textScaleFactor: 1, + overflow: TextOverflow.ellipsis, + ), + Padding( + padding: EdgeInsets.only(top: 5), + ), + Text( + item.groupName, + style: AppTheme.calendarListGroupName, + maxLines: 1, + textScaleFactor: 1, + overflow: TextOverflow.ellipsis, + ), + ], + )), + ], + ), + ), + ); + } + + _timeWidget( + BuildContext context, + DateTime date, + bool isAllDay, + ) { + return Container( + width: 70, + child: Center( + child: Container( + decoration: BoxDecoration( + color: AppTheme.colorDarkBlue, + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + height: 24, + width: 60, + child: Center( + child: Text( + isAllDay + ? CirclesLocalizations.of(context).calendarStringAllDay + : formatTime(context, date), + textScaleFactor: 1, + style: AppTheme.calendarListTime, + )), + // color: AppTheme.colorDarkBlue, + ))); + } + + /// Calendar + + _buildCalendarList( + BuildContext context, + CalendarScreenViewModel viewModel, + ) { + final numberOfHeaderItems = viewModel.headerItemSizeMap.keys.length; + final items = viewModel.calendar.toList(); + double lastSectionHeight = 0; + + if (numberOfHeaderItems > 0) { + final lastHeaderItems = + viewModel.headerItemSizeMap[numberOfHeaderItems - 1]; + lastSectionHeight = _Style.calenderHeaderHeight + + _Style.calenderItemHeight * lastHeaderItems; + } + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return ListView.builder( + padding: EdgeInsets.only( + top: _Style.titleHeight, + bottom: constraints.minHeight - (lastSectionHeight + _Style.titleHeight)), + controller: _scrollController, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + + if (item is CalendarHeaderItem) { + return _buildHeaderItem( + context, + item, + ); + } else if (item is CalendarEntryItem) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: DrawerStyle.selectionBorderRadius, + highlightColor: Colors.white24, + onTap: () { + _selectCalendarEvent(context: context, item: item); + }, + child: _buildEntryItem( + context, + item, + ), + )); + } + + return Container(); + }); + }); + } + + _selectCalendarEvent({ + context, + item, + }) { + Navigator.pop(context); + + final provider = StoreProvider.of(context); + final previousChannelId = provider.state.channelState.selectedChannel; + + provider.dispatch(SelectGroup(item.groupId)); + + provider.dispatch( + SelectChannelIdAction( + channelId: item.eventId, + groupId: provider.state.selectedGroupId, + userId: provider.state.user.uid, + previousChannelId: previousChannelId, + ), + ); + } + + _calendar( + BuildContext context, + CalendarScreenViewModel viewModel, + ) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + children: [ + Container( + width: DrawerStyle.listWidth, + height: constraints.maxHeight, + child: _buildCalendarList(context, viewModel), + ), + Container( + color: Colors.white.withAlpha(240), + padding: _Style.defaultElementPadding, + width: DrawerStyle.listWidth, + height: _Style.titleHeight, + child: Stack( + children: [ + Positioned.fill( + child: Align( + alignment: Alignment.bottomLeft, + child: Container( + alignment: Alignment(-1.0, 0.6), + child: Text( + CirclesLocalizations.of(context).calendarTitle, + style: AppTheme.circleTitle, + textScaleFactor: 1, + ), + ), + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomRight, + child: Container( + width: 50, + child: FlatButton( + child: Image.asset( + "assets/graphics/calendar/calendar_today.png"), + onPressed: () { + _setScrollOffsetToSelectedEntry( + animated: true, + viewModel: viewModel, + toSelectedEntry: false, + ); + }, + ), + ), + ), + ), + ], + ), + ), + ], + ); + }); + } + + /// Setting the offset to upcoming events or a selected item + _setScrollOffsetToSelectedEntry({ + CalendarScreenViewModel viewModel, + bool toSelectedEntry, + bool animated, + }) { + final headerIndex = toSelectedEntry + ? viewModel.selectedEventHeaderIndex + : viewModel.upcomingEventHeaderIndex; + + final headersHeight = (headerIndex * _Style.calenderHeaderHeight); + var items = 0; + + viewModel.headerItemSizeMap.forEach((key, value) { + if (key <= headerIndex) { + items += value; + } + }); + + final itemsHeight = items * _Style.calenderItemHeight; + final offset = itemsHeight + headersHeight; + + if (animated) { + _scrollController.animateTo( + offset, + curve: Curves.elasticInOut, + duration: Duration(milliseconds: 400), + ); + } else { + _scrollController.jumpTo(offset); + } + } + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: CalendarScreenViewModel.fromStore, + distinct: true, + builder: (context, viewModel) { + final calendar = _calendar(context, viewModel); + + // Scrolls to upcoming events or selected group + Future.delayed(Duration(milliseconds: 0), () { + if (_scrollController.offset == 0) { + _setScrollOffsetToSelectedEntry( + viewModel: viewModel, + toSelectedEntry: viewModel.selectedEventHeaderIndex != -1, + animated: false, + ); + } + }); + + return calendar; + }); + } +} + +/// Private Styles +class _Style { + static const topSectionHeight = + _Style.titleHeight + DrawerStyle.sectionPadding * 2; + + static const pastItemOpacity = 0.6; + static const defaultElementPadding = EdgeInsets.only( + left: 4, + right: 4, + ); + + static const titleHeight = 100.0; + + static const calenderHeaderHeight = 32.0; + static const calenderHeaderAlignment = AlignmentDirectional(-1.0, 0.0); + + static const calenderItemHeight = 62.0; +} diff --git a/lib/presentation/calendar/calendar_screen_viewmodel.dart b/lib/presentation/calendar/calendar_screen_viewmodel.dart new file mode 100644 index 0000000..cab3ebf --- /dev/null +++ b/lib/presentation/calendar/calendar_screen_viewmodel.dart @@ -0,0 +1,112 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/calendar/calendar_item.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'calendar_screen_viewmodel.g.dart'; + +abstract class CalendarScreenViewModel + implements Built { + BuiltList get calendar; + + /// Indicating a selected event. -1 if nothing is selected. + int get selectedEventHeaderIndex; + + /// Index of header closes to now + int get upcomingEventHeaderIndex; + + BuiltMap get headerItemSizeMap; + + CalendarScreenViewModel._(); + + factory CalendarScreenViewModel( + [void Function(CalendarScreenViewModelBuilder) updates]) = + _$CalendarScreenViewModel; + + static CalendarScreenViewModel fromStore(Store store) { + final nowFullDate = DateTime.now(); + final today = (DateTime( + nowFullDate.year, + nowFullDate.month, + nowFullDate.day, + )); + final calendarItems = List(); + DateTime sectionDate = DateTime( + 1900, + today.month, + today.day, + ); + + var sectionItem; + var headerItemCount = 0; + var selectedEventHeaderIndex = -1; + var upcomingEventHeaderIndex = -1; + final headerItemMap = Map(); + + // Create CalendarHeaderItem and associated CalendarEntryItems + store.state.userCalendar.forEach((calendarEntry) { + // Group events by day + final entryDate = DateTime( + calendarEntry.eventDate.year, + calendarEntry.eventDate.month, + calendarEntry.eventDate.day, + ); + + // Add new header item for every day + if (entryDate.isAfter(sectionDate)) { + headerItemCount += 1; + sectionDate = entryDate; + + // Check if we've found the most relevant item and mark it + if (calendarEntry.eventDate.isAfter(today) && upcomingEventHeaderIndex == -1) { + upcomingEventHeaderIndex = headerItemCount - 1; + } + + // Add header + sectionItem = CalendarHeaderItem( + date: entryDate, + isToday: today.isAtSameMomentAs(entryDate), + isPast: upcomingEventHeaderIndex == -1 + ); + calendarItems.add(sectionItem); + } + + // Add header to map and increment items count for header. + headerItemMap.putIfAbsent(headerItemCount, () => 0); + headerItemMap[headerItemCount] = headerItemMap[headerItemCount] + 1; + + // Check if event is selected and persist its index. + final isSelected = + store.state.channelState.selectedChannel == calendarEntry.channelId && + store.state.selectedGroupId == calendarEntry.groupId; + + if (isSelected) { + selectedEventHeaderIndex = headerItemCount - 1; + } + + // Add actual CalendarEntryItem + calendarItems.add(CalendarEntryItem( + date: calendarEntry.eventDate, + groupName: calendarEntry.groupName, + groupId: calendarEntry.groupId, + eventName: calendarEntry.channelName, + eventId: calendarEntry.channelId, + rsvpStatus: RSVP.UNSET, + isSelected: isSelected, + isAllDay: !calendarEntry.hasStartTime, + isPast: upcomingEventHeaderIndex == -1 + )); + }); + + return CalendarScreenViewModel( + (viewModel) => viewModel + ..calendar = ListBuilder(calendarItems) + ..headerItemSizeMap = MapBuilder(headerItemMap) + ..selectedEventHeaderIndex = selectedEventHeaderIndex + ..upcomingEventHeaderIndex = upcomingEventHeaderIndex, + ); + } +} diff --git a/lib/presentation/calendar/calendar_screen_viewmodel.g.dart b/lib/presentation/calendar/calendar_screen_viewmodel.g.dart new file mode 100644 index 0000000..4ed5add --- /dev/null +++ b/lib/presentation/calendar/calendar_screen_viewmodel.g.dart @@ -0,0 +1,166 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'calendar_screen_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$CalendarScreenViewModel extends CalendarScreenViewModel { + @override + final BuiltList calendar; + @override + final int selectedEventHeaderIndex; + @override + final int upcomingEventHeaderIndex; + @override + final BuiltMap headerItemSizeMap; + + factory _$CalendarScreenViewModel( + [void Function(CalendarScreenViewModelBuilder) updates]) => + (new CalendarScreenViewModelBuilder()..update(updates)).build(); + + _$CalendarScreenViewModel._( + {this.calendar, + this.selectedEventHeaderIndex, + this.upcomingEventHeaderIndex, + this.headerItemSizeMap}) + : super._() { + if (calendar == null) { + throw new BuiltValueNullFieldError('CalendarScreenViewModel', 'calendar'); + } + if (selectedEventHeaderIndex == null) { + throw new BuiltValueNullFieldError( + 'CalendarScreenViewModel', 'selectedEventHeaderIndex'); + } + if (upcomingEventHeaderIndex == null) { + throw new BuiltValueNullFieldError( + 'CalendarScreenViewModel', 'upcomingEventHeaderIndex'); + } + if (headerItemSizeMap == null) { + throw new BuiltValueNullFieldError( + 'CalendarScreenViewModel', 'headerItemSizeMap'); + } + } + + @override + CalendarScreenViewModel rebuild( + void Function(CalendarScreenViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + CalendarScreenViewModelBuilder toBuilder() => + new CalendarScreenViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is CalendarScreenViewModel && + calendar == other.calendar && + selectedEventHeaderIndex == other.selectedEventHeaderIndex && + upcomingEventHeaderIndex == other.upcomingEventHeaderIndex && + headerItemSizeMap == other.headerItemSizeMap; + } + + @override + int get hashCode { + return $jf($jc( + $jc($jc($jc(0, calendar.hashCode), selectedEventHeaderIndex.hashCode), + upcomingEventHeaderIndex.hashCode), + headerItemSizeMap.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('CalendarScreenViewModel') + ..add('calendar', calendar) + ..add('selectedEventHeaderIndex', selectedEventHeaderIndex) + ..add('upcomingEventHeaderIndex', upcomingEventHeaderIndex) + ..add('headerItemSizeMap', headerItemSizeMap)) + .toString(); + } +} + +class CalendarScreenViewModelBuilder + implements + Builder { + _$CalendarScreenViewModel _$v; + + ListBuilder _calendar; + ListBuilder get calendar => + _$this._calendar ??= new ListBuilder(); + set calendar(ListBuilder calendar) => + _$this._calendar = calendar; + + int _selectedEventHeaderIndex; + int get selectedEventHeaderIndex => _$this._selectedEventHeaderIndex; + set selectedEventHeaderIndex(int selectedEventHeaderIndex) => + _$this._selectedEventHeaderIndex = selectedEventHeaderIndex; + + int _upcomingEventHeaderIndex; + int get upcomingEventHeaderIndex => _$this._upcomingEventHeaderIndex; + set upcomingEventHeaderIndex(int upcomingEventHeaderIndex) => + _$this._upcomingEventHeaderIndex = upcomingEventHeaderIndex; + + MapBuilder _headerItemSizeMap; + MapBuilder get headerItemSizeMap => + _$this._headerItemSizeMap ??= new MapBuilder(); + set headerItemSizeMap(MapBuilder headerItemSizeMap) => + _$this._headerItemSizeMap = headerItemSizeMap; + + CalendarScreenViewModelBuilder(); + + CalendarScreenViewModelBuilder get _$this { + if (_$v != null) { + _calendar = _$v.calendar?.toBuilder(); + _selectedEventHeaderIndex = _$v.selectedEventHeaderIndex; + _upcomingEventHeaderIndex = _$v.upcomingEventHeaderIndex; + _headerItemSizeMap = _$v.headerItemSizeMap?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(CalendarScreenViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$CalendarScreenViewModel; + } + + @override + void update(void Function(CalendarScreenViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$CalendarScreenViewModel build() { + _$CalendarScreenViewModel _$result; + try { + _$result = _$v ?? + new _$CalendarScreenViewModel._( + calendar: calendar.build(), + selectedEventHeaderIndex: selectedEventHeaderIndex, + upcomingEventHeaderIndex: upcomingEventHeaderIndex, + headerItemSizeMap: headerItemSizeMap.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'calendar'; + calendar.build(); + + _$failedField = 'headerItemSizeMap'; + headerItemSizeMap.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'CalendarScreenViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/channel_screen.dart b/lib/presentation/channel/channel_screen.dart new file mode 100644 index 0000000..e715732 --- /dev/null +++ b/lib/presentation/channel/channel_screen.dart @@ -0,0 +1,80 @@ +import "dart:async"; + +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/channel_screen_viewmodel.dart"; +import "package:circles_app/presentation/channel/event/rsvp_dialog.dart"; +import "package:circles_app/presentation/channel/event/rsvp_header.dart"; +import "package:circles_app/presentation/channel/input/chat_input.dart"; +import "package:circles_app/presentation/channel/join_channel.dart"; +import "package:circles_app/presentation/channel/messages_scroll_controller.dart"; +import "package:circles_app/presentation/channel/messages_section.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class ChannelScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return StoreConnector( + builder: (context, vm) { + return MessagesScrollController( + scrollController: ScrollController(), + child: Column( + children: _buildChildren(vm, context), + ), + ); + }, + converter: ChannelScreenViewModel.fromStore, + distinct: true, + ); + } +} + +List _buildChildren(ChannelScreenViewModel vm, BuildContext context) { + final widgets = []; + + if (vm.channel.type == ChannelType.EVENT && + vm.rsvpStatus == RSVP.UNSET && + vm.channel.startDate.isAfter(DateTime.now())) { + _addRsvpHeader(context, widgets); + } + + widgets.add(MessagesSection( + key: Key("MessageSection ${vm.channel.name}"), + )); + + if (vm.userIsMember) { + widgets.add(ChatInput( + key: Key("ChatInput ${vm.channel.name}"), + )); + } else { + if (vm.failedToJoin) { + // Placeholder until UI specs are ready + widgets.add(AlertDialog( + title: Text("Join channel failed"), + content: Text("An error occured. Please try again!"), + actions: [ + FlatButton( + child: Text("Ok"), + onPressed: () { + StoreProvider.of(context) + .dispatch(ClearFailedJoinAction()); + }) + ], + )); + } + widgets.add(JoinChannel(vm.groupId, vm.channel, vm.user)); + } + return widgets; +} + +void _addRsvpHeader(BuildContext context, List widgets) { + final completer = Completer(); + completer.future.then((rsvp) { + showDialogRsvp(context, rsvp); + }); + widgets.add(RsvpHeader( + completer: completer, + )); +} diff --git a/lib/presentation/channel/channel_screen_viewmodel.dart b/lib/presentation/channel/channel_screen_viewmodel.dart new file mode 100644 index 0000000..3037c04 --- /dev/null +++ b/lib/presentation/channel/channel_screen_viewmodel.dart @@ -0,0 +1,50 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'channel_screen_viewmodel.g.dart'; + +abstract class ChannelScreenViewModel + implements Built { + bool get isAuthor; + + bool get userIsMember; + + String get groupId; + + Channel get channel; + + User get user; + + bool get failedToJoin; + + RSVP get rsvpStatus; + + ChannelScreenViewModel._(); + + factory ChannelScreenViewModel( + [void Function(ChannelScreenViewModelBuilder) updates]) = + _$ChannelScreenViewModel; + + static ChannelScreenViewModel fromStore(Store store) { + final selectedChannel = getSelectedChannel(store.state); + final hasSelectedChannel = selectedChannel != null; + final channelUser = selectedChannel + ?.users + ?.firstWhere((u) => u.id == store.state.user.uid, orElse: () => null); + + return ChannelScreenViewModel((v) => v + ..isAuthor = + selectedChannel.authorId == store.state.user.uid + ..userIsMember = hasSelectedChannel && channelUser != null + ..groupId = hasSelectedChannel ? store.state.selectedGroupId : "" + ..channel = selectedChannel.toBuilder() + ..user = store.state.user.toBuilder() + ..failedToJoin = store.state.channelState.joinChannelFailed + ..rsvpStatus = channelUser?.rsvp ?? RSVP.UNSET); + } +} diff --git a/lib/presentation/channel/channel_screen_viewmodel.g.dart b/lib/presentation/channel/channel_screen_viewmodel.g.dart new file mode 100644 index 0000000..89fd9ab --- /dev/null +++ b/lib/presentation/channel/channel_screen_viewmodel.g.dart @@ -0,0 +1,206 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_screen_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ChannelScreenViewModel extends ChannelScreenViewModel { + @override + final bool isAuthor; + @override + final bool userIsMember; + @override + final String groupId; + @override + final Channel channel; + @override + final User user; + @override + final bool failedToJoin; + @override + final RSVP rsvpStatus; + + factory _$ChannelScreenViewModel( + [void Function(ChannelScreenViewModelBuilder) updates]) => + (new ChannelScreenViewModelBuilder()..update(updates)).build(); + + _$ChannelScreenViewModel._( + {this.isAuthor, + this.userIsMember, + this.groupId, + this.channel, + this.user, + this.failedToJoin, + this.rsvpStatus}) + : super._() { + if (isAuthor == null) { + throw new BuiltValueNullFieldError('ChannelScreenViewModel', 'isAuthor'); + } + if (userIsMember == null) { + throw new BuiltValueNullFieldError( + 'ChannelScreenViewModel', 'userIsMember'); + } + if (groupId == null) { + throw new BuiltValueNullFieldError('ChannelScreenViewModel', 'groupId'); + } + if (channel == null) { + throw new BuiltValueNullFieldError('ChannelScreenViewModel', 'channel'); + } + if (user == null) { + throw new BuiltValueNullFieldError('ChannelScreenViewModel', 'user'); + } + if (failedToJoin == null) { + throw new BuiltValueNullFieldError( + 'ChannelScreenViewModel', 'failedToJoin'); + } + if (rsvpStatus == null) { + throw new BuiltValueNullFieldError( + 'ChannelScreenViewModel', 'rsvpStatus'); + } + } + + @override + ChannelScreenViewModel rebuild( + void Function(ChannelScreenViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelScreenViewModelBuilder toBuilder() => + new ChannelScreenViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChannelScreenViewModel && + isAuthor == other.isAuthor && + userIsMember == other.userIsMember && + groupId == other.groupId && + channel == other.channel && + user == other.user && + failedToJoin == other.failedToJoin && + rsvpStatus == other.rsvpStatus; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc($jc($jc(0, isAuthor.hashCode), userIsMember.hashCode), + groupId.hashCode), + channel.hashCode), + user.hashCode), + failedToJoin.hashCode), + rsvpStatus.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChannelScreenViewModel') + ..add('isAuthor', isAuthor) + ..add('userIsMember', userIsMember) + ..add('groupId', groupId) + ..add('channel', channel) + ..add('user', user) + ..add('failedToJoin', failedToJoin) + ..add('rsvpStatus', rsvpStatus)) + .toString(); + } +} + +class ChannelScreenViewModelBuilder + implements Builder { + _$ChannelScreenViewModel _$v; + + bool _isAuthor; + bool get isAuthor => _$this._isAuthor; + set isAuthor(bool isAuthor) => _$this._isAuthor = isAuthor; + + bool _userIsMember; + bool get userIsMember => _$this._userIsMember; + set userIsMember(bool userIsMember) => _$this._userIsMember = userIsMember; + + String _groupId; + String get groupId => _$this._groupId; + set groupId(String groupId) => _$this._groupId = groupId; + + ChannelBuilder _channel; + ChannelBuilder get channel => _$this._channel ??= new ChannelBuilder(); + set channel(ChannelBuilder channel) => _$this._channel = channel; + + UserBuilder _user; + UserBuilder get user => _$this._user ??= new UserBuilder(); + set user(UserBuilder user) => _$this._user = user; + + bool _failedToJoin; + bool get failedToJoin => _$this._failedToJoin; + set failedToJoin(bool failedToJoin) => _$this._failedToJoin = failedToJoin; + + RSVP _rsvpStatus; + RSVP get rsvpStatus => _$this._rsvpStatus; + set rsvpStatus(RSVP rsvpStatus) => _$this._rsvpStatus = rsvpStatus; + + ChannelScreenViewModelBuilder(); + + ChannelScreenViewModelBuilder get _$this { + if (_$v != null) { + _isAuthor = _$v.isAuthor; + _userIsMember = _$v.userIsMember; + _groupId = _$v.groupId; + _channel = _$v.channel?.toBuilder(); + _user = _$v.user?.toBuilder(); + _failedToJoin = _$v.failedToJoin; + _rsvpStatus = _$v.rsvpStatus; + _$v = null; + } + return this; + } + + @override + void replace(ChannelScreenViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChannelScreenViewModel; + } + + @override + void update(void Function(ChannelScreenViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChannelScreenViewModel build() { + _$ChannelScreenViewModel _$result; + try { + _$result = _$v ?? + new _$ChannelScreenViewModel._( + isAuthor: isAuthor, + userIsMember: userIsMember, + groupId: groupId, + channel: channel.build(), + user: user.build(), + failedToJoin: failedToJoin, + rsvpStatus: rsvpStatus); + } catch (_) { + String _$failedField; + try { + _$failedField = 'channel'; + channel.build(); + _$failedField = 'user'; + user.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'ChannelScreenViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/create/create_channel.dart b/lib/presentation/channel/create/create_channel.dart new file mode 100644 index 0000000..085fa01 --- /dev/null +++ b/lib/presentation/channel/create/create_channel.dart @@ -0,0 +1,315 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/common/color_label_text_form_field.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/common/error_label_text_form_field.dart"; +import "package:circles_app/presentation/user/user_item.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/painting.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:redux/redux.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; + +class CreateChannelScreen extends StatefulWidget { + @override + _CreateChannelScreenState createState() => _CreateChannelScreenState(); +} + +class _CreateChannelScreenState extends State { + final _nameController = TextEditingController(); + final _purposeController = TextEditingController(); + final Set _selectedUsers = Set(); + ChannelVisibility _visibility = ChannelVisibility.OPEN; + final GlobalKey _key = GlobalKey(); + + @override + void dispose() { + super.dispose(); + _purposeController.dispose(); + _nameController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(context), + body: _createChannelForm(context), + ); + } + + Widget _buildAppBar(context) { + return CommonAppBar( + title: CirclesLocalizations.of(context).channelFormCreateTopic, + action: FlatButton( + key: Key("Create"), + child: Text( + CirclesLocalizations.of(context).channelCreateButton, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _validateAndSubmit(); + }), + ); + } + + Widget _createChannelForm(BuildContext context) { + return StoreConnector( + builder: (context, _vm) => listForm(_vm), + converter: _ViewModel.fromStore, + ); + } + + Widget listForm(_ViewModel vm) { + final nameInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: ErrorLabelTextFormField( + key: Key("TopicName"), + maxCharacters: 30, + labelText: CirclesLocalizations.of(context).channelFormTopicName, + helperText: + CirclesLocalizations.of(context).channelFormCreateTopicEmptyError, + controller: _nameController, + validator: (value) { + if (value.isEmpty) { + return CirclesLocalizations.of(context) + .channelFormCreateTopicEmptyError; + } + return null; + }), + ); + + final purposeInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: ColorLabelTextFormField( + key: Key("Purpose"), + labelText: CirclesLocalizations.of(context).channelFormTopicDescription, + helperText: + CirclesLocalizations.of(context).channelFormTopicDescriptionHelper, + controller: _purposeController, + ), + ); + + final users = _visibility == ChannelVisibility.CLOSED ? vm.groupUsers : []; + + return Form( + key: _key, + child: Container( + child: ListView.custom( + childrenDelegate: SliverChildListDelegate( + [ + nameInput, + purposeInput, + _buildSwitch(), + _buildInviteUsersHeader(), + ...users.map((user) => UserItem( + user: user, + selected: _selectedUsers.contains(user.uid), + selectionHandler: _selectUser, + )), + ], + ), + ), + color: Colors.white, + ), + ); + } + + Padding _buildSwitch() { + final _switchWidth = 60.0; + final visibilitySwitch = Padding( + padding: EdgeInsets.only( + top: 24.0, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + bottom: 24.0, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + CirclesLocalizations.of(context).channelFormCreateTopicPublic, + style: AppTheme.switchTitleTextStyle, + ), + SizedBox(height: 4), + Text( + CirclesLocalizations.of(context) + .channelFormCreateTopicPublicHelper, + style: AppTheme.switchSubtitleTextStyle, + ), + ], + ), + ), + Container( + width: _switchWidth, + child: PlatformSwitch( + key: Key("Visibility"), + value: _visibility == ChannelVisibility.OPEN, + onChanged: (bool value) { + FocusScope.of(context).unfocus(); + setState(() { + _visibility = value + ? ChannelVisibility.OPEN + : ChannelVisibility.CLOSED; + }); + }, + )), + ], + ), + ); + return visibilitySwitch; + } + + Widget _buildInviteUsersHeader() { + return Visibility( + visible: _visibility == ChannelVisibility.CLOSED, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_padlock.png", + height: 36, + ), + ), + Expanded( + child: Text( + CirclesLocalizations.of(context) + .channelFormCreateTopicPrivateHelper, + style: AppTheme.topicDetailsItemTextStyle, + ), + ), + ], + ), + ); + } + + void _selectUser(User user) { + setState(() { + if (_selectedUsers.contains(user.uid)) { + _selectedUsers.remove(user.uid); + } else { + _selectedUsers.add(user.uid); + } + }); + } + + void _validateAndSubmit() { + if (_key.currentState.validate()) { + List invitedIds = []; + if (_visibility == ChannelVisibility.CLOSED) { + invitedIds = _selectedUsers.toList(); + if (invitedIds.length == 0) { + _showAlert( + context, + CirclesLocalizations.of(context).channelCreateTitle, + CirclesLocalizations.of(context).channelFormSelectMembersError, + ); + return; + } + } + + final completer = Completer(); + completer.future.then((val) { + Navigator.of(context).popUntil(ModalRoute.withName(Routes.home)); + }).catchError((error) { + Logger.w("Could not create channel, $error"); + _showAlert( + context, + CirclesLocalizations.of(context).channelCreateTitle, + CirclesLocalizations.of(context).channelFormTopicExists, + ); + }); + + final provider = StoreProvider.of(context); + provider.dispatch( + CreateChannel( + Channel((c) => c + ..type = ChannelType.TOPIC + ..name = _nameController.text + ..description = _purposeController.text ?? "" + ..visibility = _visibility + ..authorId = provider.state.user.uid), + BuiltList(invitedIds), + completer, + ), + ); + } + } + + void _showAlert( + BuildContext context, + String title, + String content, + ) { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(title), + content: Text(content), + actions: [ + PlatformDialogAction( + child: PlatformText(CirclesLocalizations.of(context).ok), + onPressed: () { + // UI clean up + Navigator.pop(context); + }), + ], + ), + ); + } +} + +class _ViewModel { + final List channels; + final List groupUsers; + + const _ViewModel({ + this.channels, + this.groupUsers, + }); + + bool hasChannelNamed(String name) { + return channels.contains(name.toLowerCase()); + } + + static _ViewModel fromStore(Store store) { + final state = store.state; + var channels; + if (state.selectedGroupId != null && + state.groups[state.selectedGroupId] != null) { + final channelNames = state.groups[state.selectedGroupId].channels.values + .map((c) => c.name.toLowerCase()) + .toList(); + channels = channelNames ?? []; + } + + final users = state.groupUsers.toList(); + users.remove(state.user); + + return _ViewModel( + channels: channels, + groupUsers: users, + ); + } +} diff --git a/lib/presentation/channel/details/topic_details.dart b/lib/presentation/channel/details/topic_details.dart new file mode 100644 index 0000000..355e7f7 --- /dev/null +++ b/lib/presentation/channel/details/topic_details.dart @@ -0,0 +1,381 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/details/topic_details_viewmodel.dart"; +import "package:circles_app/presentation/common/round_button.dart"; +import "package:circles_app/presentation/user/user_item.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class TopicDetails extends StatelessWidget { + const TopicDetails({ + Key key, + this.sideOpenController, + }) : super(key: key); + + final ValueNotifier sideOpenController; + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: TopicDetailsViewModel.fromStore, + builder: (context, vm) { + return _TopicDetailsWidget( + vm: vm, + sideOpenController: sideOpenController, + ); + }, + ); + } +} + +class _TopicDetailsWidget extends StatefulWidget { + const _TopicDetailsWidget({ + Key key, + this.vm, + this.sideOpenController, + }) : super(key: key); + + final TopicDetailsViewModel vm; + final ValueNotifier sideOpenController; + + @override + _TopicDetailsWidgetState createState() => _TopicDetailsWidgetState(); +} + +class _TopicDetailsWidgetState extends State<_TopicDetailsWidget> + with TickerProviderStateMixin { + TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + ); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border(left: BorderSide(color: AppTheme.colorGrey225))), + child: Column( + children: [ + _buildHeader(), + _buildTabBar(), + _buildTabContent(), + ], + ), + ); + } + + Stack _buildHeader() { + return Stack( + children: [ + _buildBackground(), + _buildGradient(), + _buildHeaderText(), + ], + ); + } + + Container _buildBackground() { + return Container( + height: 256, + decoration: BoxDecoration( + color: AppTheme.colorMintGreen, + image: DecorationImage( + colorFilter: ColorFilter.mode( + Color.fromRGBO(255, 255, 255, 0.1), BlendMode.modulate), + image: AssetImage("assets/graphics/visual_twist_white_petrol.png"), + fit: BoxFit.cover, + ), + ), + ); + } + + Container _buildGradient() { + return Container( + height: 256, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + AppTheme.colorMintGreen, + // Mint Green but transparent, so we have a nice gradient + const Color.fromRGBO(54, 207, 166, 0.0), + ], + ), + ), + ); + } + + Positioned _buildHeaderText() { + return Positioned( + bottom: AppTheme.appMargin, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.vm.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTheme.topicDetailsNameTextStyle, + ), + Visibility( + visible: widget.vm.description.isNotEmpty, + child: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + widget.vm.description, + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: AppTheme.topicDetailsDescriptionTextStyle, + ), + ), + ), + ], + ), + ); + } + + TabBar _buildTabBar() { + return TabBar( + controller: _tabController, + tabs: [ + _buildTab(CirclesLocalizations.of(context).topicDetails), + _buildTab(CirclesLocalizations.of(context).topicMembers), + ], + labelPadding: EdgeInsets.all(0), + indicatorColor: AppTheme.colorDarkBlueFont, + ); + } + + Tab _buildTab(String title) { + return Tab( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AppTheme.colorGrey225), + ), + ), + height: 60, + child: Center( + child: Text( + title, + style: AppTheme.topicDetailsTabTextStyle, + ), + ), + ), + ); + } + + Expanded _buildTabContent() { + return Expanded( + child: _TabBarView( + controller: _tabController, + children: [ + _buildDetails(), + _buildMemberList(), + ], + ), + ); + } + + Widget _buildDetails() { + return SizedBox.expand( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildVisibility(), + _buildMembersCount(), + Expanded(child: Container()), + _buildLeaveChannelButton(), + ], + ), + ); + } + + Widget _buildVisibility() { + switch (widget.vm.visibility) { + case ChannelVisibility.CLOSED: + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_padlock.png", + height: 36, + ), + ), + Text( + CirclesLocalizations.of(context).topicPrivate, + style: AppTheme.topicDetailsItemTextStyle, + ), + ], + ); + break; + case ChannelVisibility.OPEN: + default: + // Do not show for Open channels + return Container(); + break; + } + } + + Widget _buildMembersCount() { + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_members.png", + height: 36, + ), + ), + Text( + CirclesLocalizations.of(context) + .topicMembersCount(widget.vm.members.length.toString()), + style: AppTheme.topicDetailsItemTextStyle, + ), + ], + ); + } + + Padding _buildLeaveChannelButton() { + return Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Center( + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).topicLeave, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _showLeaveChannelDialog(context); + }, + ), + ), + ); + } + + Widget _buildMemberList() { + return ListView.builder( + padding: EdgeInsets.all(0), + itemCount: widget.vm.members.length + _canInviteUsers(), + itemBuilder: (BuildContext context, int index) { + if (index < widget.vm.members.length) { + final member = widget.vm.members[index]; + return UserItem( + user: member, + isYou: widget.vm.userId == member.uid, + ); + } else { + return Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: RoundButton( + text: CirclesLocalizations.of(context).channelInviteButton, + onTap: () { + Navigator.of(context).pushNamed(Routes.channelInvite, + arguments: widget.vm.channel.id); + }, + ), + ); + } + }, + ); + } + + _showLeaveChannelDialog(BuildContext context) { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(CirclesLocalizations.of(context).channelLeaveAlertTitle), + content: + Text(CirclesLocalizations.of(context).channelLeaveAlertMessage), + actions: [ + PlatformDialogAction( + child: Text(CirclesLocalizations.of(context).yes), + onPressed: () { + StoreProvider.of(context).dispatch( + LeaveChannelAction( + widget.vm.groupId, + widget.vm.channel, + widget.vm.userId, + ), + ); + widget.sideOpenController.value = false; + Navigator.pop(context); + }), + PlatformDialogAction( + child: Text(CirclesLocalizations.of(context).cancel), + onPressed: () { + Navigator.pop(context); + }), + ], + ), + ); + } + + /// Invite members is only visible if the channel is closed + num _canInviteUsers() { + if (widget.vm.channel.visibility == ChannelVisibility.CLOSED) { + return 1; + } else { + return 0; + } + } +} + +class _TabBarView extends StatefulWidget { + final TabController controller; + final List children; + + const _TabBarView({ + @required this.controller, + @required this.children, + }); + + @override + _TabBarViewState createState() => _TabBarViewState(); +} + +class _TabBarViewState extends State<_TabBarView> { + int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.controller.index; + widget.controller.addListener(() { + setState(() { + _currentIndex = widget.controller.index; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: widget.children[_currentIndex], + ); + } +} diff --git a/lib/presentation/channel/details/topic_details_viewmodel.dart b/lib/presentation/channel/details/topic_details_viewmodel.dart new file mode 100644 index 0000000..4920b30 --- /dev/null +++ b/lib/presentation/channel/details/topic_details_viewmodel.dart @@ -0,0 +1,48 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'topic_details_viewmodel.g.dart'; + +abstract class TopicDetailsViewModel + implements Built { + String get name; + + String get description; + + ChannelVisibility get visibility; + + BuiltList get members; + + String get groupId; + + Channel get channel; + + String get userId; + + TopicDetailsViewModel._(); + + factory TopicDetailsViewModel( + [void Function(TopicDetailsViewModelBuilder) updates]) = + _$TopicDetailsViewModel; + + static TopicDetailsViewModel fromStore(Store store) { + final channel = getSelectedChannel(store.state); + final members = store.state.groupUsers + .where((user) => channel.users.any((u) => u.id == user.uid)); + + return TopicDetailsViewModel((t) => t + ..name = channel.name + ..visibility = channel.visibility + ..description = channel.description ?? "" + ..members.addAll(members) + ..groupId = store.state.selectedGroupId + ..channel = channel.toBuilder() + ..userId = store.state.user.uid); + } +} diff --git a/lib/presentation/channel/details/topic_details_viewmodel.g.dart b/lib/presentation/channel/details/topic_details_viewmodel.g.dart new file mode 100644 index 0000000..886c8aa --- /dev/null +++ b/lib/presentation/channel/details/topic_details_viewmodel.g.dart @@ -0,0 +1,206 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'topic_details_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$TopicDetailsViewModel extends TopicDetailsViewModel { + @override + final String name; + @override + final String description; + @override + final ChannelVisibility visibility; + @override + final BuiltList members; + @override + final String groupId; + @override + final Channel channel; + @override + final String userId; + + factory _$TopicDetailsViewModel( + [void Function(TopicDetailsViewModelBuilder) updates]) => + (new TopicDetailsViewModelBuilder()..update(updates)).build(); + + _$TopicDetailsViewModel._( + {this.name, + this.description, + this.visibility, + this.members, + this.groupId, + this.channel, + this.userId}) + : super._() { + if (name == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'name'); + } + if (description == null) { + throw new BuiltValueNullFieldError( + 'TopicDetailsViewModel', 'description'); + } + if (visibility == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'visibility'); + } + if (members == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'members'); + } + if (groupId == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'groupId'); + } + if (channel == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'channel'); + } + if (userId == null) { + throw new BuiltValueNullFieldError('TopicDetailsViewModel', 'userId'); + } + } + + @override + TopicDetailsViewModel rebuild( + void Function(TopicDetailsViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + TopicDetailsViewModelBuilder toBuilder() => + new TopicDetailsViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is TopicDetailsViewModel && + name == other.name && + description == other.description && + visibility == other.visibility && + members == other.members && + groupId == other.groupId && + channel == other.channel && + userId == other.userId; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc($jc($jc(0, name.hashCode), description.hashCode), + visibility.hashCode), + members.hashCode), + groupId.hashCode), + channel.hashCode), + userId.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('TopicDetailsViewModel') + ..add('name', name) + ..add('description', description) + ..add('visibility', visibility) + ..add('members', members) + ..add('groupId', groupId) + ..add('channel', channel) + ..add('userId', userId)) + .toString(); + } +} + +class TopicDetailsViewModelBuilder + implements Builder { + _$TopicDetailsViewModel _$v; + + String _name; + String get name => _$this._name; + set name(String name) => _$this._name = name; + + String _description; + String get description => _$this._description; + set description(String description) => _$this._description = description; + + ChannelVisibility _visibility; + ChannelVisibility get visibility => _$this._visibility; + set visibility(ChannelVisibility visibility) => + _$this._visibility = visibility; + + ListBuilder _members; + ListBuilder get members => _$this._members ??= new ListBuilder(); + set members(ListBuilder members) => _$this._members = members; + + String _groupId; + String get groupId => _$this._groupId; + set groupId(String groupId) => _$this._groupId = groupId; + + ChannelBuilder _channel; + ChannelBuilder get channel => _$this._channel ??= new ChannelBuilder(); + set channel(ChannelBuilder channel) => _$this._channel = channel; + + String _userId; + String get userId => _$this._userId; + set userId(String userId) => _$this._userId = userId; + + TopicDetailsViewModelBuilder(); + + TopicDetailsViewModelBuilder get _$this { + if (_$v != null) { + _name = _$v.name; + _description = _$v.description; + _visibility = _$v.visibility; + _members = _$v.members?.toBuilder(); + _groupId = _$v.groupId; + _channel = _$v.channel?.toBuilder(); + _userId = _$v.userId; + _$v = null; + } + return this; + } + + @override + void replace(TopicDetailsViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$TopicDetailsViewModel; + } + + @override + void update(void Function(TopicDetailsViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$TopicDetailsViewModel build() { + _$TopicDetailsViewModel _$result; + try { + _$result = _$v ?? + new _$TopicDetailsViewModel._( + name: name, + description: description, + visibility: visibility, + members: members.build(), + groupId: groupId, + channel: channel.build(), + userId: userId); + } catch (_) { + String _$failedField; + try { + _$failedField = 'members'; + members.build(); + + _$failedField = 'channel'; + channel.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'TopicDetailsViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/event/create_event.dart b/lib/presentation/channel/event/create_event.dart new file mode 100644 index 0000000..f43ad97 --- /dev/null +++ b/lib/presentation/channel/event/create_event.dart @@ -0,0 +1,466 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/common/color_label_text_form_field.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/common/date_form_field.dart"; +import "package:circles_app/presentation/common/error_label_text_form_field.dart"; +import "package:circles_app/presentation/common/time_form_field.dart"; +import "package:circles_app/presentation/user/user_item.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:redux/redux.dart"; + +class CreateEventScreen extends StatefulWidget { + @override + _CreateEventScreenState createState() => _CreateEventScreenState(); +} + +class _CreateEventScreenState extends State { + final _nameController = TextEditingController(); + final _dateController = ValueNotifier(null); + final _timeController = ValueNotifier(null); + final _venueController = TextEditingController(); + final _purposeController = TextEditingController(); + ChannelVisibility _visibility = ChannelVisibility.OPEN; + final Set _selectedUsers = Set(); + final GlobalKey _key = GlobalKey(); + + // will be null if we are in create mode + Channel _editChannel; + bool _loadedEditChannel = false; + + @override + void dispose() { + super.dispose(); + _nameController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _venueController.dispose(); + _purposeController.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _editChannel = ModalRoute.of(context).settings.arguments; + if (_editChannel != null && !_loadedEditChannel) { + _loadedEditChannel = true; + _nameController.text = _editChannel.name; + _dateController.value = _editChannel.startDate; + if (_editChannel.hasStartTime) { + _timeController.value = TimeOfDay( + hour: _editChannel.startDate.hour, + minute: _editChannel.startDate.minute, + ); + } + _venueController.text = _editChannel.venue; + _purposeController.text = _editChannel.description; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CommonAppBar( + title: _buildTitle(context), + action: FlatButton( + key: Key("Create"), + child: Text( + _buildSaveButtonText(context), + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _validateAndSubmit(); + }), + ), + body: StoreConnector( + builder: (context, _vm) => listForm(_vm), + converter: _ViewModel.fromStore, + ), + ); + } + + String _buildSaveButtonText(BuildContext context) { + return _editChannel != null + ? CirclesLocalizations.of(context).save + : CirclesLocalizations.of(context).create; + } + + _buildTitle(BuildContext context) { + return _editChannel != null + ? CirclesLocalizations.of(context).eventEditTitle + : CirclesLocalizations.of(context).eventCreateTitle; + } + + Widget listForm(_ViewModel vm) { + final nameInput = _buildName(); + + final dateInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin / 2, + ), + child: DateFormField( + key: Key("EventDate"), + labelText: CirclesLocalizations.of(context).eventFormDate, + controller: _dateController, + validator: (value) { + if (value == null) { + return CirclesLocalizations.of(context).eventFormDateEmpty; + } + final today = DateTime.now(); + if (value.isBefore(DateTime(today.year, today.month, today.day))) { + return CirclesLocalizations.of(context).eventFormDatePast; + } + return null; + }, + ), + ); + + final timeInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin / 2, + right: AppTheme.appMargin, + ), + child: TimeFormField( + key: Key("EventTime"), + labelText: CirclesLocalizations.of(context).eventFormTime, + controller: _timeController, + validator: (_) => null, + ), + ); + + final eventTimeRow = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: dateInput), + Expanded(child: timeInput), + ], + ); + + final venueInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: ColorLabelTextFormField( + labelText: CirclesLocalizations.of(context).eventFormVenue, + helperText: CirclesLocalizations.of(context).eventFormVenueHelper, + controller: _venueController, + ), + ); + + final purposeInput = Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: ColorLabelTextFormField( + key: Key("Purpose"), + labelText: CirclesLocalizations.of(context).eventFormPurpose, + helperText: CirclesLocalizations.of(context).eventFormPurposeHelper, + controller: _purposeController, + ), + ); + + final users = _visibility == ChannelVisibility.CLOSED ? vm.groupUsers : []; + + return Form( + key: _key, + child: Container( + child: ListView.custom( + childrenDelegate: SliverChildListDelegate( + [ + nameInput, + eventTimeRow, + venueInput, + purposeInput, + Visibility( + visible: _editChannel == null, + child: _buildSwitch(), + ), + _buildInviteUsersHeader(), + ...users.map((user) => UserItem( + user: user, + selected: _selectedUsers.contains(user.uid), + selectionHandler: _selectUser, + )), + ], + ), + ), + color: Colors.white, + ), + ); + } + + Padding _buildName() { + return Padding( + padding: const EdgeInsets.only( + top: 24, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: ErrorLabelTextFormField( + key: Key("TopicName"), + maxCharacters: 30, + labelText: CirclesLocalizations.of(context).eventFormName, + helperText: + CirclesLocalizations.of(context).channelFormCreateTopicEmptyError, + controller: _nameController, + enabled: _editChannel == null, + validator: (value) { + if (value.isEmpty) { + return CirclesLocalizations.of(context) + .channelFormCreateTopicEmptyError; + } + return null; + }), + ); + } + + Padding _buildSwitch() { + final _switchWidth = 60.0; + final visibilitySwitch = Padding( + padding: EdgeInsets.only( + top: 24.0, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + bottom: 24.0, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + CirclesLocalizations.of(context).channelFormCreateTopicPublic, + style: AppTheme.switchTitleTextStyle, + ), + SizedBox(height: 4), + Text( + CirclesLocalizations.of(context) + .channelFormCreateTopicPublicHelper, + style: AppTheme.switchSubtitleTextStyle, + ), + ], + ), + ), + Container( + width: _switchWidth, + child: PlatformSwitch( + key: Key("Visibility"), + value: _visibility == ChannelVisibility.OPEN, + onChanged: (bool value) { + FocusScope.of(context).unfocus(); + setState(() { + _visibility = value + ? ChannelVisibility.OPEN + : ChannelVisibility.CLOSED; + }); + }, + )), + ], + ), + ); + return visibilitySwitch; + } + + Widget _buildInviteUsersHeader() { + return Visibility( + visible: _visibility == ChannelVisibility.CLOSED, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_padlock.png", + height: 36, + ), + ), + Expanded( + child: Text( + CirclesLocalizations.of(context) + .channelFormCreateTopicPrivateHelper, + style: AppTheme.topicDetailsItemTextStyle, + ), + ), + ], + ), + ); + } + + void _selectUser(User user) { + Logger.d("Selected user: $user"); + setState(() { + if (_selectedUsers.contains(user.uid)) { + _selectedUsers.remove(user.uid); + } else { + _selectedUsers.add(user.uid); + } + }); + } + + // TODO: Maybe unify with Create Channel module + void _showAlert( + BuildContext context, + String title, + String content, + ) { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(title), + content: Text(content), + actions: [ + PlatformDialogAction( + child: PlatformText(CirclesLocalizations.of(context).ok), + onPressed: () { + // UI clean up + Navigator.pop(context); + }), + ], + ), + ); + } + + void _validateAndSubmit() { + if (_key.currentState.validate()) { + if (_editChannel == null) { + _submitCreateChannel(); + } else { + _submitEditChannel(); + } + } + } + + _submitCreateChannel() { + List members = []; + if (_visibility == ChannelVisibility.CLOSED) { + members = _selectedUsers.toList(); + if (members.length == 0) { + Logger.w("Members list is empty. Select at least one member"); + _showAlert( + context, + CirclesLocalizations.of(context).channelCreateTitle, + CirclesLocalizations.of(context).channelFormSelectMembersError, + ); + return; + } + } + final Completer completer = _createCompleter(); + _dispatchCreateAction(members, completer); + } + + Completer _createCompleter() { + final completer = Completer(); + completer.future.then((val) { + Navigator.of(context).popUntil(ModalRoute.withName(Routes.home)); + }).catchError((error) { + _showAlert( + context, + CirclesLocalizations.of(context).channelCreateTitle, + CirclesLocalizations.of(context).channelFormTopicExists, + ); + }); + return completer; + } + + void _dispatchCreateAction(List members, Completer completer) { + final provider = StoreProvider.of(context); + provider.dispatch(CreateChannel( + Channel((c) => c + ..type = ChannelType.EVENT + ..name = _nameController.text + ..description = _purposeController.text ?? "" + ..venue = _venueController.text + ..startDate = _calculateStartDate() + ..hasStartTime = _timeController.value != null + ..visibility = _visibility + ..authorId = provider.state.user.uid), + BuiltList(members), + completer, + )); + } + + DateTime _calculateStartDate() { + final date = _dateController.value; + final year = date.year; + final month = date.month; + final day = date.day; + final time = _timeController.value; + if (time != null) { + return DateTime(year, month, day, time.hour, time.minute); + } + // The time of the event is at the end of the day when there's no time set + // https://github.com/janoodleFTW/flutter-app/issues/248 + return DateTime(year, month, day, 23, 59); + } + + void _submitEditChannel() { + final completer = Completer(); + completer.future.then((val) { + Navigator.of(context).popUntil(ModalRoute.withName(Routes.home)); + }).catchError((error) { + // TODO: Log or display error + }); + final provider = StoreProvider.of(context); + provider.dispatch(EditChannelAction( + _editChannel.rebuild((c) => c + ..description = _purposeController.text ?? "" + ..venue = _venueController.text + ..startDate = _calculateStartDate() + ..hasStartTime = _timeController.value != null), + completer, + )); + } +} + +class _ViewModel { + final List channels; + final List groupUsers; + + const _ViewModel({ + this.channels, + this.groupUsers, + }); + + bool hasChannelNamed(String name) { + return channels.contains(name.toLowerCase()); + } + + static _ViewModel fromStore(Store store) { + final state = store.state; + var channels; + if (state.selectedGroupId != null && + state.groups[state.selectedGroupId] != null) { + final channelNames = state.groups[state.selectedGroupId].channels.values + .map((c) => c.name.toLowerCase()) + .toList(); + channels = channelNames ?? []; + } + + final users = state.groupUsers.toList(); + users.remove(state.user); + + return _ViewModel( + channels: channels, + groupUsers: users, + ); + } +} diff --git a/lib/presentation/channel/event/event_details.dart b/lib/presentation/channel/event/event_details.dart new file mode 100644 index 0000000..d47c9ce --- /dev/null +++ b/lib/presentation/channel/event/event_details.dart @@ -0,0 +1,581 @@ +import "dart:async"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/event/event_details_viewmodel.dart"; +import "package:circles_app/presentation/channel/event/rsvp_dialog.dart"; +import "package:circles_app/presentation/common/round_button.dart"; +import "package:circles_app/presentation/user/rsvp_icon.dart"; +import "package:circles_app/presentation/user/user_avatar.dart"; +import "package:circles_app/presentation/user/user_item.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class EventDetails extends StatelessWidget { + const EventDetails({ + Key key, + this.sideOpenController, + }) : super(key: key); + + final ValueNotifier sideOpenController; + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: (vm) => EventDetailsViewModel.fromStore(context, vm), + builder: (context, vm) { + return _EventDetailsWidget( + vm: vm, + sideOpenController: sideOpenController, + ); + }, + ); + } +} + +class _EventDetailsWidget extends StatefulWidget { + const _EventDetailsWidget({ + Key key, + this.vm, + this.sideOpenController, + }) : super(key: key); + + final EventDetailsViewModel vm; + final ValueNotifier sideOpenController; + + @override + _EventDetailsWidgetState createState() => _EventDetailsWidgetState(); +} + +class _EventDetailsWidgetState extends State<_EventDetailsWidget> + with TickerProviderStateMixin { + TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + ); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border(left: BorderSide(color: AppTheme.colorGrey225))), + child: Column( + children: [ + _buildHeader(), + _buildTabBar(), + _buildTabContent(), + ], + ), + ); + } + + Stack _buildHeader() { + return Stack( + children: [ + _buildBackground(), + _buildGradient(), + _buildHeaderText(), + _buildEditButton(), + ], + ); + } + + Container _buildBackground() { + return Container( + height: _Style.headerSize, + decoration: BoxDecoration( + color: AppTheme.colorMintGreen, + image: DecorationImage( + colorFilter: ColorFilter.mode( + Color.fromRGBO(255, 255, 255, 0.1), BlendMode.modulate), + image: AssetImage("assets/graphics/visual_twist_white_petrol.png"), + fit: BoxFit.cover, + ), + ), + ); + } + + Container _buildGradient() { + return Container( + height: _Style.headerSize, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + AppTheme.colorMintGreen, + // Mint Green but transparent, so we have a nice gradient + const Color.fromRGBO(54, 207, 166, 0.0), + ], + ), + ), + ); + } + + Positioned _buildHeaderText() { + return Positioned( + bottom: AppTheme.appMargin, + left: AppTheme.appMargin, + right: AppTheme.appMargin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.vm.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTheme.topicDetailsNameTextStyle, + ), + Visibility( + visible: widget.vm.description.isNotEmpty, + child: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + widget.vm.description, + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: AppTheme.topicDetailsDescriptionTextStyle, + ), + ), + ), + ], + ), + ); + } + + Widget _buildEditButton() { + return Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Visibility( + visible: widget.vm.editable, + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).edit, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.pushNamed( + context, + Routes.eventNew, + arguments: widget.vm.channel, + ); + }), + ), + ), + ); + } + + TabBar _buildTabBar() { + return TabBar( + controller: _tabController, + tabs: [ + _buildTab(CirclesLocalizations + .of(context) + .eventDetails), + _buildTab(CirclesLocalizations + .of(context) + .eventGuests), + ], + labelPadding: EdgeInsets.all(0), + indicatorColor: AppTheme.colorDarkBlueFont, + ); + } + + Tab _buildTab(String title) { + return Tab( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AppTheme.colorGrey225), + ), + ), + height: 60, + child: Center( + child: Text( + title, + style: AppTheme.topicDetailsTabTextStyle, + ), + ), + ), + ); + } + + Expanded _buildTabContent() { + return Expanded( + child: _TabBarView( + controller: _tabController, + children: [ + _buildDetails(), + _buildMemberList(), + ], + ), + ); + } + + Widget _buildDetails() { + return SizedBox.expand( + child: ListView( + padding: EdgeInsets.all(0), + children: [ + _buildEventDate(), + _buildEventLocation(), + _buildVisibility(), + _buildMembersCount(), + _buildUserRsvp(), + _buildLogout(), + ], + ), + ); + } + + Widget _buildEventDate() { + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_date.png", + height: _Style.iconSize, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.vm.eventDate, + textAlign: TextAlign.start, + style: AppTheme.topicDetailsItemTextStyle, + ), + Visibility( + visible: widget.vm.eventTime.isNotEmpty, + child: Text( + widget.vm.eventTime, + textAlign: TextAlign.start, + style: AppTheme.topicDetailsItemSubtitleTextStyle, + ), + ), + ], + ), + ], + ); + } + + Widget _buildEventLocation() { + return Visibility( + visible: widget.vm.venue.isNotEmpty, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_location.png", + height: _Style.iconSize, + ), + ), + Text( + widget.vm.venue, + textAlign: TextAlign.start, + style: AppTheme.topicDetailsItemTextStyle, + ), + ], + ), + ); + } + + Widget _buildVisibility() { + switch (widget.vm.visibility) { + case ChannelVisibility.CLOSED: + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_padlock.png", + height: _Style.iconSize, + ), + ), + Text( + CirclesLocalizations + .of(context) + .eventPrivate, + style: AppTheme.topicDetailsItemTextStyle, + ), + ], + ); + break; + case ChannelVisibility.OPEN: + default: + // Do not show for Open channels + return Container(); + break; + } + } + + Widget _buildMembersCount() { + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Image.asset( + "assets/graphics/channel/details_members.png", + height: _Style.iconSize, + ), + ), + Text( + CirclesLocalizations.of(context) + .eventGuestCount(widget.vm.guestCount.toString()), + style: AppTheme.topicDetailsItemTextStyle, + ), + ], + ); + } + + Widget _buildUserRsvp() { + return Visibility( + visible: widget.vm.userRsvp != RSVP.UNSET, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: UserAvatar(user: widget.vm.user), + ), + Text( + CirclesLocalizations + .of(context) + .eventRsvpUser, + style: AppTheme.topicDetailsItemTextStyle, + ), + Padding( + padding: const EdgeInsets.only(left: AppTheme.appMargin), + child: RsvpIcon(rsvp: widget.vm.userRsvp), + ), + Visibility( + visible: widget.vm.canChangeRsvp, + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).eventRsvpChange, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _showChangeRsvpDialog(context); + }, + ), + ), + ], + ), + ); + } + + Widget _buildMemberList() { + return ListView.builder( + padding: EdgeInsets.all(0), + itemCount: widget.vm.members.length + _canInviteUsers(), + itemBuilder: (BuildContext context, int index) { + if (index < widget.vm.members.length) { + final member = widget.vm.members[index]; + return UserItem( + user: member, + rsvp: widget.vm.rsvpStatus[member.uid], + isYou: member.uid == widget.vm.user.uid, + isHost: member.uid == widget.vm.channel.authorId, + ); + } else { + return Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: RoundButton( + text: CirclesLocalizations + .of(context) + .channelInviteButton, + onTap: () { + Navigator.of(context).pushNamed(Routes.channelInvite, + arguments: widget.vm.channel.id); + }, + ), + ); + } + }, + ); + } + + /// Invite members is only visible if the channel is closed + num _canInviteUsers() { + if (widget.vm.channel.visibility == ChannelVisibility.CLOSED) { + return 1; + } else { + return 0; + } + } + + _showChangeRsvpDialog(BuildContext context) { + final completer = Completer(); + completer.future.then((rsvp) { + showDialogRsvp(context, rsvp); + }); + + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => + CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: Text( + CirclesLocalizations + .of(context) + .eventRsvpYes, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + onPressed: () { + _changeRsvp(context, completer, RSVP.YES); + }, + ), + CupertinoActionSheetAction( + child: Text( + CirclesLocalizations + .of(context) + .eventRsvpMaybe, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + onPressed: () { + _changeRsvp(context, completer, RSVP.MAYBE); + }, + ), + CupertinoActionSheetAction( + child: Text( + CirclesLocalizations + .of(context) + .eventRsvpNo, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + onPressed: () { + _changeRsvp(context, completer, RSVP.NO); + }, + ), + ], + ), + ); + } + + void _changeRsvp(BuildContext context, Completer completer, RSVP rsvp) { + Navigator.of(context).pop(); + // close the side when the user changes RSVP + widget.sideOpenController.value = false; + StoreProvider.of(context).dispatch(RsvpAction(rsvp, completer)); + } + + _buildLogout() { + return Visibility( + visible: !widget.vm.canChangeRsvp, + child: Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Center( + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).eventLeave, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _showLeaveChannelDialog(context); + }, + ), + ), + ), + ); + } + + _showLeaveChannelDialog(BuildContext context) { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(CirclesLocalizations.of(context).channelLeaveAlertTitle), + content: + Text(CirclesLocalizations.of(context).channelLeaveAlertMessage), + actions: [ + PlatformDialogAction( + child: Text(CirclesLocalizations.of(context).yes), + onPressed: () { + StoreProvider.of(context).dispatch( + LeaveChannelAction( + widget.vm.groupId, + widget.vm.channel, + widget.vm.user.uid, + ), + ); + widget.sideOpenController.value = false; + Navigator.pop(context); + }), + PlatformDialogAction( + child: Text(CirclesLocalizations.of(context).cancel), + onPressed: () { + Navigator.pop(context); + }), + ], + ), + ); + } +} + +class _TabBarView extends StatefulWidget { + final TabController controller; + final List children; + + const _TabBarView({ + @required this.controller, + @required this.children, + }); + + @override + _TabBarViewState createState() => _TabBarViewState(); +} + +class _TabBarViewState extends State<_TabBarView> { + int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.controller.index; + widget.controller.addListener(() { + setState(() { + _currentIndex = widget.controller.index; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: widget.children[_currentIndex], + ); + } +} + +class _Style { + static const headerSize = 256.0; + static const iconSize = 36.0; +} diff --git a/lib/presentation/channel/event/event_details_viewmodel.dart b/lib/presentation/channel/event/event_details_viewmodel.dart new file mode 100644 index 0000000..ec0a5fa --- /dev/null +++ b/lib/presentation/channel/event/event_details_viewmodel.dart @@ -0,0 +1,136 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/util/date_formatting.dart"; +import "package:flutter/cupertino.dart" as prefix0; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'event_details_viewmodel.g.dart'; + +abstract class EventDetailsViewModel + implements Built { + String get name; + + String get description; + + ChannelVisibility get visibility; + + BuiltList get members; + + BuiltMap get rsvpStatus; + + int get guestCount; + + String get groupId; + + Channel get channel; + + String get eventDate; + + String get eventTime; + + String get venue; + + User get user; + + RSVP get userRsvp; + + bool get editable; + + bool get canChangeRsvp; + + EventDetailsViewModel._(); + + factory EventDetailsViewModel( + [void Function(EventDetailsViewModelBuilder) updates]) = + _$EventDetailsViewModel; + + static EventDetailsViewModel fromStore(context, Store store) { + final channel = getSelectedChannel(store.state); + final members = store.state.groupUsers + .where((user) => channel.users.any((u) => u.id == user.uid)) + .toList(); + + final String dateString = _parseDate(context, channel); + final String timeString = _parseTime(context, channel); + final rsvpStatus = + channel.users.asMap().map((k, v) => MapEntry(v.id, v.rsvp)); + + members.sort((u1, u2) => _sortRsvpAndHost(rsvpStatus, u1, u2, channel.authorId)); + + return EventDetailsViewModel((t) => t + ..name = channel.name + ..visibility = channel.visibility + ..description = channel.description + ..members.addAll(members) + ..guestCount = rsvpStatus.values + .where((v) => v == RSVP.YES || v == RSVP.MAYBE) + .length + ..rsvpStatus.addAll(rsvpStatus) + ..groupId = store.state.selectedGroupId + ..channel = channel.toBuilder() + ..user = store.state.user.toBuilder() + ..userRsvp = rsvpStatus[store.state.user.uid] ?? RSVP.UNSET + ..editable = _isEditable(channel, store) + ..eventDate = dateString + ..eventTime = timeString + ..canChangeRsvp = channel.startDate.isAfter(DateTime.now()) + ..venue = channel.venue ?? ""); + } + + // Allow to edit if: + // 1. the current user is the author of the event + // 2. the start date is after now (so, it did not pass) + static bool _isEditable(Channel channel, Store store) { + return channel.authorId == store.state.user.uid && channel.startDate.isAfter(DateTime.now()); + } + + static int _sortRsvpAndHost(Map rsvpStatus, User u1, User u2, String authorId) { + + // Helper to calculate a sorting score, smaller goes first + int _rsvpVal(RSVP rsvp) { + switch (rsvp) { + case RSVP.YES: + return 0; + case RSVP.MAYBE: + return 1; + case RSVP.NO: + case RSVP.UNSET: + default: + return 2; + } + } + + final rsvp1 = rsvpStatus[u1.uid]; + final rsvp2 = rsvpStatus[u2.uid]; + final u1Host = u1.uid == authorId; + final u2Host = u2.uid == authorId; + final val1 = u1Host ? -1 : _rsvpVal(rsvp1); + final val2 = u2Host ? -1 : _rsvpVal(rsvp2); + final diff = val1 - val2; + if (diff == 0) { + // Order by name if RSVP is same + return u1.name.compareTo(u2.name); + } else { + return diff; + } + } + + static String _parseDate(context, Channel channel) { + if (channel.startDate == null) { + return ""; + } + return formatDate(context, channel.startDate); + } + + static String _parseTime(prefix0.BuildContext context, Channel channel) { + if (channel.hasStartTime != null && !channel.hasStartTime) { + return ""; + } + return formatTime(context, channel.startDate); + } +} diff --git a/lib/presentation/channel/event/event_details_viewmodel.g.dart b/lib/presentation/channel/event/event_details_viewmodel.g.dart new file mode 100644 index 0000000..df02e06 --- /dev/null +++ b/lib/presentation/channel/event/event_details_viewmodel.g.dart @@ -0,0 +1,348 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'event_details_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$EventDetailsViewModel extends EventDetailsViewModel { + @override + final String name; + @override + final String description; + @override + final ChannelVisibility visibility; + @override + final BuiltList members; + @override + final BuiltMap rsvpStatus; + @override + final int guestCount; + @override + final String groupId; + @override + final Channel channel; + @override + final String eventDate; + @override + final String eventTime; + @override + final String venue; + @override + final User user; + @override + final RSVP userRsvp; + @override + final bool editable; + @override + final bool canChangeRsvp; + + factory _$EventDetailsViewModel( + [void Function(EventDetailsViewModelBuilder) updates]) => + (new EventDetailsViewModelBuilder()..update(updates)).build(); + + _$EventDetailsViewModel._( + {this.name, + this.description, + this.visibility, + this.members, + this.rsvpStatus, + this.guestCount, + this.groupId, + this.channel, + this.eventDate, + this.eventTime, + this.venue, + this.user, + this.userRsvp, + this.editable, + this.canChangeRsvp}) + : super._() { + if (name == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'name'); + } + if (description == null) { + throw new BuiltValueNullFieldError( + 'EventDetailsViewModel', 'description'); + } + if (visibility == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'visibility'); + } + if (members == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'members'); + } + if (rsvpStatus == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'rsvpStatus'); + } + if (guestCount == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'guestCount'); + } + if (groupId == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'groupId'); + } + if (channel == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'channel'); + } + if (eventDate == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'eventDate'); + } + if (eventTime == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'eventTime'); + } + if (venue == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'venue'); + } + if (user == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'user'); + } + if (userRsvp == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'userRsvp'); + } + if (editable == null) { + throw new BuiltValueNullFieldError('EventDetailsViewModel', 'editable'); + } + if (canChangeRsvp == null) { + throw new BuiltValueNullFieldError( + 'EventDetailsViewModel', 'canChangeRsvp'); + } + } + + @override + EventDetailsViewModel rebuild( + void Function(EventDetailsViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + EventDetailsViewModelBuilder toBuilder() => + new EventDetailsViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is EventDetailsViewModel && + name == other.name && + description == other.description && + visibility == other.visibility && + members == other.members && + rsvpStatus == other.rsvpStatus && + guestCount == other.guestCount && + groupId == other.groupId && + channel == other.channel && + eventDate == other.eventDate && + eventTime == other.eventTime && + venue == other.venue && + user == other.user && + userRsvp == other.userRsvp && + editable == other.editable && + canChangeRsvp == other.canChangeRsvp; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc( + $jc(0, + name.hashCode), + description + .hashCode), + visibility.hashCode), + members.hashCode), + rsvpStatus.hashCode), + guestCount.hashCode), + groupId.hashCode), + channel.hashCode), + eventDate.hashCode), + eventTime.hashCode), + venue.hashCode), + user.hashCode), + userRsvp.hashCode), + editable.hashCode), + canChangeRsvp.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('EventDetailsViewModel') + ..add('name', name) + ..add('description', description) + ..add('visibility', visibility) + ..add('members', members) + ..add('rsvpStatus', rsvpStatus) + ..add('guestCount', guestCount) + ..add('groupId', groupId) + ..add('channel', channel) + ..add('eventDate', eventDate) + ..add('eventTime', eventTime) + ..add('venue', venue) + ..add('user', user) + ..add('userRsvp', userRsvp) + ..add('editable', editable) + ..add('canChangeRsvp', canChangeRsvp)) + .toString(); + } +} + +class EventDetailsViewModelBuilder + implements Builder { + _$EventDetailsViewModel _$v; + + String _name; + String get name => _$this._name; + set name(String name) => _$this._name = name; + + String _description; + String get description => _$this._description; + set description(String description) => _$this._description = description; + + ChannelVisibility _visibility; + ChannelVisibility get visibility => _$this._visibility; + set visibility(ChannelVisibility visibility) => + _$this._visibility = visibility; + + ListBuilder _members; + ListBuilder get members => _$this._members ??= new ListBuilder(); + set members(ListBuilder members) => _$this._members = members; + + MapBuilder _rsvpStatus; + MapBuilder get rsvpStatus => + _$this._rsvpStatus ??= new MapBuilder(); + set rsvpStatus(MapBuilder rsvpStatus) => + _$this._rsvpStatus = rsvpStatus; + + int _guestCount; + int get guestCount => _$this._guestCount; + set guestCount(int guestCount) => _$this._guestCount = guestCount; + + String _groupId; + String get groupId => _$this._groupId; + set groupId(String groupId) => _$this._groupId = groupId; + + ChannelBuilder _channel; + ChannelBuilder get channel => _$this._channel ??= new ChannelBuilder(); + set channel(ChannelBuilder channel) => _$this._channel = channel; + + String _eventDate; + String get eventDate => _$this._eventDate; + set eventDate(String eventDate) => _$this._eventDate = eventDate; + + String _eventTime; + String get eventTime => _$this._eventTime; + set eventTime(String eventTime) => _$this._eventTime = eventTime; + + String _venue; + String get venue => _$this._venue; + set venue(String venue) => _$this._venue = venue; + + UserBuilder _user; + UserBuilder get user => _$this._user ??= new UserBuilder(); + set user(UserBuilder user) => _$this._user = user; + + RSVP _userRsvp; + RSVP get userRsvp => _$this._userRsvp; + set userRsvp(RSVP userRsvp) => _$this._userRsvp = userRsvp; + + bool _editable; + bool get editable => _$this._editable; + set editable(bool editable) => _$this._editable = editable; + + bool _canChangeRsvp; + bool get canChangeRsvp => _$this._canChangeRsvp; + set canChangeRsvp(bool canChangeRsvp) => + _$this._canChangeRsvp = canChangeRsvp; + + EventDetailsViewModelBuilder(); + + EventDetailsViewModelBuilder get _$this { + if (_$v != null) { + _name = _$v.name; + _description = _$v.description; + _visibility = _$v.visibility; + _members = _$v.members?.toBuilder(); + _rsvpStatus = _$v.rsvpStatus?.toBuilder(); + _guestCount = _$v.guestCount; + _groupId = _$v.groupId; + _channel = _$v.channel?.toBuilder(); + _eventDate = _$v.eventDate; + _eventTime = _$v.eventTime; + _venue = _$v.venue; + _user = _$v.user?.toBuilder(); + _userRsvp = _$v.userRsvp; + _editable = _$v.editable; + _canChangeRsvp = _$v.canChangeRsvp; + _$v = null; + } + return this; + } + + @override + void replace(EventDetailsViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$EventDetailsViewModel; + } + + @override + void update(void Function(EventDetailsViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$EventDetailsViewModel build() { + _$EventDetailsViewModel _$result; + try { + _$result = _$v ?? + new _$EventDetailsViewModel._( + name: name, + description: description, + visibility: visibility, + members: members.build(), + rsvpStatus: rsvpStatus.build(), + guestCount: guestCount, + groupId: groupId, + channel: channel.build(), + eventDate: eventDate, + eventTime: eventTime, + venue: venue, + user: user.build(), + userRsvp: userRsvp, + editable: editable, + canChangeRsvp: canChangeRsvp); + } catch (_) { + String _$failedField; + try { + _$failedField = 'members'; + members.build(); + _$failedField = 'rsvpStatus'; + rsvpStatus.build(); + + _$failedField = 'channel'; + channel.build(); + + _$failedField = 'user'; + user.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'EventDetailsViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/event/rsvp_dialog.dart b/lib/presentation/channel/event/rsvp_dialog.dart new file mode 100644 index 0000000..e9c7fcc --- /dev/null +++ b/lib/presentation/channel/event/rsvp_dialog.dart @@ -0,0 +1,81 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; + +showDialogRsvp(context, RSVP rsvp) { + showDialog( + context: context, + builder: (context) { + return Center( + child: Card( + child: Container( + width: _Style.dialogWidth, + height: _Style.dialogHeight, + child: _dialogContent(context, rsvp), + ), + ), + ); + }); +} + +String _rsvpIcon(RSVP rsvp) { + switch (rsvp) { + case RSVP.YES: + return "assets/graphics/channel/rsvp/rsvp_yes_large.png"; + break; + case RSVP.MAYBE: + return "assets/graphics/channel/rsvp/rsvp_maybe_large.png"; + break; + case RSVP.NO: + return "assets/graphics/channel/rsvp/rsvp_no_large.png"; + break; + case RSVP.UNSET: + default: + return ""; + break; + } +} + +Column _dialogContent(context, RSVP rsvp) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Image.asset( + _rsvpIcon(rsvp), + height: _Style.dialogIconSize, + width: _Style.dialogIconSize, + ), + _rsvpText(context, rsvp), + ], + ); +} + +Widget _rsvpText(context, RSVP rsvp) { + switch (rsvp) { + case RSVP.YES: + return Text( + CirclesLocalizations.of(context).eventRsvpDialogYes, + style: AppTheme.dialogRsvpYesTextStyle, + ); + case RSVP.MAYBE: + return Text( + CirclesLocalizations.of(context).eventRsvpDialogMaybe, + style: AppTheme.dialogRsvpMaybeTextStyle, + ); + case RSVP.NO: + return Text( + CirclesLocalizations.of(context).eventRsvpDialogNo, + style: AppTheme.dialogRsvpNoTextStyle, + ); + case RSVP.UNSET: + default: + return Container(); + } +} + +class _Style { + static const dialogWidth = 188.0; + static const dialogHeight = 188.0; + static const dialogIconSize = 64.0; +} diff --git a/lib/presentation/channel/event/rsvp_header.dart b/lib/presentation/channel/event/rsvp_header.dart new file mode 100644 index 0000000..104ee60 --- /dev/null +++ b/lib/presentation/channel/event/rsvp_header.dart @@ -0,0 +1,95 @@ +import "dart:async"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class RsvpHeader extends StatelessWidget { + const RsvpHeader({ + @required this.completer, + }); + + final Completer completer; + + @override + Widget build(BuildContext context) { + return Container( + height: 64, + decoration: BoxDecoration( + color: AppTheme.colorMintGreen, + image: DecorationImage( + colorFilter: ColorFilter.mode( + Color.fromRGBO(255, 255, 255, 0.1), BlendMode.modulate), + image: AssetImage("assets/graphics/visual_twist_white_petrol.png"), + fit: BoxFit.cover, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildButton( + context, + text: CirclesLocalizations.of(context).eventRsvpYes, + textColor: Colors.white, + solid: AppTheme.colorDarkBlue, + rsvp: RSVP.YES, + ), + _buildButton( + context, + text: CirclesLocalizations.of(context).eventRsvpMaybe, + rsvp: RSVP.MAYBE, + ), + _buildButton( + context, + text: CirclesLocalizations.of(context).eventRsvpNo, + rsvp: RSVP.NO, + ), + ], + ), + ); + } + + Widget _buildButton( + context, { + @required String text, + @required RSVP rsvp, + Color textColor = AppTheme.colorDarkBlue, + Color solid = Colors.transparent, + }) { + final buttonWidth = + (MediaQuery.of(context).size.width - AppTheme.appMargin * 4) / 3; + return Container( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.all(Radius.circular(24.0)), + onTap: () { + _submitRsvpStatus(context, rsvp); + }, + child: Center( + child: Text( + text, + style: AppTheme.buttonMediumTextStyle.apply(color: textColor), + ), + ), + ), + ), + decoration: BoxDecoration( + border: Border.all( + color: AppTheme.colorDarkBlue, + ), + borderRadius: BorderRadius.all(Radius.circular(24.0)), + color: solid), + width: buttonWidth, + height: 33, + ); + } + + void _submitRsvpStatus(context, RSVP rsvp) { + StoreProvider.of(context).dispatch(RsvpAction(rsvp, completer)); + } +} diff --git a/lib/presentation/channel/input/attach_button.dart b/lib/presentation/channel/input/attach_button.dart new file mode 100644 index 0000000..73dfbec --- /dev/null +++ b/lib/presentation/channel/input/attach_button.dart @@ -0,0 +1,118 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/attachment/attachment_actions.dart"; +import "package:circles_app/presentation/common/platform_alerts.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/permissions.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:image_picker/image_picker.dart"; + +class AttachButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return IconButton( + padding: EdgeInsets.all(16), + icon: Image.asset( + "assets/graphics/input/icon_add_content.png", + scale: 2.5, + ), + onPressed: () { + _showDialogCameraOrGalleryCupertino(context); + }, + ); + } + + _pickFiles(BuildContext context) async { + final permission = await getStoragePermission(); + if (!permission) { + showNoAccessAlert(context: context, type: AccessResourceType.STORAGE); + } else { + await Navigator.pushReplacementNamed(context, Routes.imagePicker); + } + } + + Future _takePicture(BuildContext context) async { + final bool cameraPermission = await getCameraPermission(); + if (!cameraPermission) { + await showNoAccessAlert( + context: context, type: AccessResourceType.CAMERA); + return; + } + + final imageFile = await ImagePicker.pickImage(source: ImageSource.camera); + if (imageFile == null) return; + StoreProvider.of(context).dispatch( + NewMessageWithMultipleFilesAction([imageFile.path], true), + ); + await Navigator.of(context).maybePop(); + } + + void _showDialogCameraOrGalleryCupertino(context) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: _cameraItem(context), + onPressed: () { + _takePicture(context); + }, + ), + CupertinoActionSheetAction( + child: _galleryItem(context), + onPressed: () { + _pickFiles(context); + }, + ), + ], + ), + ); + } + + Row _cameraItem(BuildContext context) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: Image.asset( + "assets/graphics/input/icon_camera.png", + scale: 3, + ), + ), + Text( + CirclesLocalizations.of(context).attachModalCamera, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + ], + ); + } + + Row _galleryItem(BuildContext context) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: Image.asset( + "assets/graphics/input/icon_pictures.png", + scale: 3, + ), + ), + Text( + CirclesLocalizations.of(context).attachModalGallery, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + ], + ); + } +} diff --git a/lib/presentation/channel/input/chat_input.dart b/lib/presentation/channel/input/chat_input.dart new file mode 100644 index 0000000..c9ffe82 --- /dev/null +++ b/lib/presentation/channel/input/chat_input.dart @@ -0,0 +1,122 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/ui/ui_actions.dart"; +import "package:circles_app/presentation/channel/input/attach_button.dart"; +import "package:circles_app/presentation/channel/input/chat_input_viewmodel.dart"; +import "package:circles_app/presentation/channel/input/send_button.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class ChatInput extends StatefulWidget { + const ChatInput({Key key}) : super(key: key); + + @override + _ChatInputState createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + TextEditingController _controller; + bool _textInserted = false; + String _channelId; + String _groupId; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _controller.addListener(_updateInputTextChange); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + offset: Offset(0.0, -1.0), + ) + ], + ), + child: Column( + children: [ + StoreConnector( + distinct: true, + onInit: (store) { + // Keep channel and group id for later + // so we can store the chat input draft + _channelId = store.state.channelState.selectedChannel; + _groupId = store.state.selectedGroupId; + }, + onInitialBuild: (vm) { + // Load previous draft + _controller.text = vm.inputDraft; + }, + onDispose: (store) { + // Store the chat input draft just before disposing the chat + // (so before changing channels) + store.dispatch(UpdatedChatDraftAction( + groupId: _groupId, + channelId: _channelId, + text: _controller.text, + )); + }, + builder: (BuildContext context, ChatInputViewModel vm) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: 44.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AttachButton(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 8.0), + child: TextField( + maxLines: 6, + minLines: 1, + controller: _controller, + style: AppTheme.inputTextStyle, + cursorColor: AppTheme.colorTextEnabled, + decoration: InputDecoration( + border: InputBorder.none, + hintText: CirclesLocalizations.of(context) + .channelInputHint, + hintStyle: AppTheme.inputHintTextStyle, + ), + ), + ), + ), + SendButton( + controller: _controller, + enabled: _textInserted, + ), + ], + ), + ); + }, + converter: ChatInputViewModel.fromStore, + ), + ], + ), + ), + ); + } + + _updateInputTextChange() { + final bool inputState = _controller.text.length > 0; + if (inputState == _textInserted) return; + setState(() { + _textInserted = inputState; + }); + } + + @override + void dispose() { + // Also removes the _updateInputTextChange listener. + super.dispose(); + _controller.dispose(); + } +} diff --git a/lib/presentation/channel/input/chat_input_viewmodel.dart b/lib/presentation/channel/input/chat_input_viewmodel.dart new file mode 100644 index 0000000..7e315f5 --- /dev/null +++ b/lib/presentation/channel/input/chat_input_viewmodel.dart @@ -0,0 +1,25 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/ui/ui_state_selector.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'chat_input_viewmodel.g.dart'; + +abstract class ChatInputViewModel + implements Built { + + @nullable + String get inputDraft; + + ChatInputViewModel._(); + + factory ChatInputViewModel( + [void Function(ChatInputViewModelBuilder) updates]) = + _$ChatInputViewModel; + + static ChatInputViewModel fromStore(Store store) { + return ChatInputViewModel((v) => v + ..inputDraft = getInputDraftSelectedChannel(store.state)); + } +} diff --git a/lib/presentation/channel/input/chat_input_viewmodel.g.dart b/lib/presentation/channel/input/chat_input_viewmodel.g.dart new file mode 100644 index 0000000..f4447d2 --- /dev/null +++ b/lib/presentation/channel/input/chat_input_viewmodel.g.dart @@ -0,0 +1,86 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_input_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ChatInputViewModel extends ChatInputViewModel { + @override + final String inputDraft; + + factory _$ChatInputViewModel( + [void Function(ChatInputViewModelBuilder) updates]) => + (new ChatInputViewModelBuilder()..update(updates)).build(); + + _$ChatInputViewModel._({this.inputDraft}) : super._(); + + @override + ChatInputViewModel rebuild( + void Function(ChatInputViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChatInputViewModelBuilder toBuilder() => + new ChatInputViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChatInputViewModel && inputDraft == other.inputDraft; + } + + @override + int get hashCode { + return $jf($jc(0, inputDraft.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChatInputViewModel') + ..add('inputDraft', inputDraft)) + .toString(); + } +} + +class ChatInputViewModelBuilder + implements Builder { + _$ChatInputViewModel _$v; + + String _inputDraft; + String get inputDraft => _$this._inputDraft; + set inputDraft(String inputDraft) => _$this._inputDraft = inputDraft; + + ChatInputViewModelBuilder(); + + ChatInputViewModelBuilder get _$this { + if (_$v != null) { + _inputDraft = _$v.inputDraft; + _$v = null; + } + return this; + } + + @override + void replace(ChatInputViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChatInputViewModel; + } + + @override + void update(void Function(ChatInputViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChatInputViewModel build() { + final _$result = _$v ?? new _$ChatInputViewModel._(inputDraft: inputDraft); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/input/send_button.dart b/lib/presentation/channel/input/send_button.dart new file mode 100644 index 0000000..821e779 --- /dev/null +++ b/lib/presentation/channel/input/send_button.dart @@ -0,0 +1,47 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/presentation/channel/messages_scroll_controller.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class SendButton extends StatelessWidget { + const SendButton({ + Key key, + @required TextEditingController controller, + @required bool enabled, + }) : _controller = controller, + _enabled = enabled, + super(key: key); + + final TextEditingController _controller; + final bool _enabled; + + @override + Widget build(BuildContext context) { + return FlatButton( + child: Text( + CirclesLocalizations.of(context).channelInputSend, + style: AppTheme.buttonTextStyle, + ), + padding: EdgeInsets.all(16), + disabledTextColor: AppTheme.colorTextDisabled, + textColor: AppTheme.colorTextEnabled, + onPressed: !_enabled + ? null + : () { + final text = _controller.text; + _controller.clear(); + StoreProvider.of(context).dispatch( + SendMessage(text), + ); + MessagesScrollController.of(context).scrollController.animateTo( + 0.0, + duration: Duration(milliseconds: 600), + curve: Curves.fastOutSlowIn, + ); + }, + ); + } +} diff --git a/lib/presentation/channel/invite/invite_to_channel_screen.dart b/lib/presentation/channel/invite/invite_to_channel_screen.dart new file mode 100644 index 0000000..1d6b954 --- /dev/null +++ b/lib/presentation/channel/invite/invite_to_channel_screen.dart @@ -0,0 +1,85 @@ +import "dart:async"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/channel/invite/invite_to_channel_viewmodel.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/user/user_item.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class InviteToChannelScreen extends StatefulWidget { + @override + _InviteToChannelScreenState createState() => _InviteToChannelScreenState(); +} + +class _InviteToChannelScreenState extends State { + final _selectedUsers = []; + + @override + Widget build(BuildContext context) { + final String channelId = ModalRoute.of(context).settings.arguments; + + return StoreConnector( + distinct: true, + converter: InviteToChannelViewModel.fromStore(channelId), + builder: (context, vm) { + return Scaffold( + appBar: _buildAppBar(context, vm), + body: _buildUsersList(context, vm), + ); + }, + ); + } + + Widget _buildAppBar(BuildContext context, InviteToChannelViewModel vm) { + return CommonAppBar( + title: CirclesLocalizations.of(context).channelInviteTitle, + action: Visibility( + visible: _selectedUsers.isNotEmpty, + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).invite, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _validateAndSubmit(vm); + }, + ), + ), + ); + } + + void _validateAndSubmit(InviteToChannelViewModel vm) { + final completer = Completer(); + completer.future.whenComplete(() { + Navigator.of(context).pop(); + }); + vm.inviteToChannel(_selectedUsers, completer); + } + + Widget _buildUsersList(BuildContext context, InviteToChannelViewModel vm) { + return ListView.builder( + padding: EdgeInsets.all(0), + itemCount: vm.newUsers.length, + itemBuilder: (BuildContext context, int index) { + final member = vm.newUsers[index]; + return UserItem( + user: member, + selected: _selectedUsers.contains(member.uid), + selectionHandler: (User user) { + setState(() { + if (_selectedUsers.contains(user.uid)) { + _selectedUsers.remove(user.uid); + } else { + _selectedUsers.add(user.uid); + } + }); + }, + ); + }, + ); + } +} diff --git a/lib/presentation/channel/invite/invite_to_channel_viewmodel.dart b/lib/presentation/channel/invite/invite_to_channel_viewmodel.dart new file mode 100644 index 0000000..9ae5714 --- /dev/null +++ b/lib/presentation/channel/invite/invite_to_channel_viewmodel.dart @@ -0,0 +1,43 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'invite_to_channel_viewmodel.g.dart'; + +abstract class InviteToChannelViewModel + implements + Built { + InviteToChannelViewModel._(); + + BuiltList get newUsers; + + @BuiltValueField(compare: false) + void Function(Iterable, Completer) get inviteToChannel; + + factory InviteToChannelViewModel( + [void Function(InviteToChannelViewModelBuilder) updates]) = + _$InviteToChannelViewModel; + + static InviteToChannelViewModel Function(Store store) fromStore( + String channelId) { + return (Store store) { + final selectedGroup = store.state.selectedGroupId; + final channel = store.state.groups[selectedGroup].channels[channelId]; + + // Filter out any user that is already part of the channel + final newUsers = store.state.groupUsers + .where((user) => !channel.users.any((u) => u.id == user.uid)); + + return InviteToChannelViewModel((vm) => vm + ..newUsers.replace(newUsers) + ..inviteToChannel = (users, completer) => + store.dispatch(InviteToChannelAction(users, channel, completer))); + }; + } +} diff --git a/lib/presentation/channel/invite/invite_to_channel_viewmodel.g.dart b/lib/presentation/channel/invite/invite_to_channel_viewmodel.g.dart new file mode 100644 index 0000000..8803428 --- /dev/null +++ b/lib/presentation/channel/invite/invite_to_channel_viewmodel.g.dart @@ -0,0 +1,124 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'invite_to_channel_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$InviteToChannelViewModel extends InviteToChannelViewModel { + @override + final BuiltList newUsers; + @override + final void Function(Iterable, Completer) inviteToChannel; + + factory _$InviteToChannelViewModel( + [void Function(InviteToChannelViewModelBuilder) updates]) => + (new InviteToChannelViewModelBuilder()..update(updates)).build(); + + _$InviteToChannelViewModel._({this.newUsers, this.inviteToChannel}) + : super._() { + if (newUsers == null) { + throw new BuiltValueNullFieldError( + 'InviteToChannelViewModel', 'newUsers'); + } + if (inviteToChannel == null) { + throw new BuiltValueNullFieldError( + 'InviteToChannelViewModel', 'inviteToChannel'); + } + } + + @override + InviteToChannelViewModel rebuild( + void Function(InviteToChannelViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + InviteToChannelViewModelBuilder toBuilder() => + new InviteToChannelViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is InviteToChannelViewModel && newUsers == other.newUsers; + } + + @override + int get hashCode { + return $jf($jc(0, newUsers.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('InviteToChannelViewModel') + ..add('newUsers', newUsers) + ..add('inviteToChannel', inviteToChannel)) + .toString(); + } +} + +class InviteToChannelViewModelBuilder + implements + Builder { + _$InviteToChannelViewModel _$v; + + ListBuilder _newUsers; + ListBuilder get newUsers => + _$this._newUsers ??= new ListBuilder(); + set newUsers(ListBuilder newUsers) => _$this._newUsers = newUsers; + + void Function(Iterable, Completer) _inviteToChannel; + void Function(Iterable, Completer) get inviteToChannel => + _$this._inviteToChannel; + set inviteToChannel( + void Function(Iterable, Completer) inviteToChannel) => + _$this._inviteToChannel = inviteToChannel; + + InviteToChannelViewModelBuilder(); + + InviteToChannelViewModelBuilder get _$this { + if (_$v != null) { + _newUsers = _$v.newUsers?.toBuilder(); + _inviteToChannel = _$v.inviteToChannel; + _$v = null; + } + return this; + } + + @override + void replace(InviteToChannelViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$InviteToChannelViewModel; + } + + @override + void update(void Function(InviteToChannelViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$InviteToChannelViewModel build() { + _$InviteToChannelViewModel _$result; + try { + _$result = _$v ?? + new _$InviteToChannelViewModel._( + newUsers: newUsers.build(), inviteToChannel: inviteToChannel); + } catch (_) { + String _$failedField; + try { + _$failedField = 'newUsers'; + newUsers.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'InviteToChannelViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/join_channel.dart b/lib/presentation/channel/join_channel.dart new file mode 100644 index 0000000..5f31d4e --- /dev/null +++ b/lib/presentation/channel/join_channel.dart @@ -0,0 +1,48 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class JoinChannel extends StatelessWidget { + final String _groupId; + final Channel _channel; + final User _user; + + const JoinChannel(this._groupId, this._channel, this._user); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 30.0, bottom: 40.0), + child: Container( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + Container( + child: Text( + CirclesLocalizations.of(context).channelJoinMessage), + ), + Container( + child: RaisedButton( + color: Colors.blue, + textColor: Colors.white, + child: Text(CirclesLocalizations.of(context).channelJoin), + onPressed: () { + StoreProvider.of(context) + .dispatch(JoinChannelAction(groupId: _groupId, channel: _channel, user: _user)); + }, + )) + ], + )), + ], + ), + )); + } +} diff --git a/lib/presentation/channel/message/media/MediaMessage.dart b/lib/presentation/channel/message/media/MediaMessage.dart new file mode 100644 index 0000000..7e98151 --- /dev/null +++ b/lib/presentation/channel/message/media/MediaMessage.dart @@ -0,0 +1,291 @@ +import "dart:io"; + +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/attachment/attachment_actions.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/presentation/image/image_with_loader.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class MessageMedia extends StatelessWidget { + const MessageMedia(this.message); + + final Message message; + + @override + Widget build(BuildContext context) { + switch (message.mediaStatus) { + case MediaStatus.UPLOADING: + return _loadingProgress(); + break; + case MediaStatus.DONE: + return InkWell( + child: _buildMosaic(), + onTap: () { + Navigator.pushNamed(context, Routes.image, arguments: message); + }, + ); + break; + case MediaStatus.ERROR: + default: + return _buildError(context); + break; + } + } + + Widget _buildMosaic() { + final media = message.media.toList(); + switch (media.length) { + case 0: + Logger.w("Empty media list"); + return SizedBox.shrink(); + case 1: + return _buildSingleImage(media.first); + case 2: + return _buildTwoImages(media); + case 3: + return _buildThreeImages(media); + case 4: + return _buildFourImages(media); + default: + return _buildFiveOrMoreImages(media); + } + } + + Widget _buildSingleImage(String url) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 296, + maxHeight: 296, + ), + child: _aspectRatioImage( + url: url, + // backend should store the aspect ratio if there's only one media file + // defaults to 4:3 + aspectRatio: message.mediaAspectRatio ?? 4 / 3, + ), + ); + } + + Widget _aspectRatioImage({ + String url, + double aspectRatio = 1, + }) { + return AspectRatio( + aspectRatio: aspectRatio, + child: ImageWithLoader(url: url), + ); + } + + Widget _buildTwoImages(List media) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 296, + maxHeight: 148, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: _aspectRatioImage(url: media[0])), + _spacer(), + Expanded(child: _aspectRatioImage(url: media[1])), + ], + ), + ); + } + + Widget _buildThreeImages(List media) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 296, + maxHeight: 196, + ), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 196, child: _aspectRatioImage(url: media[0])), + _spacer(), + Expanded( + flex: 97, + child: Column( + children: [ + _aspectRatioImage(url: media[1]), + _spacer(), + _aspectRatioImage(url: media[2]), + ], + ), + ), + ], + ), + ); + } + + SizedBox _spacer() { + return SizedBox.fromSize( + size: Size(2, 2), + ); + } + + Widget _buildFourImages(List media) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 296, + maxHeight: 296, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: _aspectRatioImage(url: media[0])), + _spacer(), + Expanded(child: _aspectRatioImage(url: media[1])), + ], + ), + _spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: _aspectRatioImage(url: media[2])), + _spacer(), + Expanded(child: _aspectRatioImage(url: media[3])), + ], + ), + ], + ), + ); + } + + Widget _buildFiveOrMoreImages(List media) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 296, + maxHeight: 248, + ), + child: Column( + children: [ + Row( + children: [ + Expanded(child: _aspectRatioImage(url: media[0])), + _spacer(), + Expanded(child: _aspectRatioImage(url: media[1])), + ], + ), + _spacer(), + Row( + children: [ + Expanded(child: _aspectRatioImage(url: media[2])), + _spacer(), + Expanded(child: _aspectRatioImage(url: media[3])), + _spacer(), + Expanded( + child: _plusMorePictures( + valueCount: media.length - 5, + child: _aspectRatioImage( + url: media[4], + ), + ), + ), + ], + ) + ], + ), + ); + } + + Widget _loadingProgress() { + return Container( + height: 148, + width: 148, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(16)), + color: AppTheme.colorGrey241, + ), + child: Center( + child: SizedBox( + width: 48, + height: 48, + child: _buildCircularProgressIndicator(), + ), + )); + } + + CircularProgressIndicator _buildCircularProgressIndicator() { + return CircularProgressIndicator( + backgroundColor: AppTheme.colorGrey225, + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(AppTheme.colorGrey155), + ); + } + + Widget _buildError(BuildContext context) { + return Container( + height: 148, + width: 148, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(16)), + color: AppTheme.colorGrey241, + ), + child: Center( + child: InkWell( + onTap: () { + _retryPictureUpload(context); + }, + child: SizedBox( + width: 48, + height: 48, + child: Image.asset( + "assets/graphics/upload/indicator_0_try_again.png")), + ), + )); + } + + /// Retry picture upload + /// + /// Delete the old upload message and create a new one. + /// + /// The failed to upload paths should be in the message.media still, + /// use them to upload pictures again. + void _retryPictureUpload(BuildContext context) { + final store = StoreProvider.of(context); + store.dispatch(DeleteMessage(message.id)); + store.dispatch(NewMessageWithMultipleFilesAction( + message.media.toList(), + Platform.isAndroid, + )); + } + + Widget _plusMorePictures({ + int valueCount, + Widget child, + }) { + if (valueCount <= 0) { + return child; + } else { + return Stack( + alignment: Alignment.center, + children: [ + child, + AspectRatio( + aspectRatio: 1, + child: Container( + color: AppTheme.colorWhite_50, + child: Center( + child: Text( + "+$valueCount", + style: AppTheme.plusManyPicturesTextStyle, + ), + ), + ), + ) + ], + ); + } + } +} diff --git a/lib/presentation/channel/message/message_body.dart b/lib/presentation/channel/message/message_body.dart new file mode 100644 index 0000000..9a8e4ce --- /dev/null +++ b/lib/presentation/channel/message/message_body.dart @@ -0,0 +1,45 @@ +import "package:circles_app/model/message.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; +import "package:linkify/linkify.dart"; +import "package:url_launcher/url_launcher.dart"; + +class MessageBody extends StatelessWidget { + const MessageBody({ + Key key, + @required Message message, + }) : _message = message, + super(key: key); + + final Message _message; + + @override + Widget build(BuildContext context) { + if (_message.body.isEmpty) { + return SizedBox.shrink(); + } + final elements = linkify( + _message.body, + humanize: true, + linkTypes: null, + ); + return RichText( + textScaleFactor: MediaQuery.of(context).textScaleFactor, + text: buildTextSpan( + elements, + style: AppTheme.messageTextStyle, + onOpen: (link) async { + if (await canLaunch(link.url)) { + await launch(link.url); + } else { + throw "Could not launch $link"; + } + }, + linkStyle: AppTheme.linkTextStyle, + ), + ); + } +} diff --git a/lib/presentation/channel/message/message_item.dart b/lib/presentation/channel/message/message_item.dart new file mode 100644 index 0000000..d254464 --- /dev/null +++ b/lib/presentation/channel/message/message_item.dart @@ -0,0 +1,197 @@ +import "dart:io"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/channel/message/media/MediaMessage.dart"; +import "package:circles_app/presentation/channel/message/message_body.dart"; +import "package:circles_app/presentation/channel/message/message_timestamp.dart"; +import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_section.dart"; +import "package:circles_app/presentation/user/user_avatar.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:transparent_image/transparent_image.dart"; + +class MessageItem extends StatelessWidget { + const MessageItem({ + @required Message message, + @required bool userIsMember, + @required User currentUser, + @required User author, + Key key, + }) : _message = message, + _currentUser = currentUser, + _userIsMember = userIsMember, + _author = author, + assert(message != null), + assert(currentUser != null), + super(key: key); + + final Message _message; + final User _currentUser; + final bool _userIsMember; + + // _author can be null if the author was deleted + // see: https://github.com/janoodleFTW/flutter-app/issues/222 + final User _author; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + child: Column( + children: [ + SizedBox(height: AppTheme.appMargin), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatar(context), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _authorName(context), + MessageTimestamp( + message: _message, + currentUser: _currentUser, + ), + ], + ), + SizedBox(height: 8.0), + _buildBody(), + ReactionSection( + message: _message, + currentUser: _currentUser, + userIsMember: _userIsMember, + ), + ], + ), + ), + SizedBox(width: AppTheme.appMargin) + ], + ), + SizedBox(height: AppTheme.appMargin), + ], + ), + onTap: () { + // On iOS, taping on the chat section dismisses keyboard + if (Platform.isIOS) { + FocusScope.of(context).requestFocus(FocusNode()); + } + }, + onLongPress: () { + if (_currentUser.uid != _author?.uid && + !_message.reactions.containsKey(_currentUser.uid) && + _userIsMember) { + showEmojiPicker(context, _message); + } + }, + ), + ); + } + + Widget _buildBody() { + switch (_message.messageType) { + case MessageType.MEDIA: + return MessageMedia(_message); + break; + case MessageType.SYSTEM: + case MessageType.USER: + case MessageType.RSVP: + default: + return MessageBody( + message: _message, + ); + break; + } + } + + Widget _buildAvatar(context) { + return InkWell( + onTap: () { + if (_author != null) { + Navigator.of(context).pushNamed(Routes.user, arguments: _author.uid); + } + }, + child: Padding( + padding: const EdgeInsets.only( + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: UserAvatar( + user: _author, + ), + ), + ); + } + + Widget _authorName(context) { + return Flexible( + child: InkWell( + onTap: () { + if (_author != null) { + Navigator.of(context) + .pushNamed(Routes.user, arguments: _author.uid); + } + }, + child: Text( + _author?.name ?? CirclesLocalizations.of(context).deletedUser, + overflow: TextOverflow.ellipsis, + style: AppTheme.messageAuthorNameTextStyle, + ), + ), + ); + } +} + +class PictureInMessage extends StatelessWidget { + final String url; + + const PictureInMessage(this.url); + + @override + Widget build(BuildContext context) { + if (url == null) { + return SizedBox.shrink(); + } else { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Material( + child: InkWell( + onTap: () { + Navigator.of(context).pushNamed( + Routes.image, + arguments: url, + ); + }, + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + )), + Center( + child: Hero( + tag: url, + child: FadeInImage.memoryNetwork( + image: url, + fit: BoxFit.cover, + placeholder: kTransparentImage, + ), + ), + ), + ], + ), + ), + ), + ); + } + } +} diff --git a/lib/presentation/channel/message/message_timestamp.dart b/lib/presentation/channel/message/message_timestamp.dart new file mode 100644 index 0000000..3f6cf0e --- /dev/null +++ b/lib/presentation/channel/message/message_timestamp.dart @@ -0,0 +1,46 @@ +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:intl/intl.dart"; + +class MessageTimestamp extends StatelessWidget { + const MessageTimestamp({ + Key key, + @required Message message, + @required User currentUser, + }) : _message = message, + _currentUser = currentUser, + super(key: key); + + final Message _message; + final User _currentUser; + + @override + Widget build(BuildContext context) { + final timestamp = Text( + DateFormat.Hm().format(_message.timestamp), + style: AppTheme.messageTimestampTextStyle, + ); + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: AnimatedSwitcher( + duration: Duration(milliseconds: 500), + child: AnimatedSwitcher( + child: _message.authorId == _currentUser.uid && _message.pending + ? _buildLoading() + : timestamp, + duration: Duration(milliseconds: 200), + ), + ), + ); + } + + Widget _buildLoading() { + return Icon( + Icons.cached, + size: 16.0, + color: Colors.grey, + ); + } +} diff --git a/lib/presentation/channel/message/system_message_item.dart b/lib/presentation/channel/message/system_message_item.dart new file mode 100644 index 0000000..3eb911a --- /dev/null +++ b/lib/presentation/channel/message/system_message_item.dart @@ -0,0 +1,36 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; + +class SystemMessageItem extends StatelessWidget { + final Message _message; + + const SystemMessageItem( + this._message, { + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 48 * AppTheme.pixelMultiplier, + width: MediaQuery.of(context).size.width, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + // Currently only dealing with SYSTEM or RSVP messages + child: Text(_message.messageType == MessageType.SYSTEM ? + CirclesLocalizations.of(context).channelSystemMessage(_message.body).toUpperCase() : + CirclesLocalizations.of(context).rsvpSystemMessage(_message.body).toUpperCase(), + style: AppTheme.systemMessageTextStyle, + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/channel/messages_list/messages_list.dart b/lib/presentation/channel/messages_list/messages_list.dart new file mode 100644 index 0000000..26e12ed --- /dev/null +++ b/lib/presentation/channel/messages_list/messages_list.dart @@ -0,0 +1,67 @@ +import "dart:io"; + +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/presentation/channel/message/message_item.dart"; +import "package:circles_app/presentation/channel/message/system_message_item.dart"; +import "package:circles_app/presentation/channel/messages_list/messages_list_viewmodel.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class MessagesList extends StatelessWidget { + const MessagesList({ + Key key, + @required this.scrollController, + }) : super(key: key); + + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapUp: (details) { + // On iOS, taping on the chat section dismisses keyboard + if (Platform.isIOS) { + FocusScope.of(context).requestFocus(FocusNode()); + } + }, + child: StoreConnector( + builder: (context, vm) { + return ListView.builder( + controller: scrollController, + reverse: true, + itemCount: vm.messages.length, + itemBuilder: (context, index) { + final message = vm.messages[index]; + return _selectMessageBuilder(message, vm); + }); + }, + converter: MessagesListViewModel.fromStore, + distinct: true, + ), + ); + } + + Widget _selectMessageBuilder( + Message message, + MessagesListViewModel vm, + ) { + switch (message.messageType) { + case MessageType.SYSTEM: + case MessageType.RSVP: + return SystemMessageItem(message); + break; + case MessageType.USER: + case MessageType.MEDIA: + return MessageItem( + message: message, + currentUser: vm.currentUser, + userIsMember: vm.userIsMember, + author: vm.authors[message.authorId], + ); + break; + default: + return SizedBox.shrink(); + } + } +} diff --git a/lib/presentation/channel/messages_list/messages_list_viewmodel.dart b/lib/presentation/channel/messages_list/messages_list_viewmodel.dart new file mode 100644 index 0000000..d39f5cc --- /dev/null +++ b/lib/presentation/channel/messages_list/messages_list_viewmodel.dart @@ -0,0 +1,38 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'messages_list_viewmodel.g.dart'; + +abstract class MessagesListViewModel + implements Built { + @nullable + User get currentUser; + + BuiltList get messages; + + bool get userIsMember; + + BuiltMap get authors; + + MessagesListViewModel._(); + + factory MessagesListViewModel( + [void Function(MessagesListViewModelBuilder) updates]) = + _$MessagesListViewModel; + + static MessagesListViewModel fromStore(Store store) { + return MessagesListViewModel((m) => m + ..messages = store.state.messagesOnScreen.toBuilder() + ..currentUser = store.state.user?.toBuilder() + ..authors = MapBuilder( + store.state.groupUsers.asMap().map((k, v) => MapEntry(v.uid, v))) + ..userIsMember = getSelectedChannel(store.state)?.users + ?.any((u) => u.id == store.state.user.uid)); + } +} diff --git a/lib/presentation/channel/messages_list/messages_list_viewmodel.g.dart b/lib/presentation/channel/messages_list/messages_list_viewmodel.g.dart new file mode 100644 index 0000000..ae3799c --- /dev/null +++ b/lib/presentation/channel/messages_list/messages_list_viewmodel.g.dart @@ -0,0 +1,155 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'messages_list_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$MessagesListViewModel extends MessagesListViewModel { + @override + final User currentUser; + @override + final BuiltList messages; + @override + final bool userIsMember; + @override + final BuiltMap authors; + + factory _$MessagesListViewModel( + [void Function(MessagesListViewModelBuilder) updates]) => + (new MessagesListViewModelBuilder()..update(updates)).build(); + + _$MessagesListViewModel._( + {this.currentUser, this.messages, this.userIsMember, this.authors}) + : super._() { + if (messages == null) { + throw new BuiltValueNullFieldError('MessagesListViewModel', 'messages'); + } + if (userIsMember == null) { + throw new BuiltValueNullFieldError( + 'MessagesListViewModel', 'userIsMember'); + } + if (authors == null) { + throw new BuiltValueNullFieldError('MessagesListViewModel', 'authors'); + } + } + + @override + MessagesListViewModel rebuild( + void Function(MessagesListViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + MessagesListViewModelBuilder toBuilder() => + new MessagesListViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is MessagesListViewModel && + currentUser == other.currentUser && + messages == other.messages && + userIsMember == other.userIsMember && + authors == other.authors; + } + + @override + int get hashCode { + return $jf($jc( + $jc($jc($jc(0, currentUser.hashCode), messages.hashCode), + userIsMember.hashCode), + authors.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('MessagesListViewModel') + ..add('currentUser', currentUser) + ..add('messages', messages) + ..add('userIsMember', userIsMember) + ..add('authors', authors)) + .toString(); + } +} + +class MessagesListViewModelBuilder + implements Builder { + _$MessagesListViewModel _$v; + + UserBuilder _currentUser; + UserBuilder get currentUser => _$this._currentUser ??= new UserBuilder(); + set currentUser(UserBuilder currentUser) => _$this._currentUser = currentUser; + + ListBuilder _messages; + ListBuilder get messages => + _$this._messages ??= new ListBuilder(); + set messages(ListBuilder messages) => _$this._messages = messages; + + bool _userIsMember; + bool get userIsMember => _$this._userIsMember; + set userIsMember(bool userIsMember) => _$this._userIsMember = userIsMember; + + MapBuilder _authors; + MapBuilder get authors => + _$this._authors ??= new MapBuilder(); + set authors(MapBuilder authors) => _$this._authors = authors; + + MessagesListViewModelBuilder(); + + MessagesListViewModelBuilder get _$this { + if (_$v != null) { + _currentUser = _$v.currentUser?.toBuilder(); + _messages = _$v.messages?.toBuilder(); + _userIsMember = _$v.userIsMember; + _authors = _$v.authors?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(MessagesListViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$MessagesListViewModel; + } + + @override + void update(void Function(MessagesListViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$MessagesListViewModel build() { + _$MessagesListViewModel _$result; + try { + _$result = _$v ?? + new _$MessagesListViewModel._( + currentUser: _currentUser?.build(), + messages: messages.build(), + userIsMember: userIsMember, + authors: authors.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'currentUser'; + _currentUser?.build(); + _$failedField = 'messages'; + messages.build(); + + _$failedField = 'authors'; + authors.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'MessagesListViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/messages_scroll_controller.dart b/lib/presentation/channel/messages_scroll_controller.dart new file mode 100644 index 0000000..77bde72 --- /dev/null +++ b/lib/presentation/channel/messages_scroll_controller.dart @@ -0,0 +1,19 @@ +import "package:flutter/material.dart"; + +class MessagesScrollController extends InheritedWidget { + final ScrollController scrollController; + + const MessagesScrollController({ + Key key, + @required Widget child, + @required this.scrollController, + }) : super(key: key, child: child); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) { + return true; + } + + static MessagesScrollController of(BuildContext context) => + context.inheritFromWidgetOfExactType(MessagesScrollController); +} diff --git a/lib/presentation/channel/messages_section.dart b/lib/presentation/channel/messages_section.dart new file mode 100644 index 0000000..f095e7d --- /dev/null +++ b/lib/presentation/channel/messages_section.dart @@ -0,0 +1,98 @@ +import "package:circles_app/presentation/channel/messages_list/messages_list.dart"; +import "package:circles_app/presentation/channel/messages_scroll_controller.dart"; +import "package:flutter/material.dart"; + +class MessagesSection extends StatefulWidget { + const MessagesSection({Key key}): super(key: key); + + @override + _MessagesSectionState createState() => _MessagesSectionState(); +} + +class _MessagesSectionState extends State + with SingleTickerProviderStateMixin { + ScrollController scrollController; + + static final Animatable _fabTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ).chain(CurveTween( + curve: Curves.fastOutSlowIn, + )); + + AnimationController _controller; + Animation _fabPosition; + + @override + void initState() { + super.initState(); + _initTransitionController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + scrollController = MessagesScrollController.of(context).scrollController; + scrollController.addListener(_onScroll); + } + + @override + Widget build(BuildContext context) { + return Expanded( + child: ClipRect( + child: Stack( + children: [ + Container( + color: Colors.white, + child: MessagesList(scrollController: scrollController), + ), + SlideTransition( + position: _fabPosition, + child: _createFloatingActionButton(), + ) + ], + ), + ), + ); + } + + void _initTransitionController() { + _controller = AnimationController( + vsync: this, // the SingleTickerProviderStateMixin + duration: Duration(milliseconds: 500), + ); + _fabPosition = _controller.drive(_fabTween); + } + + void _onScroll() { + if (scrollController.offset > 100) { + _controller.value = 1; + } else { + _controller.value = scrollController.offset / 100; + } + } + + Padding _createFloatingActionButton() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + alignment: Alignment.bottomRight, + child: FloatingActionButton( + child: Icon(Icons.arrow_downward), + onPressed: () { + _scrollToBottom(); + }, + ), + ), + ); + } + + void _scrollToBottom() { + scrollController.animateTo( + 0, + duration: Duration(milliseconds: 100), + curve: Curves.bounceOut, + ); + } +} + diff --git a/lib/presentation/channel/reaction/emoji_picker.dart b/lib/presentation/channel/reaction/emoji_picker.dart new file mode 100644 index 0000000..ab06454 --- /dev/null +++ b/lib/presentation/channel/reaction/emoji_picker.dart @@ -0,0 +1,74 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +final emojiPickerOptions = const [ + "❤️", + "😂", + "🔥", + "😍", + "👍", + "🤔", + "👽", + "😊", + "🥰", +]; + +void showEmojiPicker(BuildContext context, Message message) { + showCupertinoModalPopup( + context: context, + builder: (context) { + return EmojiPicker(message); + }); +} + +class EmojiPicker extends StatelessWidget { + final Message _message; + + const EmojiPicker(this._message); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(16)), + color: Colors.white, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Wrap( + children: emojiPickerOptions.map((emoji) => + Material( + color: Colors.white, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + emoji, + style: TextStyle(fontSize: 20 * AppTheme.pixelMultiplier), + ), + ), + onTap: () { + StoreProvider.of(context).dispatch(EmojiReaction( + _message.id, + emoji, + )); + Navigator.of(context).pop(); + }, + ), + ) + ).toList() + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/channel/reaction/reaction.dart b/lib/presentation/channel/reaction/reaction.dart new file mode 100644 index 0000000..45bf973 --- /dev/null +++ b/lib/presentation/channel/reaction/reaction.dart @@ -0,0 +1,119 @@ +import "dart:io"; + +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class Reaction extends StatelessWidget { + final String _emoji; + final int _count; + final bool _isUserEmoji; + final String _messageId; + + const Reaction({ + @required emoji, + @required count, + @required isUserEmoji, + @required messageId, + Key key, + }) : _emoji = emoji, + _count = count, + _isUserEmoji = isUserEmoji, + _messageId = messageId, + super(key: key); + + @override + Widget build(BuildContext context) { + final emojiBottomPadding = Platform.isIOS ? 8.0 : 0.0; + final emojiWidget = Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(bottom: emojiBottomPadding), + child: Text( + _emoji, + style: TextStyle(fontSize: 16 * AppTheme.pixelMultiplier), + textScaleFactor: 1, + ), + ), + SizedBox( + width: 4, + ), + Text( + _count.toString(), + style: AppTheme.emojiReactionTextStyle, + textScaleFactor: 1, + ), + ], + ); + + if (_isUserEmoji) { + return _removeTapAction( + context, + emojiBorder( + emojiWidget, + borderColor: AppTheme.colorDarkBlueFont, + bodyColor: AppTheme.colorLightGreen, + )); + } else { + return _addTapAction(context, emojiBorder(emojiWidget)); + } + } + + Widget _removeTapAction( + context, + Widget child, + ) { + return Material( + child: InkWell( + child: child, + onTap: () { + StoreProvider.of(context) + .dispatch(RemoveEmojiReaction(_messageId)); + }, + ), + color: Colors.transparent, + ); + } + + Widget _addTapAction( + context, + Widget child, + ) { + return Material( + child: InkWell( + child: child, + onTap: () { + // Tapping on any other emoji replaces the original + StoreProvider.of(context) + .dispatch(EmojiReaction(_messageId, _emoji)); + }, + ), + color: Colors.transparent, + ); + } +} + +Widget emojiBorder( + Widget emojiWidget, { + Color borderColor = AppTheme.colorGrey225, + Color bodyColor = Colors.white, +}) { + return Container( + width: 44, + height: 32, + decoration: BoxDecoration( + border: Border.all( + color: borderColor, + width: 1.0, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(16.0), + color: bodyColor, + ), + child: Center(child: emojiWidget), + ); +} diff --git a/lib/presentation/channel/reaction/reaction_button.dart b/lib/presentation/channel/reaction/reaction_button.dart new file mode 100644 index 0000000..cdd7f44 --- /dev/null +++ b/lib/presentation/channel/reaction/reaction_button.dart @@ -0,0 +1,30 @@ +import "package:circles_app/model/message.dart"; +import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; +import "package:circles_app/presentation/channel/reaction/reaction.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +class ReactionButton extends StatelessWidget { + const ReactionButton( + this._message, + ); + + final Message _message; + + @override + Widget build(BuildContext context) { + return InkWell( + child: emojiBorder( + Image.asset( + "assets/graphics/icon_smile.png", + height: 16 * AppTheme.pixelMultiplier, + width: 16 * AppTheme.pixelMultiplier, + ), + ), + onTap: () { + showEmojiPicker(context, _message); + }, + ); + } +} diff --git a/lib/presentation/channel/reaction/reaction_detail_data.dart b/lib/presentation/channel/reaction/reaction_detail_data.dart new file mode 100644 index 0000000..1065d4c --- /dev/null +++ b/lib/presentation/channel/reaction/reaction_detail_data.dart @@ -0,0 +1,17 @@ +import "package:built_value/built_value.dart"; + +// ignore: prefer_double_quotes +part 'reaction_detail_data.g.dart'; + +abstract class ReactionDetailData + implements Built { + String get emoji; + + String get names; + + ReactionDetailData._(); + + factory ReactionDetailData( + [void Function(ReactionDetailDataBuilder) updates]) = + _$ReactionDetailData; +} diff --git a/lib/presentation/channel/reaction/reaction_detail_data.g.dart b/lib/presentation/channel/reaction/reaction_detail_data.g.dart new file mode 100644 index 0000000..74ae737 --- /dev/null +++ b/lib/presentation/channel/reaction/reaction_detail_data.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reaction_detail_data.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ReactionDetailData extends ReactionDetailData { + @override + final String emoji; + @override + final String names; + + factory _$ReactionDetailData( + [void Function(ReactionDetailDataBuilder) updates]) => + (new ReactionDetailDataBuilder()..update(updates)).build(); + + _$ReactionDetailData._({this.emoji, this.names}) : super._() { + if (emoji == null) { + throw new BuiltValueNullFieldError('ReactionDetailData', 'emoji'); + } + if (names == null) { + throw new BuiltValueNullFieldError('ReactionDetailData', 'names'); + } + } + + @override + ReactionDetailData rebuild( + void Function(ReactionDetailDataBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ReactionDetailDataBuilder toBuilder() => + new ReactionDetailDataBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ReactionDetailData && + emoji == other.emoji && + names == other.names; + } + + @override + int get hashCode { + return $jf($jc($jc(0, emoji.hashCode), names.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ReactionDetailData') + ..add('emoji', emoji) + ..add('names', names)) + .toString(); + } +} + +class ReactionDetailDataBuilder + implements Builder { + _$ReactionDetailData _$v; + + String _emoji; + String get emoji => _$this._emoji; + set emoji(String emoji) => _$this._emoji = emoji; + + String _names; + String get names => _$this._names; + set names(String names) => _$this._names = names; + + ReactionDetailDataBuilder(); + + ReactionDetailDataBuilder get _$this { + if (_$v != null) { + _emoji = _$v.emoji; + _names = _$v.names; + _$v = null; + } + return this; + } + + @override + void replace(ReactionDetailData other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ReactionDetailData; + } + + @override + void update(void Function(ReactionDetailDataBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ReactionDetailData build() { + final _$result = + _$v ?? new _$ReactionDetailData._(emoji: emoji, names: names); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/channel/reaction/reaction_details.dart b/lib/presentation/channel/reaction/reaction_details.dart new file mode 100644 index 0000000..73b9522 --- /dev/null +++ b/lib/presentation/channel/reaction/reaction_details.dart @@ -0,0 +1,115 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_detail_data.dart"; +import "package:flutter/material.dart"; + +class ReactionDetails extends StatelessWidget { + @override + Widget build(BuildContext context) { + final BuiltMap map = + ModalRoute.of(context).settings.arguments; + + final details = toListOfReactionDetailData(map); + + return Scaffold( + appBar: AppBar( + title: Text( + "Reactions", + style: TextStyle(color: Colors.black), + ), + ), + body: ListView.builder( + itemCount: details.length, + itemBuilder: (context, position) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: _ReactionDetail(details[position])); + }, + ), + ); + } +} + +// Function is public so it can be tested +List toListOfReactionDetailData( + BuiltMap map) { + final details = []; + final reactions = map.values; + final List emojiToDisplay = _sortEmoji(map); + for (final emoji in emojiToDisplay) { + // Pick all the reactions for a given emoji + final reactionsWithEmoji = + reactions.where((it) => it.emoji == emoji).toList(); + // Sort by timestamp (newest to oldest) + reactionsWithEmoji.sort((a, b) => + b.timestamp.millisecondsSinceEpoch - + a.timestamp.millisecondsSinceEpoch); + // Concat all the names together + final names = reactionsWithEmoji.fold( + "", (p, e) => p + (p.isNotEmpty ? ", " : "") + e.userName) as String; + // Display the reaction + details.add(ReactionDetailData((r) => r + ..emoji = emoji + ..names = names)); + } + return details; +} + +/// Sort as required: +/// "most applied emoji to that message first, +/// if equal then use order of emojis in emoji picker" +/// +/// See: https://github.com/janoodleFTW/flutter-app/issues/46 +List _sortEmoji(BuiltMap map) { + // Count the uses per emoji + final emojiCount = Map(); + for (final reaction in map.values) { + emojiCount[reaction.emoji] = (emojiCount[reaction.emoji] ?? 0) + 1; + } + + final emojiToDisplay = emojiCount.keys.toList(); + emojiToDisplay.sort((a, b) { + // Sort list by count (more counts first) + final count = emojiCount[b] - emojiCount[a]; + if (count != 0) { + return count; + } + // Or by position in emoji picker if not + final pA = emojiPickerOptions.indexOf(a); + final pB = emojiPickerOptions.indexOf(b); + return pA - pB; + }); + return emojiToDisplay; +} + +class _ReactionDetail extends StatelessWidget { + final ReactionDetailData _data; + + const _ReactionDetail(this._data); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Text( + _data.emoji, + style: TextStyle(fontSize: 30), + ), + // Allow text to wrap + Flexible( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + _data.names, + style: TextStyle(fontSize: 20), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/channel/reaction/reaction_section.dart b/lib/presentation/channel/reaction/reaction_section.dart new file mode 100644 index 0000000..ff8c2bc --- /dev/null +++ b/lib/presentation/channel/reaction/reaction_section.dart @@ -0,0 +1,66 @@ +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/channel/reaction/reaction.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_button.dart"; +import "package:circles_app/routes.dart"; +import "package:flutter/material.dart"; + +class ReactionSection extends StatelessWidget { + const ReactionSection({ + Key key, + @required Message message, + @required User currentUser, + @required bool userIsMember, + }) : _message = message, + _currentUser = currentUser, + _userIsMember = userIsMember, + super(key: key); + + final Message _message; + final User _currentUser; + final bool _userIsMember; + + @override + Widget build(BuildContext context) { + final userEmoji = _message.reactions[_currentUser.uid]; + + final list = []; + + _message.reactionsCount().forEach((emoji, count) { + final isUserEmoji = userEmoji?.emoji == emoji; + list.add(Reaction( + emoji: emoji, + count: count, + isUserEmoji: isUserEmoji, + messageId: _message.id, + )); + }); + + if (list.isNotEmpty && + _currentUser.uid != _message.authorId && + !_message.reactions.containsKey(_currentUser.uid) && + _userIsMember) { + list.add(ReactionButton(_message)); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: InkWell( + onLongPress: () { + Navigator.of(context).pushNamed( + Routes.reaction, + arguments: _message.reactions, + ); + }, + // Wrap takes care of showing the each reaction one after the other + // and when it runs out of space, will go to the next line. + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + direction: Axis.horizontal, + children: list, + ), + ), + ); + } +} diff --git a/lib/presentation/common/color_label_text_form_field.dart b/lib/presentation/common/color_label_text_form_field.dart new file mode 100644 index 0000000..12e53e3 --- /dev/null +++ b/lib/presentation/common/color_label_text_form_field.dart @@ -0,0 +1,61 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; + +/// +/// This is a TextFormField which will change the Label color +/// when it is not empty. +/// +/// i.e. When the content is not empty, the whole TextFormField +/// becomes the normal color: the border and the label. +/// +/// If you are also using validation, use ErrorLabelTextFormField +/// +class ColorLabelTextFormField extends StatefulWidget { + const ColorLabelTextFormField({ + Key key, + String labelText, + String helperText, + @required TextEditingController controller, + }) : _controller = controller, + _labelText = labelText, + _helperText = helperText, + super(key: key); + + final TextEditingController _controller; + final String _labelText; + final String _helperText; + + @override + _ColorLabelTextFormFieldState createState() => + _ColorLabelTextFormFieldState(); +} + +class _ColorLabelTextFormFieldState extends State { + bool _isEmpty = true; + + @override + void initState() { + super.initState(); + widget._controller.addListener(() { + setState(() { + _isEmpty = widget._controller.text.isEmpty; + }); + }); + } + + @override + Widget build(BuildContext context) { + final theme = _isEmpty + ? AppTheme.inputDecorationEmptyTheme + : AppTheme.inputDecorationFilledTheme; + + return TextFormField( + style: AppTheme.inputMediumTextStyle, + decoration: InputDecoration( + labelText: widget._labelText, + helperText: widget._helperText, + ).applyDefaults(theme), + controller: widget._controller, + ); + } +} diff --git a/lib/presentation/common/common_app_bar.dart b/lib/presentation/common/common_app_bar.dart new file mode 100644 index 0000000..577a0ca --- /dev/null +++ b/lib/presentation/common/common_app_bar.dart @@ -0,0 +1,92 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; + +class CommonAppBar extends StatelessWidget implements PreferredSizeWidget { + final Widget _action; + final Widget _leftAction; + final String _title; + final String _subtitle; + + const CommonAppBar({ + Widget action, + Widget leftAction, + @required String title, + String subtitle, + Key key, + }) : _action = action, + _leftAction = leftAction, + _title = title, + _subtitle = subtitle, + super(key: key); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: true, + child: Container( + height: AppTheme.appBarSize, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + offset: Offset(0.0, 1.0), + ) + ], + ), + child: Row( + children: [ + Visibility( + visible: _leftAction == null, + child: Container( + width: 100.0, // Minimum size of a flat button + child: FlatButton( + child: Text( + CirclesLocalizations.of(context).back, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.of(context).pop(); + }), + ), + replacement: _leftAction ?? SizedBox.shrink(), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _title, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: _subtitle == null + ? AppTheme.appBarTitleTextStyle + : AppTheme.appBarTitle2TextStyle, + ), + Visibility( + visible: _subtitle != null, + child: Text( + _subtitle ?? "", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: AppTheme.appBarSubtitleTextStyle, + ), + ), + ], + ), + ), + Container( + width: 100.0, // Minimum size of a flat button + child: _action, + ), + ], + ), + ), + ); + } + + @override + Size get preferredSize => Size.fromHeight(AppTheme.appBarSize); +} diff --git a/lib/presentation/common/date_form_field.dart b/lib/presentation/common/date_form_field.dart new file mode 100644 index 0000000..4eb4c18 --- /dev/null +++ b/lib/presentation/common/date_form_field.dart @@ -0,0 +1,148 @@ +import "dart:io"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:intl/intl.dart"; + +class DateFormField extends StatefulWidget { + const DateFormField({ + Key key, + String labelText, + String helperText, + @required ValueNotifier controller, + @required FormFieldValidator validator, + }) : _controller = controller, + _labelText = labelText, + _helperText = helperText, + _validator = validator, + super(key: key); + + final ValueNotifier _controller; + final String _labelText; + final String _helperText; + final FormFieldValidator _validator; + + @override + _DateFormFieldState createState() => _DateFormFieldState(); +} + +class _DateFormFieldState extends State { + @override + Widget build(BuildContext context) { + return FormField( + validator: widget._validator, + initialValue: widget._controller.value, + builder: (state) { + final theme = state.errorText != null + ? AppTheme.inputDecorationErrorTheme + : (state.value == null + ? AppTheme.inputDecorationEmptyTheme + : AppTheme.inputDecorationFilledTheme); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (Platform.isIOS) { + _showDatePickerIOS(context, state); + } else { + _showDatePickerAndroid(context, state); + } + }, + child: InputDecorator( + isEmpty: state.value == null, + decoration: InputDecoration( + labelText: widget._labelText, + helperText: widget._helperText, + errorText: state.errorText, + ).applyDefaults(theme), + child: Text( + state.value != null + ? DateFormat.yMMMd().format(state.value) + : "", + style: AppTheme.inputMediumTextStyle, + ), + ), + ), + ); + }, + ); + } + + void _showDatePickerIOS( + BuildContext context, + FormFieldState state, + ) { + showCupertinoModalPopup( + context: context, + builder: (context) { + return Container( + width: MediaQuery.of(context).size.width, + height: 200, + color: Colors.white, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlatButton( + child: Text( + CirclesLocalizations.of(context).cancel, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.maybePop(context); + state.didChange(null); + widget._controller.value = null; + }, + ), + FlatButton( + child: Text( + CirclesLocalizations.of(context).save, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.maybePop(context); + if (widget._controller.value == null) { + state.didChange(DateTime.now()); + widget._controller.value = DateTime.now(); + } + }, + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + initialDateTime: widget._controller.value ?? DateTime.now(), + minimumYear: DateTime.now().year, + maximumYear: 2030, + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (dateTime) { + state.didChange(dateTime); + widget._controller.value = dateTime; + }, + ), + ), + ], + ), + ); + }, + ); + } + + void _showDatePickerAndroid( + BuildContext context, FormFieldState state) { + final now = DateTime.now(); + showDatePicker( + context: context, + firstDate: DateTime(now.year, now.month, now.day), + lastDate: DateTime(2030), + initialDate: widget._controller.value ?? now, + ).then((dateTime) { + state.didChange(dateTime); + widget._controller.value = dateTime; + }); + } +} diff --git a/lib/presentation/common/error_label_text_form_field.dart b/lib/presentation/common/error_label_text_form_field.dart new file mode 100644 index 0000000..5f124ee --- /dev/null +++ b/lib/presentation/common/error_label_text_form_field.dart @@ -0,0 +1,92 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +/// +/// This is a TextFormField which will change the Label color +/// when there's an error and as well does the same +/// ColorLabelTextFormField does. +/// +/// i.e. When the validator returns not null, the whole TextFormField +/// becomes red: The error text, the border and the label. +/// +/// In the default TextFormField the label stays in the original color. +/// +/// It only makes sense to use this when you have a validator, +/// otherwise just use the normal TextFormField or the +/// ColorLabelTextFormField. +/// +class ErrorLabelTextFormField extends StatefulWidget { + const ErrorLabelTextFormField({ + Key key, + String labelText, + String helperText, + int maxCharacters = 0, + bool enabled = true, + @required TextEditingController controller, + @required FormFieldValidator validator, + }) : _controller = controller, + _validator = validator, + _labelText = labelText, + _helperText = helperText, + _maxCharacters = maxCharacters, + _enabled = enabled, + super(key: key); + + final TextEditingController _controller; + final FormFieldValidator _validator; + final String _labelText; + final String _helperText; + final int _maxCharacters; + final bool _enabled; + + @override + _ErrorLabelTextFormFieldState createState() => + _ErrorLabelTextFormFieldState(); +} + +class _ErrorLabelTextFormFieldState extends State { + bool _isEmpty = true; + bool _hasErrors = false; + + @override + void initState() { + super.initState(); + widget._controller.addListener(() { + setState(() { + _isEmpty = widget._controller.text.isEmpty; + }); + }); + } + + @override + Widget build(BuildContext context) { + final theme = _hasErrors + ? AppTheme.inputDecorationErrorTheme + : (_isEmpty + ? AppTheme.inputDecorationEmptyTheme + : AppTheme.inputDecorationFilledTheme); + + return TextFormField( + inputFormatters: [ + widget._maxCharacters > 0 + ? LengthLimitingTextInputFormatter(widget._maxCharacters) + : null + ], + style: AppTheme.inputMediumTextStyle, + decoration: InputDecoration( + labelText: widget._labelText, + helperText: widget._helperText, + ).applyDefaults(theme), + controller: widget._controller, + enabled: widget._enabled, + validator: (value) { + final out = widget._validator(value); + setState(() { + _hasErrors = (out != null); + }); + return out; + }, + ); + } +} diff --git a/lib/presentation/common/modal_item.dart b/lib/presentation/common/modal_item.dart new file mode 100644 index 0000000..c79a14f --- /dev/null +++ b/lib/presentation/common/modal_item.dart @@ -0,0 +1,40 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; + +class ModalItem extends StatelessWidget { + const ModalItem({ + Key key, + this.iconAsset, + this.iconData, + @required this.label, + }) : super(key: key); + + final String iconAsset; + final String label; + final IconData iconData; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: iconAsset != null + ? Image.asset( + iconAsset, + scale: 3, + ) + : Icon(iconData), + ), + Text( + label, + style: AppTheme.optionTextStyle, + textScaleFactor: 1, + ), + ], + ); + } +} diff --git a/lib/presentation/common/platform_alerts.dart b/lib/presentation/common/platform_alerts.dart new file mode 100644 index 0000000..a9d2ec3 --- /dev/null +++ b/lib/presentation/common/platform_alerts.dart @@ -0,0 +1,49 @@ +import "package:circles_app/circles_localization.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; + +enum AccessResourceType { CAMERA, STORAGE } + +showNoAccessAlert({ + AccessResourceType type, + BuildContext context, +}) { + final dialog = PlatformAlertDialog( + title: Text(CirclesLocalizations.of(context).platformAlertAccessTitle), + content: + Text(CirclesLocalizations.of(context).platformAlertAccessBody(type)), + actions: [ + PlatformDialogAction( + child: PlatformText(CirclesLocalizations.of(context).ok), + onPressed: () { + Navigator.pop(context); + }), + ], + ); + + return showPlatformDialog( + context: context, + builder: (_) => dialog, + ); +} + +/// Present PlatformDialog for fetures yet to implement. +showSoonAlert({BuildContext context}) { + final actions = [ + PlatformDialogAction( + child: PlatformText(CirclesLocalizations.of(context).cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + ]; + + return showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(CirclesLocalizations.of(context).genericSoonAlertTitle), + content: Text(CirclesLocalizations.of(context).genericSoonAlertMessage), + actions: actions, + ), + ); +} diff --git a/lib/presentation/common/round_button.dart b/lib/presentation/common/round_button.dart new file mode 100644 index 0000000..2f2d049 --- /dev/null +++ b/lib/presentation/common/round_button.dart @@ -0,0 +1,44 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; + +class RoundButton extends StatelessWidget { + const RoundButton({ + @required this.text, + @required this.onTap, + }); + + final String text; + final Function onTap; + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(24); + return Material( + color: Colors.transparent, + borderRadius: borderRadius, + child: InkWell( + onTap: onTap, + borderRadius: borderRadius, + child: Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all( + color: AppTheme.colorDarkBlue + ) + ), + height: 40, + width: 200, + child: Center( + child: Text( + text, + style: AppTheme.buttonTextStyle.apply( + color: AppTheme.colorDarkBlue + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/common/time_form_field.dart b/lib/presentation/common/time_form_field.dart new file mode 100644 index 0000000..c68b0e2 --- /dev/null +++ b/lib/presentation/common/time_form_field.dart @@ -0,0 +1,151 @@ +import "dart:io"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +class TimeFormField extends StatefulWidget { + const TimeFormField({ + Key key, + String labelText, + String helperText, + @required ValueNotifier controller, + @required FormFieldValidator validator, + }) : _controller = controller, + _labelText = labelText, + _helperText = helperText, + _validator = validator, + super(key: key); + + final ValueNotifier _controller; + final String _labelText; + final String _helperText; + final FormFieldValidator _validator; + + @override + _TimeFormFieldState createState() => _TimeFormFieldState(); +} + +class _TimeFormFieldState extends State { + @override + Widget build(BuildContext context) { + return FormField( + validator: widget._validator, + initialValue: widget._controller.value, + builder: (state) { + final theme = state.errorText != null + ? AppTheme.inputDecorationErrorTheme + : (state.value == null + ? AppTheme.inputDecorationEmptyTheme + : AppTheme.inputDecorationFilledTheme); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (Platform.isIOS) { + _showTimePickerIOS(context, state); + } else { + _showTimePickerAndroid(context, state); + } + }, + child: InputDecorator( + isEmpty: state.value == null, + decoration: InputDecoration( + labelText: widget._labelText, + helperText: widget._helperText, + errorText: state.errorText, + ).applyDefaults(theme), + child: Text( + state.value?.format(context) ?? "", + style: AppTheme.inputMediumTextStyle, + ), + ), + ), + ); + }, + ); + } + + void _showTimePickerAndroid( + BuildContext context, FormFieldState state) { + showTimePicker( + context: context, + initialTime: widget._controller.value ?? TimeOfDay.now(), + ).then((timeOfDay) { + state.didChange(timeOfDay); + widget._controller.value = timeOfDay; + }); + } + + void _showTimePickerIOS( + BuildContext context, + FormFieldState state, + ) { + showCupertinoModalPopup( + context: context, + builder: (context) { + return Container( + width: MediaQuery.of(context).size.width, + height: 200, + color: Colors.white, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlatButton( + child: Text( + CirclesLocalizations.of(context).cancel, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.maybePop(context); + state.didChange(null); + widget._controller.value = null; + }, + ), + FlatButton( + child: Text( + CirclesLocalizations.of(context).save, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + Navigator.maybePop(context); + if (widget._controller.value == null) { + state.didChange(TimeOfDay.now()); + widget._controller.value = TimeOfDay.now(); + } + }, + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + initialDateTime: _initialDateTime(), + mode: CupertinoDatePickerMode.time, + use24hFormat: MediaQuery.of(context).alwaysUse24HourFormat, + onDateTimeChanged: (dateTime) { + state.didChange(TimeOfDay.fromDateTime(dateTime)); + widget._controller.value = TimeOfDay.fromDateTime(dateTime); + }, + ), + ), + ], + ), + ); + }, + ); + } + + DateTime _initialDateTime() { + final value = widget._controller.value; + final today = DateTime.now(); + if (value != null) { + return DateTime( + today.year, today.month, today.day, value.hour, value.minute); + } + return today; + } +} diff --git a/lib/presentation/home/channel_list/channel_list.dart b/lib/presentation/home/channel_list/channel_list.dart new file mode 100644 index 0000000..cf67c63 --- /dev/null +++ b/lib/presentation/home/channel_list/channel_list.dart @@ -0,0 +1,213 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list_item.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list_viewmodel.dart"; +import "package:circles_app/presentation/home/channel_list/event_status_icon_widget.dart"; +import "package:circles_app/presentation/home/channel_list/group_status_icon_widget.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class ChannelsList extends StatelessWidget { + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: ChannelListViewModel.fromStore, + distinct: true, + builder: (context, viewModel) { + final items = viewModel.items.toList(); + return Container( + width: DrawerStyle.listWidth, + child: Padding( + padding: EdgeInsets.only(left: DrawerStyle.defaultPadding, right: DrawerStyle.defaultPadding), + child: ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final previousItem = index > 0 ? items[index - 1] : null; + return _buildChannelList(item, previousItem, index); + }, + ), + )); + }); + } + + _buildChannelList(ChannelListItem item, previousItem, index) { + if (item is ChannelListChannelItem) { + return _ChannelItemWidget(item); + } else if (item is ChannelListHeadingItem) { + // Avoid additional padding if previous item in list isn't a channel. + final followsChannelItem = previousItem is ChannelListChannelItem; + final heading = _ChannelListHeadingWidget( + item, followsChannelItem ? DrawerStyle.sectionPadding : 0); + + // Add padding for first title item. + return index == 0 + ? Padding( + padding: EdgeInsets.only(top: 20), + child: heading, + ) + : heading; + } else if (item is ChannelListActionItem) { + return _ChannelListActionItemWidget(item); + } + + return Container(); + } +} + +//- The following private widgets are only used to construct the list above. + +class _ChannelListActionItemWidget extends StatelessWidget { + final ChannelListActionItem _item; + + const _ChannelListActionItemWidget(this._item); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: DrawerStyle.doublePadding), + child: Row(children: [ + Expanded( + child: Container( + padding: EdgeInsets.only(left: DrawerStyle.doublePadding), + child: Text( + ChannelItemTitleKeyHelper.stringOf(_item.title, context), + style: AppTheme.circleSectionButtonTitle, + textScaleFactor: 1, + ), + ), + ), + Container( + width: 50, + child: FlatButton( + onPressed: () => {_item.buttonAction(context)}, + child: Image.asset( + "assets/graphics/channel/create_new_channel.png"))), + ])); + } +} + +class _ChannelListHeadingWidget extends StatelessWidget { + final ChannelListHeadingItem _item; + final double _topPadding; + + const _ChannelListHeadingWidget(this._item, this._topPadding); + + @override + Widget build(BuildContext context) { + final title = _item.text != null + ? _item.text + : ChannelItemTitleKeyHelper.stringOf(_item.key, context); + + return Padding( + padding: + EdgeInsets.only(left: DrawerStyle.doublePadding, top: _topPadding), + child: _styledHeadline(title, _item.type), + ); + } + + _styledHeadline(String title, ChannelListHeadingItemType type) { + switch (type) { + case ChannelListHeadingItemType.SECTION: + return Padding( + padding: EdgeInsets.only(bottom: 5), + child: Text(title, style: AppTheme.circleSectionTitle)); + case ChannelListHeadingItemType.H1: + return Text(title, style: AppTheme.circleTitle); + case ChannelListHeadingItemType.H2: + return Padding( + padding: EdgeInsets.only(top: DrawerStyle.sectionPadding), + child: Stack( + children: [ + Text(title, style: AppTheme.circleSectionButtonTitle), + Positioned( + right: 20, + child: Image.asset( + "assets/graphics/updates_indicator.png", + width: 12, + )) + ], + )); + } + } +} + +class _ChannelItemWidget extends StatelessWidget { + final ChannelListChannelItem _item; + + const _ChannelItemWidget(this._item); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: DrawerStyle.selectionBorderRadius, + highlightColor: Colors.white24, + onTap: () { + Navigator.pop(context); + final provider = StoreProvider.of(context); + final previousChannelId = + provider.state.channelState.selectedChannel; + provider.dispatch( + SelectChannel( + channel: _item.channel, + groupId: provider.state.selectedGroupId, + userId: provider.state.user.uid, + previousChannelId: previousChannelId), + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: DrawerStyle.selectionBorderRadius, + color: + _item.isSelected ? Colors.white : Colors.transparent), + child: Padding( + padding: EdgeInsets.only( + left: DrawerStyle.doublePadding, + right: DrawerStyle.doublePadding, + ), + child: Container( + height: DrawerStyle.rowHeight, + child: Row( + children: [ + _buildIcon(_item), + Padding( + padding: EdgeInsets.only(top: 2), + child: Container( + width: 160, + child: Text(_item.title, + overflow: TextOverflow.ellipsis, + style: AppTheme + .circleSectionChannelTitle))) + ], + )))))); + } + + _buildIcon(ChannelListChannelItem item) { + if (item.channel.type == ChannelType.EVENT) { + try { + return EventStatusIconWidget( + joined: item.userIsMember, + isPublic: item.isPublic, + eventDate: item.channel.startDate ?? DateTime.now(), + ); + } catch (e) { + return Container(); + } + } + return GroupStatusIconWidget( + joined: item.userIsMember, isPrivateChannel: !item.isPublic); + } +} + +class DrawerStyle { + static const listWidth = 232.0; + static const defaultPadding = 5.0; + static const doublePadding = 10.0; + static const selectionBorderRadius = BorderRadius.all(Radius.circular(4.0)); + static const sectionPadding = 20.0; + static const rowHeight = 44.0; +} diff --git a/lib/presentation/home/channel_list/channel_list_item.dart b/lib/presentation/home/channel_list/channel_list_item.dart new file mode 100644 index 0000000..8b213fb --- /dev/null +++ b/lib/presentation/home/channel_list/channel_list_item.dart @@ -0,0 +1,78 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/model/channel.dart"; +import "package:flutter/widgets.dart"; + +abstract class ChannelListItem {} + +// ChannelListHeadingItem + +class ChannelListHeadingItem implements ChannelListItem { + final String text; + final ChannelLocalizedKey key; + final ChannelListHeadingItemType type; + + ChannelListHeadingItem( + {this.text, + this.key = ChannelLocalizedKey.NONE, + this.type = ChannelListHeadingItemType.SECTION}); +} + +enum ChannelListHeadingItemType { SECTION, H1, H2 } + +// ChannelListActionItem + +class ChannelListActionItem implements ChannelListItem { + final ChannelLocalizedKey title; + final Function buttonAction; + + ChannelListActionItem(this.title, this.buttonAction); +} + +// ChannelListChannelItem + +class ChannelListChannelItem implements ChannelListItem { + final Channel channel; + final String title; + final bool userIsMember; + final bool isPublic; + final bool isSelected; + + ChannelListChannelItem(this.channel, this.title, this.userIsMember, + this.isPublic, this.isSelected); +} + +// This is needed since we currently need a BuildContext to localize. +enum ChannelLocalizedKey { + TOPICS, + JOINED, + PENDING, + EVENTS, + UPCOMING, + PREVIOUS, + UNREAD, + NONE, +} + +class ChannelItemTitleKeyHelper { + static String stringOf(ChannelLocalizedKey key, BuildContext context) { + switch (key) { + case ChannelLocalizedKey.TOPICS: + return CirclesLocalizations.of(context).channelTitle; + case ChannelLocalizedKey.PENDING: + return CirclesLocalizations.of(context).channelListPending; + case ChannelLocalizedKey.JOINED: + return CirclesLocalizations.of(context).channelListJoined; + case ChannelLocalizedKey.EVENTS: + return CirclesLocalizations.of(context).channelListEvents; + case ChannelLocalizedKey.UPCOMING: + return CirclesLocalizations.of(context).channelListUpcoming; + case ChannelLocalizedKey.PREVIOUS: + return CirclesLocalizations.of(context).channelListPrevious; + case ChannelLocalizedKey.UNREAD: + return CirclesLocalizations.of(context).channelListUnread; + case ChannelLocalizedKey.NONE: + return ""; + } + return ""; + } +} diff --git a/lib/presentation/home/channel_list/channel_list_viewmodel.dart b/lib/presentation/home/channel_list/channel_list_viewmodel.dart new file mode 100644 index 0000000..347083e --- /dev/null +++ b/lib/presentation/home/channel_list/channel_list_viewmodel.dart @@ -0,0 +1,204 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list_item.dart"; +import "package:circles_app/routes.dart"; +import "package:flutter/widgets.dart" as W; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'channel_list_viewmodel.g.dart'; + +abstract class ChannelListViewModel + implements Built { + User get user; + + BuiltList get items; + + ChannelListViewModel._(); + + factory ChannelListViewModel( + [void Function(ChannelListViewModelBuilder) updates]) = + _$ChannelListViewModel; + + static ChannelListViewModel fromStore(Store store) { + final groupId = store.state.selectedGroupId; + final user = store.state.user; + final circle = store.state.groups[groupId]; + final channels = circle.channels.values.toList(); + final selectedChannelId = store.state.channelState.selectedChannel; + + _filterIrrelevantChannel(channels, user); + + final updatedChannels = channels + .where((c) => c.users.any((u) => u.id == user.uid) && c.hasUpdates) + .toList(); + + final list = [ + ChannelListHeadingItem( + text: circle.name, type: ChannelListHeadingItemType.H1), + ..._buildUnreadSection( + _toChannelItem(updatedChannels, selectedChannelId, user.uid)), + ..._buildEventSection( + channels: channels, + selectedChannelId: selectedChannelId, + userId: user.uid), + ..._buildGroupSection( + channels: channels, + selectedChannelId: selectedChannelId, + userId: user.uid) + ]; + + return ChannelListViewModel((c) => c + ..user = user.toBuilder() + ..items = ListBuilder(list)); + } + + // Filter unjoined private channels. This should eventually be moved to the backend. + static _filterIrrelevantChannel(List channels, user) { + channels.removeWhere((c) => + c.visibility == ChannelVisibility.CLOSED && + !c.users.any((u) => u.id == user.uid)); + } + + // Building list group for regular channels. + static _buildGroupSection({channels, selectedChannelId, userId}) { + final List unjoinedChannels = channels + .where((Channel c) => + !c.users.any((u) => u.id == userId) && c.type == ChannelType.TOPIC) + .toList(); + + final List readChannels = channels + .where((c) => + c.type == ChannelType.TOPIC && + c.users.any((u) => u.id == userId) && + (!c.hasUpdates || c.hasUpdates == null)) + .toList(); + + _sortChannelsByName(readChannels); + _sortChannelsByName(unjoinedChannels); + + // Only show joined and unjoined section when there is content: + var unjoinedSection = []; + var joinedAndReadSection = []; + + if (readChannels.length > 0) { + joinedAndReadSection = [ + ChannelListHeadingItem(key: ChannelLocalizedKey.JOINED), + ..._toChannelItem(readChannels, selectedChannelId, userId) + ]; + } + + if (unjoinedChannels.length > 0) { + unjoinedSection = [ + ChannelListHeadingItem(key: ChannelLocalizedKey.PENDING), + ..._toChannelItem(unjoinedChannels, selectedChannelId, userId) + ]; + } + + return [ + ChannelListActionItem( + ChannelLocalizedKey.TOPICS, + (context) { + W.Navigator.pushNamed(context, Routes.channelNew); + }, + ), + ...joinedAndReadSection, + ...unjoinedSection + ]; + } + + static _sortChannelsByName(List channels) { + channels + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } + + // Building list group for event channels. + static _buildEventSection({channels, selectedChannelId, userId}) { + final List events = + channels.where((c) => c.type == ChannelType.EVENT).toList(); + + // Sort events by date. + events.sort((Channel a, Channel b) { + return a.startDate.compareTo(b.startDate); + }); + + final now = DateTime.now(); + final today = now.add(Duration( + hours: -now.hour, minutes: -now.minute, seconds: -(now.second + 1))); + + final upcomingIndex = events.indexWhere((c) => today.isBefore(c.startDate)); + var upcomingEvents = []; + var previousEvents = []; + + if (events.length > 0) { + final eventItems = _toChannelItem(events, selectedChannelId, userId); + + // If upcomingIndex is == -1 there's no upcoming events + if (upcomingIndex >= 0) { + final upcomingChannelListItems = + eventItems.getRange(upcomingIndex, eventItems.length); + if (upcomingChannelListItems.length > 0) { + upcomingEvents = [ + ChannelListHeadingItem(key: ChannelLocalizedKey.UPCOMING), + ...upcomingChannelListItems.toList() + ]; + } + } + + var previousChannelListItems; + if (upcomingIndex >= 0) { + previousChannelListItems = eventItems.getRange(0, upcomingIndex); + } else { + // if upcomingIndex == -1 then ALL events have passed + previousChannelListItems = eventItems; + } + if (previousChannelListItems.length > 0) { + previousEvents = [ + ChannelListHeadingItem(key: ChannelLocalizedKey.PREVIOUS), + ...previousChannelListItems.toList() + ]; + } + } + + return [ + ChannelListActionItem( + ChannelLocalizedKey.EVENTS, + (context) { + W.Navigator.pushNamed(context, Routes.eventNew); + }, + ), + ...upcomingEvents, + ...previousEvents + ]; + } + + // Building list group for all unread channels (including events). + // This is sorted by most recent activity. + + static _buildUnreadSection(List unreadChannelItems) { + return unreadChannelItems.length > 0 + ? [ + ChannelListHeadingItem( + key: ChannelLocalizedKey.UNREAD, + type: ChannelListHeadingItemType.H2), + ...unreadChannelItems + ] + : []; + } + + static List _toChannelItem( + List channels, String selectedChannelId, String userId) { + return channels + .map((item) => ChannelListChannelItem( + item, + item.name, + item.users.any((u) => u.id == userId), + item.visibility == ChannelVisibility.OPEN, + selectedChannelId == item.id, + )) + .toList(); + } +} diff --git a/lib/presentation/home/channel_list/channel_list_viewmodel.g.dart b/lib/presentation/home/channel_list/channel_list_viewmodel.g.dart new file mode 100644 index 0000000..d9a8548 --- /dev/null +++ b/lib/presentation/home/channel_list/channel_list_viewmodel.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_list_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ChannelListViewModel extends ChannelListViewModel { + @override + final User user; + @override + final BuiltList items; + + factory _$ChannelListViewModel( + [void Function(ChannelListViewModelBuilder) updates]) => + (new ChannelListViewModelBuilder()..update(updates)).build(); + + _$ChannelListViewModel._({this.user, this.items}) : super._() { + if (user == null) { + throw new BuiltValueNullFieldError('ChannelListViewModel', 'user'); + } + if (items == null) { + throw new BuiltValueNullFieldError('ChannelListViewModel', 'items'); + } + } + + @override + ChannelListViewModel rebuild( + void Function(ChannelListViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChannelListViewModelBuilder toBuilder() => + new ChannelListViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ChannelListViewModel && + user == other.user && + items == other.items; + } + + @override + int get hashCode { + return $jf($jc($jc(0, user.hashCode), items.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ChannelListViewModel') + ..add('user', user) + ..add('items', items)) + .toString(); + } +} + +class ChannelListViewModelBuilder + implements Builder { + _$ChannelListViewModel _$v; + + UserBuilder _user; + UserBuilder get user => _$this._user ??= new UserBuilder(); + set user(UserBuilder user) => _$this._user = user; + + ListBuilder _items; + ListBuilder get items => + _$this._items ??= new ListBuilder(); + set items(ListBuilder items) => _$this._items = items; + + ChannelListViewModelBuilder(); + + ChannelListViewModelBuilder get _$this { + if (_$v != null) { + _user = _$v.user?.toBuilder(); + _items = _$v.items?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ChannelListViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ChannelListViewModel; + } + + @override + void update(void Function(ChannelListViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$ChannelListViewModel build() { + _$ChannelListViewModel _$result; + try { + _$result = _$v ?? + new _$ChannelListViewModel._( + user: user.build(), items: items.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'user'; + user.build(); + _$failedField = 'items'; + items.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'ChannelListViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/home/channel_list/event_status_icon_widget.dart b/lib/presentation/home/channel_list/event_status_icon_widget.dart new file mode 100644 index 0000000..3213615 --- /dev/null +++ b/lib/presentation/home/channel_list/event_status_icon_widget.dart @@ -0,0 +1,90 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/widgets.dart"; +import "package:intl/intl.dart"; + +class EventStatusIconWidget extends StatelessWidget { + final bool _joined; + final bool _isPublic; // Padlock used? + final DateTime _eventDate; + + const EventStatusIconWidget( + {joined, isPublic, eventDate, Key key}) + : _joined = joined, + _eventDate = eventDate, + _isPublic = isPublic, + super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: Container( + width: _Style.width, + height: _Style.width, + child: Padding( + padding: EdgeInsets.only(top: 2), + child: Stack( + children: [ + _imageWidget(_joined), + _buildIcon(_eventDate, _joined, _isPublic), + ], + )))); + } + + _buildIcon(eventDate, isMember, isPublic) { + if (isPublic) { + return Positioned( + left: 0, top: 7, child: _eventIconTime(eventDate, isMember)); + } else { + return Positioned( + top: 4, + child: Visibility( + visible: !_isPublic, + child: Image.asset( + "assets/graphics/channel/padlock.png", + width: _Style.imageSize.width, + height: _Style.imageSize.height, + ), + )); + } + } + + _eventIconTime(eventDate, isMember) => Container( + width: _Style.imageSize.width, + child: Column( + children: [ + Text( + eventDate.day.toString(), + textAlign: TextAlign.center, + style: isMember + ? AppTheme.eventIconMemberTitle + : AppTheme.eventIconTitle, + textScaleFactor: 1, + ), + Text( + DateFormat("MMM").format(eventDate).toUpperCase(), + textAlign: TextAlign.center, + style: isMember + ? AppTheme.eventIconMemberSubTitle + : AppTheme.eventIconSubTitle, + textScaleFactor: 1, + ), + Padding( + padding: EdgeInsets.only(bottom: 2), + ) + ], + )); + + _imageWidget(joined) => Image.asset( + joined + ? "assets/graphics/channel/event_joined.png" + : "assets/graphics/channel/event_open.png", + width: _Style.imageSize.width, + height: _Style.imageSize.height, + color: AppTheme.colorDarkBlue, + ); +} + +class _Style { + static const imageSize = Size(25, 26); + static const width = 32.0; +} diff --git a/lib/presentation/home/channel_list/group_status_icon_widget.dart b/lib/presentation/home/channel_list/group_status_icon_widget.dart new file mode 100644 index 0000000..9e21677 --- /dev/null +++ b/lib/presentation/home/channel_list/group_status_icon_widget.dart @@ -0,0 +1,46 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/widgets.dart"; + +class GroupStatusIconWidget extends StatelessWidget { + final bool _joined; + final bool _isPrivateChannel; + + const GroupStatusIconWidget({joined, isPrivateChannel, Key key}) + : _joined = joined, + _isPrivateChannel = isPrivateChannel, + super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: Container( + width: _Style.width, + height: _Style.width, + child: Padding( + padding: EdgeInsets.only(top: 5.0), + child: Stack( + children: [ + Image.asset( + _joined + ? "assets/graphics/channel/topic_joined.png" + : "assets/graphics/channel/topic_open.png", + width: _Style.imageSize.width, + color: AppTheme.colorDarkBlue, + ), + Visibility( + visible: _isPrivateChannel, + child: Image.asset( + "assets/graphics/channel/padlock.png", + width: _Style.imageSize.width, + height: _Style.imageSize.height, + ), + ), + ], + )))); + } +} + +class _Style { + static const imageSize = Size(25, 26); + static const width = 32.0; +} diff --git a/lib/presentation/home/circles_drawer.dart b/lib/presentation/home/circles_drawer.dart new file mode 100644 index 0000000..d996764 --- /dev/null +++ b/lib/presentation/home/circles_drawer.dart @@ -0,0 +1,49 @@ +import "package:circles_app/presentation/calendar/calendar_screen.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list.dart"; +import "package:circles_app/presentation/home/group_list/group_list.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; + +enum DrawerState { CALENDAR, CHANNEL } + +class CirclesDrawer extends StatefulWidget { + @override + _CirclesDrawerState createState() => _CirclesDrawerState(); +} + +class _CirclesDrawerState extends State { + DrawerState _drawerState = DrawerState.CHANNEL; + + _drawerStateChange(DrawerState state) { + setState(() { + _drawerState = state; + }); + } + + @override + Widget build(BuildContext context) { + return Drawer( + child: Container( + decoration: BoxDecoration( + color: AppTheme.colorMintGreen, + image: DecorationImage( + colorFilter: ColorFilter.mode( + Color.fromRGBO(255, 255, 255, 0.1), + BlendMode.modulate, + ), + image: AssetImage("assets/graphics/visual_twist_white_petrol.png"), + fit: BoxFit.cover, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GroupList(_drawerStateChange), + _drawerState == DrawerState.CALENDAR + ? CalendarScreen() + : ChannelsList() + ], + ), + )); + } +} diff --git a/lib/presentation/home/group_list/group_list.dart b/lib/presentation/home/group_list/group_list.dart new file mode 100644 index 0000000..5c2cd00 --- /dev/null +++ b/lib/presentation/home/group_list/group_list.dart @@ -0,0 +1,362 @@ +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/presentation/common/platform_alerts.dart"; +import "package:circles_app/presentation/home/circles_drawer.dart"; +import "package:circles_app/presentation/home/group_list/group_list_viewmodel.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/HexColor.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class GroupList extends StatefulWidget { + final Function(DrawerState) stateChangeCallback; + + const GroupList(this.stateChangeCallback); + + @override + _GroupListState createState() => _GroupListState(); +} + +class _GroupListState extends State { + bool _calendarSelected = false; + + _buildFirstSection(BuildContext context) { + return Container( + height: _Style.firstSectionHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + child: Row(children: [ + ..._buildSelectionHighlight(_calendarSelected, Colors.white), + _selectableListItem( + icon: Image.asset("assets/graphics/drawer/events.png"), + isSelected: _calendarSelected, + action: () { + widget.stateChangeCallback(DrawerState.CALENDAR); + setState(() { + _calendarSelected = true; + }); + }), + ])), + Padding( + padding: EdgeInsets.only( + top: _Style.padding, + ), + ), + Container( + color: AppTheme.colorDarkGreen, + height: _Style.separatorHeight, + width: _Style.separatorWidth, + ), + ], + )); + } + + _buildThirdSection(BuildContext context) { + return Container( + height: _Style.thirdSectionHeight, + child: Column( + children: [ + _Style.defaultPadding, + _GroupSettingsButton( + Image.asset("assets/graphics/drawer/create_topic.png"), () { + showSoonAlert(context: context); + }), + ], + )); + } + + _buildFourthSection(BuildContext context) { + return Container( + height: _Style.fourthSectionHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _GroupSettingsButton( + Image.asset("assets/graphics/drawer/settings.png"), + () { + Navigator.pushNamed(context, Routes.settings); + }, + ), + _Style.defaultPadding, + _GroupSettingsButton( + Image.asset("assets/graphics/drawer/account.png"), + () { + _openUserAccount(context); + }, + ), + _Style.defaultPadding, + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: GroupListViewModel.fromStore, + distinct: true, + builder: (context, viewModel) { + final secondSectionHeight = viewModel.groups.length * _Style.itemHeight; + final statusBarHeight = MediaQuery.of(context).padding.top; + final topPadding = MediaQuery.of(context).size.height - + (_Style.totalStaticSectionHeight + + secondSectionHeight + + statusBarHeight); + + final items = [ + _buildFirstSection(context), + ...viewModel.groups.toList(), + _buildThirdSection(context), + // Atempt to position fourth section in bottom of screen. + // Attach it with padding if there isn't enough space available. + Padding( + padding: EdgeInsets.only( + top: topPadding < 100 ? _Style.padding * 2 : topPadding), + ), + _buildFourthSection(context) + ]; + + return Container( + height: MediaQuery.of(context).size.height, + width: _Style.listWidth, + color: AppTheme.colorDarkBlue.withOpacity(0.3), + child: ListView.builder( + padding: EdgeInsets.only(top: 0), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + + if (item is Group) { + return _GroupListItem( + item, + item.id == viewModel.selectedGroupId && !_calendarSelected, + viewModel.updatedGroups.contains(item.id), + () { + widget.stateChangeCallback(DrawerState.CHANNEL); + setState(() { + _calendarSelected = false; + }); + }, + ); + } else { + return item; + } + }), + ); + }, + ); + } + + void _openUserAccount(BuildContext context) { + final uid = StoreProvider.of(context).state.user.uid; + Navigator.of(context).pushNamed(Routes.user, arguments: uid); + } +} + +class _GroupListItem extends StatelessWidget { + final Group _group; + final bool _selected; + final bool _hasUpdates; + final Function _selectionCallback; + + const _GroupListItem( + group, + selected, + hasUpdates, + selectionCallback, { + Key key, + }) : _group = group, + _selected = selected, + _hasUpdates = hasUpdates, + _selectionCallback = selectionCallback, + super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: _Style.itemHeight, + child: Padding( + padding: const EdgeInsets.only( + top: _Style.padding, + right: _Style.padding, + ), + child: _GroupButton( + _group, + (id) { + _selectionCallback(); + _selectGroup(context, id); + }, + _selected, + _hasUpdates, + ), + )); + } + + void _selectGroup( + BuildContext context, + String id, + ) { + StoreProvider.of(context).dispatch(SelectGroup(id)); + } +} + +class _GroupSettingsButton extends StatelessWidget { + final Image image; + final Function onPressed; + + const _GroupSettingsButton( + this.image, + this.onPressed, { + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: _Style.circleButtonWidth, + height: _Style.circleButtonWidth, + child: FittedBox( + fit: BoxFit.cover, + child: FlatButton( + shape: CircleBorder(), + child: image, + onPressed: onPressed, + ))); + } +} + +class _GroupButton extends StatelessWidget { + final Group group; + final Function(String) onPressedCircle; + final bool isSelected; + final bool hasUpdates; + + const _GroupButton( + this.group, + this.onPressedCircle, + this.isSelected, + this.hasUpdates, { + Key key, + }) : assert(group != null), + assert(onPressedCircle != null), + super(key: key); + + @override + Widget build(BuildContext context) { + final _circleColor = HexColor(group.hexColor); + final _groupText = group.abbreviation.substring(0, 2).toUpperCase(); + + return Container( + child: Row( + children: [ + ..._buildSelectionHighlight(isSelected, _circleColor), + _selectableListItem( + color: _circleColor, + text: _groupText, + action: () { + onPressedCircle(group.id); + }, + updateIndicatorVisible: hasUpdates, + isSelected: isSelected, + ), + ], + ), + ); + } +} + +_selectableListItem({ + Color color = Colors.white, + String text = "", + Image icon, + Function action, + bool updateIndicatorVisible = false, + bool isSelected = false, +}) { + return AnimatedContainer( + duration: Duration(milliseconds: 100), + width: _Style.circleButtonWidth, + height: _Style.circleButtonWidth, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.all(Radius.circular(isSelected ? 8.0 : 22.0)), + ), + child: Stack( + overflow: Overflow.visible, + children: [ + InkWell( + child: Center( + child: Container( + alignment: Alignment(0, 0.2), + width: _Style.circleButtonWidth, + height: _Style.circleButtonWidth, + child: icon == null + ? Text(text, style: AppTheme.circleMenuAbbreviationText) + : icon, + )), + onTap: action, + ), + Visibility( + visible: updateIndicatorVisible, + child: Positioned( + top: -2, + right: -2, + height: _Style.circleUnreadIndicatorWidth, + width: _Style.circleUnreadIndicatorWidth, + child: Image.asset( + "assets/graphics/update_indicator_darkgreen.png", + ), + ), + ), + ], + ), + ); +} + +List _buildSelectionHighlight(isSelected, circleColor) { + final List widgets = []; + if (isSelected) { + final highlight = ClipRRect( + borderRadius: BorderRadius.only( + topRight: Radius.circular(_Style.circleHighlightBorderRadius), + bottomRight: Radius.circular(_Style.circleHighlightBorderRadius)), + child: Container( + width: _Style.circleHighlightWidth, + height: _Style.circleButtonWidth, + color: circleColor, + )); + widgets.add(highlight); + } + + final sizedBoxSpace = SizedBox( + width: (isSelected ? 11 : 15), + ); + + widgets.add(sizedBoxSpace); + return widgets; +} + +class _Style { + static const listWidth = 72.0; + static const circleButtonWidth = 44.0; + + static const circleHighlightWidth = 4.0; + static const circleHighlightBorderRadius = 10.0; + static const circleUnreadIndicatorWidth = 14.0; + + static const separatorHeight = 2.0; + static const separatorWidth = 48.0; + static const padding = 8.0; + static const defaultPadding = Padding(padding: EdgeInsets.only(top: padding)); + + static const itemHeight = 52.0; + static const firstSectionHeight = 100.0; + static const thirdSectionHeight = 60.0; + static const fourthSectionHeight = 180.0; + static const totalStaticSectionHeight = + 340.0; // Sum of all sections without itemHeight +} diff --git a/lib/presentation/home/group_list/group_list_viewmodel.dart b/lib/presentation/home/group_list/group_list_viewmodel.dart new file mode 100644 index 0000000..866c263 --- /dev/null +++ b/lib/presentation/home/group_list/group_list_viewmodel.dart @@ -0,0 +1,31 @@ +import "package:built_collection/built_collection.dart"; +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/group.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'group_list_viewmodel.g.dart'; + +abstract class GroupListViewModel + implements Built { + BuiltList get groups; + BuiltList get updatedGroups; + String get selectedGroupId; + + GroupListViewModel._(); + + factory GroupListViewModel( + [void Function(GroupListViewModelBuilder) updates]) = + _$GroupListViewModel; + + static GroupListViewModel fromStore(Store store) { + final unreadGroupsMap = store.state.user.unreadUpdates.toMap(); + unreadGroupsMap.removeWhere((key, value) => value == null || value.length == 0); + + return GroupListViewModel((c) => c + ..groups = ListBuilder(store.state.groups.values) + ..selectedGroupId = store.state.selectedGroupId + ..updatedGroups = ListBuilder(unreadGroupsMap.keys)); + } +} diff --git a/lib/presentation/home/group_list/group_list_viewmodel.g.dart b/lib/presentation/home/group_list/group_list_viewmodel.g.dart new file mode 100644 index 0000000..18814f5 --- /dev/null +++ b/lib/presentation/home/group_list/group_list_viewmodel.g.dart @@ -0,0 +1,141 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_list_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$GroupListViewModel extends GroupListViewModel { + @override + final BuiltList groups; + @override + final BuiltList updatedGroups; + @override + final String selectedGroupId; + + factory _$GroupListViewModel( + [void Function(GroupListViewModelBuilder) updates]) => + (new GroupListViewModelBuilder()..update(updates)).build(); + + _$GroupListViewModel._( + {this.groups, this.updatedGroups, this.selectedGroupId}) + : super._() { + if (groups == null) { + throw new BuiltValueNullFieldError('GroupListViewModel', 'groups'); + } + if (updatedGroups == null) { + throw new BuiltValueNullFieldError('GroupListViewModel', 'updatedGroups'); + } + if (selectedGroupId == null) { + throw new BuiltValueNullFieldError( + 'GroupListViewModel', 'selectedGroupId'); + } + } + + @override + GroupListViewModel rebuild( + void Function(GroupListViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + GroupListViewModelBuilder toBuilder() => + new GroupListViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is GroupListViewModel && + groups == other.groups && + updatedGroups == other.updatedGroups && + selectedGroupId == other.selectedGroupId; + } + + @override + int get hashCode { + return $jf($jc($jc($jc(0, groups.hashCode), updatedGroups.hashCode), + selectedGroupId.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('GroupListViewModel') + ..add('groups', groups) + ..add('updatedGroups', updatedGroups) + ..add('selectedGroupId', selectedGroupId)) + .toString(); + } +} + +class GroupListViewModelBuilder + implements Builder { + _$GroupListViewModel _$v; + + ListBuilder _groups; + ListBuilder get groups => _$this._groups ??= new ListBuilder(); + set groups(ListBuilder groups) => _$this._groups = groups; + + ListBuilder _updatedGroups; + ListBuilder get updatedGroups => + _$this._updatedGroups ??= new ListBuilder(); + set updatedGroups(ListBuilder updatedGroups) => + _$this._updatedGroups = updatedGroups; + + String _selectedGroupId; + String get selectedGroupId => _$this._selectedGroupId; + set selectedGroupId(String selectedGroupId) => + _$this._selectedGroupId = selectedGroupId; + + GroupListViewModelBuilder(); + + GroupListViewModelBuilder get _$this { + if (_$v != null) { + _groups = _$v.groups?.toBuilder(); + _updatedGroups = _$v.updatedGroups?.toBuilder(); + _selectedGroupId = _$v.selectedGroupId; + _$v = null; + } + return this; + } + + @override + void replace(GroupListViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$GroupListViewModel; + } + + @override + void update(void Function(GroupListViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$GroupListViewModel build() { + _$GroupListViewModel _$result; + try { + _$result = _$v ?? + new _$GroupListViewModel._( + groups: groups.build(), + updatedGroups: updatedGroups.build(), + selectedGroupId: selectedGroupId); + } catch (_) { + String _$failedField; + try { + _$failedField = 'groups'; + groups.build(); + _$failedField = 'updatedGroups'; + updatedGroups.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'GroupListViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/home/home_app_bar.dart b/lib/presentation/home/home_app_bar.dart new file mode 100644 index 0000000..494c774 --- /dev/null +++ b/lib/presentation/home/home_app_bar.dart @@ -0,0 +1,161 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/presentation/home/home_app_bar_viewmodel.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { + const HomeAppBar({ + @required this.scaffoldKey, + @required this.sideOpenController, + }); + + final GlobalKey scaffoldKey; + final ValueNotifier sideOpenController; + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: HomeAppBarViewModel.fromStore(context), + builder: (context, vm) => _buildAppBar(context, vm), + distinct: true, + ); + } + + Widget _buildAppBar(BuildContext context, HomeAppBarViewModel vm) { + return SafeArea( + top: true, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + offset: Offset(0.0, 1.0), + ) + ], + ), + height: AppTheme.appBarSize, + child: Row( + children: [ + _hamburger(vm), + _title(vm), + _options(vm), + ], + ), + ), + ); + } + + Visibility _options(HomeAppBarViewModel vm) { + return Visibility( + visible: vm.memberOfChannel, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + sideOpenController.value = true; + }, + child: Container( + width: AppTheme.appBarSize, + height: AppTheme.appBarSize, + child: Center( + child: Image.asset( + "assets/graphics/menu_more_icon.png", + width: 28, + height: 28, + ), + ), + ), + ), + )); + } + + Widget _title(HomeAppBarViewModel vm) { + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vm.title, + style: AppTheme.channelTitle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + _eventDetails(vm), + ], + ), + ); + } + + Widget _eventDetails(HomeAppBarViewModel vm) { + if (vm.isEvent) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 4.0), + child: Image.asset( + "assets/graphics/channel/header_calendar_icon.png", + width: 14, + height: 13, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + vm.eventDate, + style: AppTheme.channelSubTitle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ); + } else { + return SizedBox.shrink(); + } + } + + Widget _hamburger(HomeAppBarViewModel vm) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + scaffoldKey.currentState.openDrawer(); + }, + child: Container( + width: AppTheme.appBarSize, + height: AppTheme.appBarSize, + child: Center( + child: Stack( + overflow: Overflow.visible, + children: [ + Image.asset( + "assets/graphics/menu_icon.png", + width: 25, + height: 25, + ), + Visibility( + visible: vm.hasUpdatedChannelsInGroup, + child: Positioned( + top: -3, + right: -5, + height: 12, + width: 12, + child: Image.asset( + "assets/graphics/updates_indicator_white.png", + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Size get preferredSize => Size.fromHeight(AppTheme.appBarSize); +} diff --git a/lib/presentation/home/home_app_bar_viewmodel.dart b/lib/presentation/home/home_app_bar_viewmodel.dart new file mode 100644 index 0000000..f0cbec5 --- /dev/null +++ b/lib/presentation/home/home_app_bar_viewmodel.dart @@ -0,0 +1,68 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/util/date_formatting.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'home_app_bar_viewmodel.g.dart'; + +abstract class HomeAppBarViewModel + implements Built { + HomeAppBarViewModel._(); + + factory HomeAppBarViewModel( + [void Function(HomeAppBarViewModelBuilder) updates]) = + _$HomeAppBarViewModel; + + bool get hasUpdatedChannelsInGroup; + + String get title; + + bool get memberOfChannel; + + bool get isEvent; + + String get eventDate; + + static Function(Store) fromStore(context) { + return (Store store) { + final channel = getSelectedChannel(store.state); + final groupId = store.state.selectedGroupId; + final channels = store.state.groups[groupId].channels.values.toList(); + final hasGroupUpdates = + channels.any((c) => (c != channel) && c.hasUpdates); + + final isMemberOfChannel = + channel.users.any((u) => u.id == store.state.user.uid); + + return HomeAppBarViewModel((vm) { + return vm + ..title = channel.name + ..memberOfChannel = isMemberOfChannel + ..hasUpdatedChannelsInGroup = hasGroupUpdates + ..isEvent = channel.type == ChannelType.EVENT + ..eventDate = _formatDate(context, channel); + }); + }; + } + + static String _formatDate(context, Channel channel) { + if (channel.startDate == null) { + return ""; + } + try { + if (channel.hasStartTime) { + return "${formatDate(context, channel.startDate)} " + "${CirclesLocalizations.of(context).at} " + "${formatTime(context, channel.startDate)}"; + } else { + return formatDate(context, channel.startDate); + } + } catch (error) { + return ""; + } + } +} diff --git a/lib/presentation/home/home_app_bar_viewmodel.g.dart b/lib/presentation/home/home_app_bar_viewmodel.g.dart new file mode 100644 index 0000000..88ff45b --- /dev/null +++ b/lib/presentation/home/home_app_bar_viewmodel.g.dart @@ -0,0 +1,160 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_app_bar_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$HomeAppBarViewModel extends HomeAppBarViewModel { + @override + final bool hasUpdatedChannelsInGroup; + @override + final String title; + @override + final bool memberOfChannel; + @override + final bool isEvent; + @override + final String eventDate; + + factory _$HomeAppBarViewModel( + [void Function(HomeAppBarViewModelBuilder) updates]) => + (new HomeAppBarViewModelBuilder()..update(updates)).build(); + + _$HomeAppBarViewModel._( + {this.hasUpdatedChannelsInGroup, + this.title, + this.memberOfChannel, + this.isEvent, + this.eventDate}) + : super._() { + if (hasUpdatedChannelsInGroup == null) { + throw new BuiltValueNullFieldError( + 'HomeAppBarViewModel', 'hasUpdatedChannelsInGroup'); + } + if (title == null) { + throw new BuiltValueNullFieldError('HomeAppBarViewModel', 'title'); + } + if (memberOfChannel == null) { + throw new BuiltValueNullFieldError( + 'HomeAppBarViewModel', 'memberOfChannel'); + } + if (isEvent == null) { + throw new BuiltValueNullFieldError('HomeAppBarViewModel', 'isEvent'); + } + if (eventDate == null) { + throw new BuiltValueNullFieldError('HomeAppBarViewModel', 'eventDate'); + } + } + + @override + HomeAppBarViewModel rebuild( + void Function(HomeAppBarViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + HomeAppBarViewModelBuilder toBuilder() => + new HomeAppBarViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is HomeAppBarViewModel && + hasUpdatedChannelsInGroup == other.hasUpdatedChannelsInGroup && + title == other.title && + memberOfChannel == other.memberOfChannel && + isEvent == other.isEvent && + eventDate == other.eventDate; + } + + @override + int get hashCode { + return $jf($jc( + $jc( + $jc($jc($jc(0, hasUpdatedChannelsInGroup.hashCode), title.hashCode), + memberOfChannel.hashCode), + isEvent.hashCode), + eventDate.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('HomeAppBarViewModel') + ..add('hasUpdatedChannelsInGroup', hasUpdatedChannelsInGroup) + ..add('title', title) + ..add('memberOfChannel', memberOfChannel) + ..add('isEvent', isEvent) + ..add('eventDate', eventDate)) + .toString(); + } +} + +class HomeAppBarViewModelBuilder + implements Builder { + _$HomeAppBarViewModel _$v; + + bool _hasUpdatedChannelsInGroup; + bool get hasUpdatedChannelsInGroup => _$this._hasUpdatedChannelsInGroup; + set hasUpdatedChannelsInGroup(bool hasUpdatedChannelsInGroup) => + _$this._hasUpdatedChannelsInGroup = hasUpdatedChannelsInGroup; + + String _title; + String get title => _$this._title; + set title(String title) => _$this._title = title; + + bool _memberOfChannel; + bool get memberOfChannel => _$this._memberOfChannel; + set memberOfChannel(bool memberOfChannel) => + _$this._memberOfChannel = memberOfChannel; + + bool _isEvent; + bool get isEvent => _$this._isEvent; + set isEvent(bool isEvent) => _$this._isEvent = isEvent; + + String _eventDate; + String get eventDate => _$this._eventDate; + set eventDate(String eventDate) => _$this._eventDate = eventDate; + + HomeAppBarViewModelBuilder(); + + HomeAppBarViewModelBuilder get _$this { + if (_$v != null) { + _hasUpdatedChannelsInGroup = _$v.hasUpdatedChannelsInGroup; + _title = _$v.title; + _memberOfChannel = _$v.memberOfChannel; + _isEvent = _$v.isEvent; + _eventDate = _$v.eventDate; + _$v = null; + } + return this; + } + + @override + void replace(HomeAppBarViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$HomeAppBarViewModel; + } + + @override + void update(void Function(HomeAppBarViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$HomeAppBarViewModel build() { + final _$result = _$v ?? + new _$HomeAppBarViewModel._( + hasUpdatedChannelsInGroup: hasUpdatedChannelsInGroup, + title: title, + memberOfChannel: memberOfChannel, + isEvent: isEvent, + eventDate: eventDate); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/home/homescreen.dart b/lib/presentation/home/homescreen.dart new file mode 100644 index 0000000..9f727d3 --- /dev/null +++ b/lib/presentation/home/homescreen.dart @@ -0,0 +1,51 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/presentation/channel/channel_screen.dart"; +import "package:circles_app/presentation/home/circles_drawer.dart"; +import "package:circles_app/presentation/home/home_app_bar.dart"; +import "package:circles_app/presentation/home/in_app_notification/in_app_notification_viewmodel.dart"; +import "package:circles_app/presentation/home/in_app_notification/in_app_notification_widget.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class HomeScreen extends StatefulWidget { + final ValueNotifier sideOpenController; + + const HomeScreen({ + Key key, + @required this.sideOpenController, + }) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _scaffoldKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Scaffold( + key: _scaffoldKey, + appBar: HomeAppBar( + scaffoldKey: _scaffoldKey, + sideOpenController: widget.sideOpenController, + ), + body: ChannelScreen(), + drawer: CirclesDrawer(), + ), + StoreConnector( + builder: (BuildContext context, InAppNotificationViewModel vm) { + return vm.inAppNotification != null + ? InAppNotificationWidget(vm) + : Container(); + }, + converter: InAppNotificationViewModel.fromStore, + distinct: true, + ), + ], + ); + } +} + diff --git a/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.dart b/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.dart new file mode 100644 index 0000000..08069ce --- /dev/null +++ b/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.dart @@ -0,0 +1,48 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:circles_app/model/in_app_notification.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'in_app_notification_viewmodel.g.dart'; + +abstract class InAppNotificationViewModel + implements + Built { + @nullable + InAppNotification get inAppNotification; + + @BuiltValueField(compare: false) + Function get onDismissed; + + @BuiltValueField(compare: false) + Function get onTap; + + InAppNotificationViewModel._(); + + factory InAppNotificationViewModel( + [void Function(InAppNotificationViewModelBuilder) updates]) = + _$InAppNotificationViewModel; + + static InAppNotificationViewModel fromStore(Store store) { + final previouslySelectedChannel = store.state.channelState.selectedChannel; + return InAppNotificationViewModel((i) => i + ..inAppNotification = store.state.inAppNotification?.toBuilder() + ..onDismissed = () { + store.dispatch(OnPushNotificationDismissedAction()); + } + ..onTap = () { + store.dispatch(SelectGroup(store.state.inAppNotification.groupId)); + store.dispatch(SelectChannel( + previousChannelId: previouslySelectedChannel, + channel: store.state.inAppNotification.channel, + groupId: store.state.inAppNotification.groupId, + userId: store.state.user.uid, + )); + store.dispatch(OnPushNotificationDismissedAction()); + }); + } +} diff --git a/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.g.dart b/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.g.dart new file mode 100644 index 0000000..6d81e99 --- /dev/null +++ b/lib/presentation/home/in_app_notification/in_app_notification_viewmodel.g.dart @@ -0,0 +1,133 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'in_app_notification_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$InAppNotificationViewModel extends InAppNotificationViewModel { + @override + final InAppNotification inAppNotification; + @override + final Function onDismissed; + @override + final Function onTap; + + factory _$InAppNotificationViewModel( + [void Function(InAppNotificationViewModelBuilder) updates]) => + (new InAppNotificationViewModelBuilder()..update(updates)).build(); + + _$InAppNotificationViewModel._( + {this.inAppNotification, this.onDismissed, this.onTap}) + : super._() { + if (onDismissed == null) { + throw new BuiltValueNullFieldError( + 'InAppNotificationViewModel', 'onDismissed'); + } + if (onTap == null) { + throw new BuiltValueNullFieldError('InAppNotificationViewModel', 'onTap'); + } + } + + @override + InAppNotificationViewModel rebuild( + void Function(InAppNotificationViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + InAppNotificationViewModelBuilder toBuilder() => + new InAppNotificationViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is InAppNotificationViewModel && + inAppNotification == other.inAppNotification; + } + + @override + int get hashCode { + return $jf($jc(0, inAppNotification.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('InAppNotificationViewModel') + ..add('inAppNotification', inAppNotification) + ..add('onDismissed', onDismissed) + ..add('onTap', onTap)) + .toString(); + } +} + +class InAppNotificationViewModelBuilder + implements + Builder { + _$InAppNotificationViewModel _$v; + + InAppNotificationBuilder _inAppNotification; + InAppNotificationBuilder get inAppNotification => + _$this._inAppNotification ??= new InAppNotificationBuilder(); + set inAppNotification(InAppNotificationBuilder inAppNotification) => + _$this._inAppNotification = inAppNotification; + + Function _onDismissed; + Function get onDismissed => _$this._onDismissed; + set onDismissed(Function onDismissed) => _$this._onDismissed = onDismissed; + + Function _onTap; + Function get onTap => _$this._onTap; + set onTap(Function onTap) => _$this._onTap = onTap; + + InAppNotificationViewModelBuilder(); + + InAppNotificationViewModelBuilder get _$this { + if (_$v != null) { + _inAppNotification = _$v.inAppNotification?.toBuilder(); + _onDismissed = _$v.onDismissed; + _onTap = _$v.onTap; + _$v = null; + } + return this; + } + + @override + void replace(InAppNotificationViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$InAppNotificationViewModel; + } + + @override + void update(void Function(InAppNotificationViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$InAppNotificationViewModel build() { + _$InAppNotificationViewModel _$result; + try { + _$result = _$v ?? + new _$InAppNotificationViewModel._( + inAppNotification: _inAppNotification?.build(), + onDismissed: onDismissed, + onTap: onTap); + } catch (_) { + String _$failedField; + try { + _$failedField = 'inAppNotification'; + _inAppNotification?.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'InAppNotificationViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/home/in_app_notification/in_app_notification_widget.dart b/lib/presentation/home/in_app_notification/in_app_notification_widget.dart new file mode 100644 index 0000000..058ef17 --- /dev/null +++ b/lib/presentation/home/in_app_notification/in_app_notification_widget.dart @@ -0,0 +1,131 @@ +import "dart:async"; + +import "package:circles_app/presentation/home/in_app_notification/in_app_notification_viewmodel.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; + +class InAppNotificationWidget extends StatefulWidget { + final InAppNotificationViewModel _vm; + + const InAppNotificationWidget( + this._vm, { + Key key, + }) : super(key: key); + + @override + _InAppNotificationWidgetState createState() => + _InAppNotificationWidgetState(); +} + +class _InAppNotificationWidgetState extends State + with SingleTickerProviderStateMixin { + Animation _position; + AnimationController _controller; + Timer _timer; + + @override + void initState() { + super.initState(); + _initTransitionController(); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + _timer.cancel(); + } + + void _initTransitionController() { + final Animatable _tween = Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).chain(CurveTween( + curve: Curves.fastOutSlowIn, + )); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 500), + ); + _position = _controller.drive(_tween); + } + + void _dismiss() { + _controller.reverse().then((_) { + widget._vm.onDismissed(); + }); + } + + @override + Widget build(BuildContext context) { + _controller.reset(); + _controller.forward(); + _timer = Timer(Duration(seconds: 3), _dismiss); + + return SlideTransition( + position: _position, + child: Dismissible( + key: Key(widget._vm.hashCode.toString()), + onDismissed: (direction) => widget._vm.onDismissed(), + child: GestureDetector( + onTap: widget._vm.onTap, + child: Padding( + padding: const EdgeInsets.only( + top: 32.0, + left: 12, + right: 12, + ), + child: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.grey, + blurRadius: 5.0, + spreadRadius: 1.0, + ) + ], + borderRadius: BorderRadius.all(Radius.circular(8)), + image: DecorationImage( + image: AssetImage("assets/graphics/visual_twist.png"), + fit: BoxFit.cover, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Image.asset( + "assets/graphics/icon_notification.png", + height: 16, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + "${widget._vm.inAppNotification.groupName}: ${widget._vm.inAppNotification.channel.name}", + overflow: TextOverflow.ellipsis, + style: AppTheme.notificationTitle, + ), + ), + ), + ], + ), + Text( + "${widget._vm.inAppNotification.userName}: ${widget._vm.inAppNotification.message}", + overflow: TextOverflow.ellipsis, + style: AppTheme.notificationBody, + ) + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/home/main_screen.dart b/lib/presentation/home/main_screen.dart new file mode 100644 index 0000000..701b52f --- /dev/null +++ b/lib/presentation/home/main_screen.dart @@ -0,0 +1,73 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/details/topic_details.dart"; +import "package:circles_app/presentation/channel/event/event_details.dart"; +import "package:circles_app/presentation/home/homescreen.dart"; +import "package:circles_app/presentation/home/main_screen_viewmodel.dart"; +import "package:circles_app/presentation/home/slide_out_screen.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +/// +/// This screen loads the HomeScreen when there's data loaded +/// to avoid things like having null User, null Channel, etc. +/// +/// Also holds the ValueNotifier for the side open/closed state +/// as it passes it to the SlideOut widget and the HomeScreen. +/// +class MainScreen extends StatefulWidget { + @override + _MainScreenState createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + ValueNotifier _sideOpenController; + + @override + void initState() { + super.initState(); + _sideOpenController = ValueNotifier(false); + } + + @override + void dispose() { + super.dispose(); + _sideOpenController.dispose(); + } + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: MainScreenViewModel.fromStore, + builder: (context, vm) { + if (vm.hasData) { + return SlideOutScreen( + main: HomeScreen( + sideOpenController: _sideOpenController, + ), + side: _buildDetails(vm), + sideOpenController: _sideOpenController, + ); + } else { + // TODO: Proper empty state screen + return Scaffold(); + } + }, + ); + } + + Widget _buildDetails(MainScreenViewModel vm) { + switch (vm.channelType) { + case ChannelType.TOPIC: + return TopicDetails( + sideOpenController: _sideOpenController, + ); + case ChannelType.EVENT: + return EventDetails( + sideOpenController: _sideOpenController, + ); + } + return null; + } +} diff --git a/lib/presentation/home/main_screen_viewmodel.dart b/lib/presentation/home/main_screen_viewmodel.dart new file mode 100644 index 0000000..7dfe868 --- /dev/null +++ b/lib/presentation/home/main_screen_viewmodel.dart @@ -0,0 +1,33 @@ +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'main_screen_viewmodel.g.dart'; + +abstract class MainScreenViewModel + implements Built { + bool get hasData; + + @nullable + ChannelType get channelType; + + MainScreenViewModel._(); + + factory MainScreenViewModel( + [void Function(MainScreenViewModelBuilder) updates]) = + _$MainScreenViewModel; + + static bool _hasData(Store store) { + return store.state.user != null && + store.state.channelState.selectedChannel != null; + } + + static MainScreenViewModel fromStore(Store store) { + return MainScreenViewModel((vm) => vm + ..hasData = _hasData(store) + ..channelType = getSelectedChannel(store.state)?.type); + } +} diff --git a/lib/presentation/home/main_screen_viewmodel.g.dart b/lib/presentation/home/main_screen_viewmodel.g.dart new file mode 100644 index 0000000..ac7ef1b --- /dev/null +++ b/lib/presentation/home/main_screen_viewmodel.g.dart @@ -0,0 +1,101 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'main_screen_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$MainScreenViewModel extends MainScreenViewModel { + @override + final bool hasData; + @override + final ChannelType channelType; + + factory _$MainScreenViewModel( + [void Function(MainScreenViewModelBuilder) updates]) => + (new MainScreenViewModelBuilder()..update(updates)).build(); + + _$MainScreenViewModel._({this.hasData, this.channelType}) : super._() { + if (hasData == null) { + throw new BuiltValueNullFieldError('MainScreenViewModel', 'hasData'); + } + } + + @override + MainScreenViewModel rebuild( + void Function(MainScreenViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + MainScreenViewModelBuilder toBuilder() => + new MainScreenViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is MainScreenViewModel && + hasData == other.hasData && + channelType == other.channelType; + } + + @override + int get hashCode { + return $jf($jc($jc(0, hasData.hashCode), channelType.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('MainScreenViewModel') + ..add('hasData', hasData) + ..add('channelType', channelType)) + .toString(); + } +} + +class MainScreenViewModelBuilder + implements Builder { + _$MainScreenViewModel _$v; + + bool _hasData; + bool get hasData => _$this._hasData; + set hasData(bool hasData) => _$this._hasData = hasData; + + ChannelType _channelType; + ChannelType get channelType => _$this._channelType; + set channelType(ChannelType channelType) => _$this._channelType = channelType; + + MainScreenViewModelBuilder(); + + MainScreenViewModelBuilder get _$this { + if (_$v != null) { + _hasData = _$v.hasData; + _channelType = _$v.channelType; + _$v = null; + } + return this; + } + + @override + void replace(MainScreenViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$MainScreenViewModel; + } + + @override + void update(void Function(MainScreenViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$MainScreenViewModel build() { + final _$result = _$v ?? + new _$MainScreenViewModel._(hasData: hasData, channelType: channelType); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/presentation/home/slide_out_screen.dart b/lib/presentation/home/slide_out_screen.dart new file mode 100644 index 0000000..e12d375 --- /dev/null +++ b/lib/presentation/home/slide_out_screen.dart @@ -0,0 +1,173 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +class SlideOutScreen extends StatefulWidget { + const SlideOutScreen({ + this.main, + this.side, + this.sideOpenController, + }); + + final Widget main; + final Widget side; + final ValueNotifier sideOpenController; + + @override + _SlideOutScreenState createState() => _SlideOutScreenState(); +} + +class _SlideOutScreenState extends State + with SingleTickerProviderStateMixin { + static const _clip = 60.0; + + double _screenWidth; + AnimationController _controller; + Animation _position; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + widget.sideOpenController.addListener(() { + if (widget.sideOpenController.value) { + _hideHomeScreen(); + } else { + _showHomeScreen(); + } + }); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _screenWidth = MediaQuery.of(context).size.width; + final offset = _clip - _screenWidth; + + final Animatable fabTween = RelativeRectTween( + // Position in which the HomeScreen is fully visible + begin: RelativeRect.fromLTRB(0.0, 0.0, offset, 0.0), + // Position in which the side details are fully visible + end: RelativeRect.fromLTRB(offset, 0.0, 0.0, 0.0), + ).chain(CurveTween( + curve: Curves.fastOutSlowIn, + )); + + _position = _controller.drive(fabTween); + } + + void _move(DragUpdateDetails details) { + // delta based on the total screen width, kind of works + final double delta = details.primaryDelta / _screenWidth; + // only allow dragging from left to right + if (delta > 0) { + _controller.value -= delta; + } + } + + void _settle(DragEndDetails details) { + // Only allow swiping left to right + if (details.primaryVelocity > 0) { + _showHomeScreen(); + } else { + // If the animation is closer to start + // i.e. the home screen is mostly visible + if (_controller.value < 0.5) { + // animate back to start (home screen open) + _showHomeScreen(); + } else { + // if not, animate to show the side details section + _hideHomeScreen(); + } + } + } + + @override + Widget build(BuildContext context) { + // Stack + PositionedTransition allow for transitioning the child screen + return Stack( + children: [ + PositionedTransition( + rect: _position, + // Takes care of the dragging to close + child: GestureDetector( + onHorizontalDragUpdate: _move, + onHorizontalDragEnd: _settle, + child: Row( + children: [ + _expandOnTap(child: _mainScreen()), + _sideContainer(context), + ], + ), + ), + ), + ], + ); + } + + Widget _sideContainer(BuildContext context) { + return SizedBox( + width: _screenWidth - _clip, + child: Scaffold( + body: widget.side, + ), + ); + } + + Widget _expandOnTap({Widget child}) { + // Rebuilds the GestureDetector on animation changes + // required to reconfigure the GestureDetector behavior and the IgnorePointer + return AnimatedBuilder( + animation: _controller, + builder: (context, widget) { + return GestureDetector( + behavior: + // set as opaque so we can absorb the taps + _isExpanded() + ? HitTestBehavior.deferToChild + : HitTestBehavior.opaque, + onTap: () { + _showHomeScreen(); + }, + // Ignore taps on the HomeScreen when the Side is displayed + child: IgnorePointer( + ignoring: !_isExpanded(), + child: child, + ), + ); + }, + ); + } + + SizedBox _mainScreen() { + // Required since we have a relative size Widget inside (ListView) + return SizedBox( + width: _screenWidth, + child: widget.main, + ); + } + + void _showHomeScreen() { + widget.sideOpenController.value = false; + _controller.animateBack(0.0); + } + + void _hideHomeScreen() { + widget.sideOpenController.value = true; + _controller.forward(); + FocusScope.of(context).requestFocus(FocusNode()); + } + + // True when the HomeScreen is fully visible + bool _isExpanded() => _controller.value == 0.0; +} diff --git a/lib/presentation/image/file_picker_item.dart b/lib/presentation/image/file_picker_item.dart new file mode 100644 index 0000000..f2320d7 --- /dev/null +++ b/lib/presentation/image/file_picker_item.dart @@ -0,0 +1,148 @@ +import "dart:io"; +import "dart:typed_data"; + +import "package:circles_app/native_channels/android_thumbnail_channel.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/cache.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; +import "package:media_picker_builder/data/media_file.dart"; +import "package:media_picker_builder/media_picker_builder.dart"; + +/// Displays a MediaFile +/// +/// To be used in the FilePicker grid. +/// When [selected] it will be grayed out and will display a check on the +/// bottom right corner. +class FilePickerItem extends StatelessWidget { + const FilePickerItem({ + Key key, + this.file, + this.selected, + this.thumbnailCache, + }) : super(key: key); + + final MediaFile file; + final bool selected; + final BasicCache thumbnailCache; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + file.thumbnailPath != null ? _buildThumb(file) : _buildThumbAsync(file), + file.type == MediaType.VIDEO + ? Icon(Icons.play_circle_filled, color: Colors.white, size: 24) + : const SizedBox(), + Visibility( + visible: selected, + child: Container( + color: AppTheme.colorGrey128_50, + padding: EdgeInsets.all(6), + child: Align( + alignment: Alignment.bottomRight, + child: Image.asset( + "assets/graphics/upload/selected.png", + width: 24, + height: 24, + ), + ), + ), + ) + ], + ); + } + + Widget _buildThumb(MediaFile file) { + return RotatedBox( + quarterTurns: Platform.isIOS + ? 0 + : MediaPickerBuilder.orientationToQuarterTurns(file.orientation), + child: Image.file( + File(file.thumbnailPath), + fit: BoxFit.cover, + ), + ); + } + + Widget _buildThumbAsync(MediaFile file) { + if (Platform.isIOS) { + return _buildThumbAsyncIOS(file); + } else if (Platform.isAndroid) { + return _buildThumbAsyncAndroid(file); + } else { + return _errorIcon(); + } + } + + Widget _buildThumbAsyncIOS(MediaFile file) { + return FutureBuilder( + future: MediaPickerBuilder.getThumbnail( + fileId: file.id, + type: file.type, + ), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final thumbnail = snapshot.data; + file.thumbnailPath = thumbnail; + return Image.file( + File(thumbnail), + fit: BoxFit.cover, + ); + } else if (snapshot.hasError) { + Logger.w("Error loading thumbnail for ${file.path}.", + e: snapshot.error); + return _errorIcon(); + } else { + return _loadingIndicator(); + } + }); + } + + Padding _loadingIndicator() { + return Padding( + padding: const EdgeInsets.all(16), + child: CircularProgressIndicator(), + ); + } + + Icon _errorIcon() { + return Icon( + Icons.cancel, + color: Colors.red, + ); + } + + Widget _buildThumbAsyncAndroid(MediaFile file) { + if (thumbnailCache.containsKey(file.id)) { + return Image.memory( + thumbnailCache[file.id], + fit: BoxFit.cover, + ); + } + return FutureBuilder( + future: getThumbnailBitmap( + fileId: file.id, + type: file.type.index, + ), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final thumbnail = snapshot.data; + thumbnailCache[file.id] = thumbnail; + return Image.memory( + thumbnail, + fit: BoxFit.cover, + ); + } else if (snapshot.hasError) { + Logger.w( + "Error loading thumbnail for ${file.id}.", + e: snapshot.error, + ); + return _errorIcon(); + } else { + return _loadingIndicator(); + } + }); + } +} diff --git a/lib/presentation/image/file_picker_screen.dart b/lib/presentation/image/file_picker_screen.dart new file mode 100644 index 0000000..804b5c9 --- /dev/null +++ b/lib/presentation/image/file_picker_screen.dart @@ -0,0 +1,277 @@ +import "dart:io"; +import "dart:typed_data"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/attachment/attachment_actions.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/common/platform_alerts.dart"; +import "package:circles_app/presentation/image/file_picker_item.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/cache.dart"; +import "package:circles_app/util/logger.dart"; +import "package:circles_app/util/permissions.dart"; +import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:image_picker/image_picker.dart"; +import "package:media_picker_builder/data/album.dart"; +import "package:media_picker_builder/data/media_file.dart"; +import "package:media_picker_builder/media_picker_builder.dart"; + +/// Displays a gallery image picker +class FilePickerScreen extends StatefulWidget { + @override + _FilePickerScreenState createState() => _FilePickerScreenState(); +} + +class _FilePickerScreenState extends State { + final files = []; + // On Android, thumbnails are loaded from memory and not from file paths + // we use this simple cache to keep them in memory while the file picker + // is displayed + final thumbnailCache = BasicCache(size: 100); + + // Indexes of picked files + final picked = []; + + // Data loading flag (currently used on iOS only) + var _isLoading = false; + + @override + void initState() { + super.initState(); + _loadFilePaths(); + } + + @override + void dispose() { + super.dispose(); + thumbnailCache.clear(); + } + + /// Load all pictures from the device + /// + /// First will ask the user for permission to read all files, then + /// will load pictures and put them in + /// the [files] array. + _loadFilePaths() { + getStoragePermission().then((gotPermission) { + if (!gotPermission) { + Logger.w("User gave no permission to load photos"); + return []; + } else { + setState(() { + _isLoading = true; + }); + return MediaPickerBuilder.getAlbums( + withImages: true, + withVideos: false, + loadIOSPaths: + false // For iOS users this should never be true since it'll consume way too much memory. + ); + } + }).then((albums) { + _updateMediaFiles(albums); + setState(() { + _isLoading = false; + }); + }); + } + + /// Update [MediaFiles] in [files] + /// + /// Removes all duplicated [MediaFiles] from the [Album]s list + /// then clears [files] and adds all the new files. + void _updateMediaFiles(List albums) { + // Avoid duplicates by path for Android. + // In iOS files are uniquely referenced by their id + final map = {}; + for (final album in albums) { + for (final file in album.files) { + if (Platform.isIOS) { + map[file.id] = file; + } else { + map[file.path] = file; + } + } + } + setState(() { + files.clear(); + files.addAll(map.values); + // Sort by new + files.sort((a, b) => b.dateAdded.compareTo(a.dateAdded)); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CommonAppBar( + title: "All Photos", + ), + body: Column( + children: [ + Expanded( + child: _isLoading + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + child: CircularProgressIndicator( + backgroundColor: AppTheme.colorGrey225, + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + AppTheme.colorGrey155), + )) + ]) + : _buildGridView(), + ), + _buildBottomBar(context) + ], + ), + ); + } + + /// Bottom bar with the camera icon and the send button + Container _buildBottomBar(BuildContext context) { + return Container( + height: 64, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: _buildCameraIcon(context), + ), + _buildSendButton(context), + ], + ), + ); + } + + /// Send button + /// + /// Will be disabled if there are no [picked] files. + /// It will display the count otherwise. + FlatButton _buildSendButton(BuildContext context) { + return FlatButton( + child: Text( + picked.isEmpty ? "Send" : "Send (${picked.length})", + style: AppTheme.buttonTextStyle, + ), + onPressed: picked.isNotEmpty ? () => _sendPictures(context) : null, + ); + } + + /// Action to send pictures + /// + /// Reads all the paths from [picked] and sends them in a Middleware action. + void _sendPictures(BuildContext context) { + final fileIdentifiers = []; + final isPath = !Platform.isIOS; + + for (final index in picked) { + if (!isPath) { + fileIdentifiers.add(files[index].id); + } else { + fileIdentifiers.add(files[index].path); + } + } + StoreProvider.of(context) + .dispatch(NewMessageWithMultipleFilesAction(fileIdentifiers, isPath)); + // Close the file picker + Navigator.pop(context); + } + + /// Camera icon, when clicked launches take and share picture. + /// + /// Taking and sharing a camera picture works like uploading a single image. + IconButton _buildCameraIcon(BuildContext context) { + return IconButton( + icon: Image.asset( + "assets/graphics/input/icon_camera.png", + scale: 3, + ), + onPressed: () async { + final bool cameraPermission = await getCameraPermission(); + if (!cameraPermission) { + await showNoAccessAlert( + context: context, type: AccessResourceType.CAMERA); + return; + } + + final imageFile = + await ImagePicker.pickImage(source: ImageSource.camera); + if (imageFile == null) return; + StoreProvider.of(context).dispatch( + NewMessageWithMultipleFilesAction([imageFile.path], true), + ); + await Navigator.of(context).maybePop(); + }, + ); + } + + /// File picker grid + /// + /// It displays 4 columns. + /// Aspect ratio of items is defined here, as 1:1 (squares). + /// + /// Increases the cacheExtend from 250 which is defined in + /// [RenderAbstractViewport.defaultCacheExtent] to 1000 + /// so the scrolling is nicer + /// + GridView _buildGridView() { + return GridView.builder( + cacheExtent: 1000, + reverse: Platform.isIOS, // Start from bottom on iOS + itemCount: files.length, + itemBuilder: (context, position) => _buildThumbnail(context, position), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 2.0, + crossAxisSpacing: 2.0, + childAspectRatio: 1.0, + ), + ); + } + + /// File picker items + /// + /// Adds an on tap action that selects item in [_selectItem]. + /// Evaluates if an item has been picked or not by checking if the + /// [picked] array contains the item index. + Widget _buildThumbnail(BuildContext context, int position) { + final file = files[position]; + return InkWell( + onTap: () => _selectItem(file, position), + child: FilePickerItem( + key: Key(file.id), + file: file, + selected: picked.contains(position), + thumbnailCache: thumbnailCache, + ), + ); + } + + /// Select item action + /// + /// Adds or removes the file index into the [picked] array. + _selectItem(MediaFile file, int position) { + if (picked.contains(position)) { + setState(() { + picked.remove(position); + }); + } else { + if (picked.length >= 30) { + Scaffold.of(context).hideCurrentSnackBar(); + Scaffold.of(context).showSnackBar(SnackBar( + content: Text("Max. 30 pictures at once"), + )); + return; + } + setState(() { + picked.add(position); + }); + } + } +} diff --git a/lib/presentation/image/image_pinch_screen.dart b/lib/presentation/image/image_pinch_screen.dart new file mode 100644 index 0000000..30c8e76 --- /dev/null +++ b/lib/presentation/image/image_pinch_screen.dart @@ -0,0 +1,24 @@ +import "package:flutter/material.dart"; +import "package:pinch_zoom_image/pinch_zoom_image.dart"; +import "package:transparent_image/transparent_image.dart"; + +class ImagePinchScreen extends StatelessWidget { + + @override + Widget build(BuildContext context) { + final String url = ModalRoute.of(context).settings.arguments; + return SafeArea( + child: GestureDetector( + child: PinchZoomImage( + image: FadeInImage.memoryNetwork( + image: url, + placeholder: kTransparentImage, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ); + } +} diff --git a/lib/presentation/image/image_screen.dart b/lib/presentation/image/image_screen.dart new file mode 100644 index 0000000..c3c5f58 --- /dev/null +++ b/lib/presentation/image/image_screen.dart @@ -0,0 +1,162 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/image/image_with_loader.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:circles_app/util/date_formatting.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class ImageScreen extends StatefulWidget { + @override + _ImageScreenState createState() => _ImageScreenState(); +} + +class _ImageScreenState extends State { + int _selectedImage = 0; + Message _message; + final PageController _pageController = PageController(); + final ScrollController _scrollController = ScrollController(); + final _itemExtent = 48.0 + 8.0; + double _screenWidth = 0; + + @override + void initState() { + super.initState(); + _pageController.addListener(() { + final newSelectedImage = _pageController.page.round(); + if (newSelectedImage != _selectedImage) { + setState(() { + _selectedImage = newSelectedImage; + final double position = _calculateScrollPosition(_selectedImage) + .clamp(0.0, _scrollController.position.maxScrollExtent); + _scrollController.animateTo( + position, + duration: Duration(milliseconds: 200), + curve: Curves.ease, + ); + }); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _message = ModalRoute.of(context).settings.arguments; + _screenWidth = MediaQuery.of(context).size.width; + } + + @override + void dispose() { + super.dispose(); + _pageController.dispose(); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CommonAppBar( + title: _getAuthorName(context), + subtitle: _getMessageTime(context), + ), + body: SafeArea( + child: Column( + children: [ + _buildCurrentSelectedImage(), + _buildBottomBar(), + ], + ), + ), + ); + } + + String _getMessageTime(BuildContext context) => + formatDateShort(context, _message.timestamp) + + ", " + + formatTime(context, _message.timestamp); + + String _getAuthorName(BuildContext context) => + StoreProvider.of(context) + .state + .groupUsers + .firstWhere((u) => u.uid == _message.authorId) + .name; + + Widget _buildCurrentSelectedImage() { + return Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _message.media.length, + itemBuilder: (context, position) { + final url = _message.media[position]; + return InkWell( + child: ImageWithLoader( + url: url, + ), + onTap: () { + return Navigator.pushNamed( + context, + Routes.imagePinch, + arguments: url, + ); + }, + ); + }, + ), + ); + } + + Widget _buildBottomBar() { + return Container( + height: 64, + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: _message.media.length, + itemExtent: _itemExtent, + padding: EdgeInsets.all(8), + shrinkWrap: true, + itemBuilder: (context, position) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(4)), + child: InkWell( + child: Container( + decoration: _selectedImage == position + ? BoxDecoration( + border: Border.all( + color: AppTheme.colorDarkBlueImageSelection, + width: 4, + )) + : null, + child: ImageWithLoader( + url: _message.media[position], + loaderSize: 16.0, + ), + ), + onTap: () { + _pageController.animateToPage( + position, + duration: Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + ), + ), + ); + }, + ), + ); + } + + double _calculateScrollPosition(int selectedImage) { + return (_itemExtent * selectedImage) - + (_screenWidth / 2) + + (_itemExtent / 2); + } +} diff --git a/lib/presentation/image/image_with_loader.dart b/lib/presentation/image/image_with_loader.dart new file mode 100644 index 0000000..b308382 --- /dev/null +++ b/lib/presentation/image/image_with_loader.dart @@ -0,0 +1,48 @@ +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:transparent_image/transparent_image.dart"; + +class ImageWithLoader extends StatelessWidget { + const ImageWithLoader({ + this.url, + this.fit = BoxFit.cover, + this.loaderSize = 48.0, + }); + + final String url; + final BoxFit fit; + final double loaderSize; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + Container( + color: AppTheme.colorGrey241, + child: Center( + child: SizedBox( + width: loaderSize, + height: loaderSize, + child: _buildCircularProgressIndicator(), + ), + ), + ), + FadeInImage.memoryNetwork( + image: url, + fit: fit, + placeholder: kTransparentImage, + ), + ], + ); + } +} + +CircularProgressIndicator _buildCircularProgressIndicator() { + return CircularProgressIndicator( + backgroundColor: AppTheme.colorGrey225, + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(AppTheme.colorGrey155), + ); +} diff --git a/lib/presentation/login/auth_button.dart b/lib/presentation/login/auth_button.dart new file mode 100644 index 0000000..0b8bfd7 --- /dev/null +++ b/lib/presentation/login/auth_button.dart @@ -0,0 +1,29 @@ +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; + +class AuthButton extends StatelessWidget { + final String buttonText; + final Function onPressedCallback; + + const AuthButton({ + @required this.buttonText, + this.onPressedCallback + }); + + @override + Widget build(BuildContext context) { + return RaisedButton( + onPressed: onPressedCallback, + color: Colors.blue, + child: Container( + height: 50.0, + alignment: Alignment.center, + child: Text( + buttonText, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white) + ) + ), + ); + } +} diff --git a/lib/presentation/login/auth_button_container.dart b/lib/presentation/login/auth_button_container.dart new file mode 100644 index 0000000..c6484b8 --- /dev/null +++ b/lib/presentation/login/auth_button_container.dart @@ -0,0 +1,44 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/presentation/login/auth_button.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:redux/redux.dart"; + +class AuthButtonContainer extends StatelessWidget { + const AuthButtonContainer(); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: _ViewModel.fromStore, + builder: (BuildContext context, _ViewModel viewModel) { + return AuthButton( + buttonText: viewModel.isLoggedIn ? CirclesLocalizations.of(context).logOut : CirclesLocalizations.of(context).logIn, + onPressedCallback: viewModel.onPressedCallback + ); + } + ); + } +} + +class _ViewModel { + final bool isLoggedIn; + final Function onPressedCallback; + + _ViewModel(this.isLoggedIn, this.onPressedCallback); + + static _ViewModel fromStore(Store store) { + return _ViewModel( + store.state.user != null, + () { + if (store.state.user != null) { + store.dispatch(LogOutAction()); + } else { + store.dispatch(LogIn()); + } + } + ); + } +} diff --git a/lib/presentation/login/loginscreen.dart b/lib/presentation/login/loginscreen.dart new file mode 100644 index 0000000..8d1deee --- /dev/null +++ b/lib/presentation/login/loginscreen.dart @@ -0,0 +1,115 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/presentation/login/auth_button.dart"; +import "package:circles_app/presentation/settings/privacy_settings_button.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_redux/flutter_redux.dart"; + +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: true, + title: Text("Timy", style: TextStyle(color: Colors.black)), + ), + body: Container( + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(40.0), + child: _LoginForm(), + ), + PrivacySettingsButton(), + ], + ), + ), + ); + } +} + + +// MARK: Login Form + +class _LoginForm extends StatefulWidget { + @override + _LoginFormState createState() { + return _LoginFormState(); + } +} + +class _LoginFormState extends State<_LoginForm> { + final _formKey = GlobalKey(); + final _userTextEditingController = TextEditingController(); + final _passwordTextEditingController = TextEditingController(); + + @override + void dispose() { + // Suggested to be disposed: https://flutter.dev/docs/cookbook/forms/retrieve-input#1-create-a-texteditingcontroller + _userTextEditingController.dispose(); + _passwordTextEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final submitCallback = () { + if (_formKey.currentState.validate()) { + final loginAction = LogIn( + email: _userTextEditingController.text, + password: _passwordTextEditingController.text); + + StoreProvider.of(context).dispatch(loginAction); + Scaffold.of(context) + .showSnackBar(SnackBar(content: Text("Logging you in..."))); + + loginAction.completer.future.catchError((error) { + Scaffold.of(context).hideCurrentSnackBar(); + Logger.w(error.code.toString()); + Scaffold.of(context).showSnackBar(SnackBar( + content: Text(CirclesLocalizations.of(context) + .authErrorMessage(error.code.toString())))); + }); + } + }; + + final submitButton = + AuthButton(buttonText: "Login", onPressedCallback: submitCallback); + + final _userTextField = TextFormField( + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: "Email"), + controller: _userTextEditingController, + validator: (value) { + if (value.isEmpty) { + return "Please enter your email"; + } + return null; + }, + ); + + final _passwordTextField = TextFormField( + decoration: const InputDecoration(labelText: "Password"), + controller: _passwordTextEditingController, + validator: (value) { + if (value.isEmpty) { + return "Please enter your password"; + } + return null; + }, + obscureText: true, + ); + + return Form( + key: _formKey, + child: Column( + children: [_userTextField, _passwordTextField, submitButton], + ), + ); + } +} diff --git a/lib/presentation/settings/privacy_settings_button.dart b/lib/presentation/settings/privacy_settings_button.dart new file mode 100644 index 0000000..ab258fb --- /dev/null +++ b/lib/presentation/settings/privacy_settings_button.dart @@ -0,0 +1,19 @@ +import "package:circles_app/circles_localization.dart"; +import "package:flutter/material.dart"; +import "package:url_launcher/url_launcher.dart"; + +class PrivacySettingsButton extends StatelessWidget { + const PrivacySettingsButton({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FlatButton( + child: Text(CirclesLocalizations.of(context).privacyButton), + onPressed: () { + launch(CirclesLocalizations.of(context).privacyLink); + }, + ); + } +} diff --git a/lib/presentation/settings/settings_screen.dart b/lib/presentation/settings/settings_screen.dart new file mode 100644 index 0000000..b69676d --- /dev/null +++ b/lib/presentation/settings/settings_screen.dart @@ -0,0 +1,20 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/settings/privacy_settings_button.dart"; +import "package:flutter/material.dart"; + +class SettingsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CommonAppBar( + title: CirclesLocalizations.of(context).settingsTitle, + ), + body: ListView( + children: [ + PrivacySettingsButton(), + ], + ), + ); + } +} diff --git a/lib/presentation/user/profile_avatar.dart b/lib/presentation/user/profile_avatar.dart new file mode 100644 index 0000000..215e360 --- /dev/null +++ b/lib/presentation/user/profile_avatar.dart @@ -0,0 +1,48 @@ +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/user/user_avatar.dart"; +import "package:flutter/material.dart"; + +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + Key key, + this.pictureIconButton, + @required this.user, + }) : super(key: key); + + final Widget pictureIconButton; + final User user; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: _Style.avatarSize, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(), + UserAvatar( + user: user, + size: _Style.avatarSize, + ), + Positioned( + bottom: 12, + right: 12, + child: AnimatedSwitcher( + child: pictureIconButton ?? SizedBox.shrink(), + duration: Duration(milliseconds: 200), + ), + ) + ], + ), + ), + ], + ); + } +} + +class _Style { + static const double avatarSize = 200.0; +} diff --git a/lib/presentation/user/rsvp_icon.dart b/lib/presentation/user/rsvp_icon.dart new file mode 100644 index 0000000..93dd995 --- /dev/null +++ b/lib/presentation/user/rsvp_icon.dart @@ -0,0 +1,34 @@ +import "package:circles_app/model/channel.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; + +class RsvpIcon extends StatelessWidget { + final RSVP rsvp; + + const RsvpIcon({ + @required this.rsvp, + }); + + @override + Widget build(BuildContext context) { + final height = 24.0, width = 24.0; + switch (rsvp) { + case RSVP.YES: + return Image.asset( + "assets/graphics/channel/rsvp/rsvp_yes.png", + height: height, + width: width, + ); + case RSVP.MAYBE: + return Image.asset( + "assets/graphics/channel/rsvp/rsvp_maybe.png", + height: height, + width: width, + ); + case RSVP.NO: + case RSVP.UNSET: + default: + return Container(); + } + } +} diff --git a/lib/presentation/user/selected_item.dart b/lib/presentation/user/selected_item.dart new file mode 100644 index 0000000..05c718a --- /dev/null +++ b/lib/presentation/user/selected_item.dart @@ -0,0 +1,26 @@ +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; + +class SelectedItem extends StatelessWidget { + final bool selected; + + const SelectedItem({ + this.selected, + }); + + @override + Widget build(BuildContext context) { + final height = 24.0, width = 24.0; + return (selected + ? Image.asset( + "assets/graphics/input/checkbox_active.png", + height: height, + width: width, + ) + : Image.asset( + "assets/graphics/input/checkbox_inactive.png", + height: height, + width: width, + )); + } +} diff --git a/lib/presentation/user/user_avatar.dart b/lib/presentation/user/user_avatar.dart new file mode 100644 index 0000000..8d07dba --- /dev/null +++ b/lib/presentation/user/user_avatar.dart @@ -0,0 +1,38 @@ +import "package:circles_app/model/user.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:transparent_image/transparent_image.dart"; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + @required this.user, + this.size = AppTheme.avatarSize, + }); + + // user can be null + final User user; + final double size; + + @override + Widget build(BuildContext context) { + if (user?.image == null) { + return Image.asset( + "assets/graphics/avatar_no_picture.png", + height: size, + width: size, + fit: BoxFit.contain, + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: FadeInImage.memoryNetwork( + image: user.image, + height: size, + width: size, + fit: BoxFit.fitHeight, + placeholder: kTransparentImage, + ), + ); + } +} diff --git a/lib/presentation/user/user_item.dart b/lib/presentation/user/user_item.dart new file mode 100644 index 0000000..7ad644d --- /dev/null +++ b/lib/presentation/user/user_item.dart @@ -0,0 +1,136 @@ +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/user/rsvp_icon.dart"; +import "package:circles_app/presentation/user/selected_item.dart"; +import "package:circles_app/routes.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; +import "package:transparent_image/transparent_image.dart"; + +class UserItem extends StatelessWidget { + const UserItem({ + Key key, + @required User user, + bool selected = false, + Function selectionHandler, + RSVP rsvp, + bool isYou = false, + bool isHost = false, + }) : _user = user, + _selected = selected, + _selectionHandler = selectionHandler, + _rsvp = rsvp, + _isYou = isYou, + _isHost = isHost, + super(key: key); + + final User _user; + final bool _selected; + final Function _selectionHandler; + final RSVP _rsvp; + final bool _isYou; + final bool _isHost; + + @override + Widget build(BuildContext context) { + final placeholderImage = Image.asset( + "assets/graphics/avatar_no_picture.png", + height: AppTheme.avatarSize, + width: AppTheme.avatarSize, + ); + + final userName = _isYou ? CirclesLocalizations.of(context).you : _user.name; + final userText = _isHost + ? "$userName · ${CirclesLocalizations.of(context).eventHost}" + : userName; + + Widget _buildImage() { + if (_user.image == null) { + return placeholderImage; + } + + return Container( + child: FadeInImage.memoryNetwork( + image: _user.image, + height: AppTheme.avatarSize, + width: AppTheme.avatarSize, + fit: BoxFit.contain, + placeholder: kTransparentImage, + )); + } + + return Material( + color: Colors.transparent, + child: InkWell( + key: Key("${_user.uid}.InkWell"), + onTap: () { + if (_selectionHandler != null) { + _selectionHandler(_user); + } else { + _openDetails(context, _user); + } + }, + child: Column( + children: [ + Container( + height: 63, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: _buildImage(), + ), + Expanded( + child: Text( + userText, + style: AppTheme.messageAuthorNameTextStyle, + overflow: TextOverflow.ellipsis, + ), + ), + Visibility( + visible: _selectionHandler != null, + child: Padding( + padding: + const EdgeInsets.only(right: AppTheme.appMargin), + child: SelectedItem( + selected: _selected, + ), + ), + ), + Visibility( + visible: _rsvp != null, + child: Padding( + padding: + const EdgeInsets.only(right: AppTheme.appMargin), + child: RsvpIcon( + rsvp: _rsvp, + ), + ), + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: AppTheme.appMargin, + right: AppTheme.appMargin, + ), + child: Container( + height: 1, + color: AppTheme.colorShadow, + ), + ), + ], + )), + ); + } + + void _openDetails(context, User user) { + Navigator.of(context).pushNamed(Routes.user, arguments: user.uid); + } +} diff --git a/lib/presentation/user/user_screen.dart b/lib/presentation/user/user_screen.dart new file mode 100644 index 0000000..819ee7a --- /dev/null +++ b/lib/presentation/user/user_screen.dart @@ -0,0 +1,359 @@ +import "dart:async"; + +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/attachment/attachment_actions.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/presentation/common/common_app_bar.dart"; +import "package:circles_app/presentation/common/error_label_text_form_field.dart"; +import "package:circles_app/presentation/common/modal_item.dart"; +import "package:circles_app/presentation/common/round_button.dart"; +import "package:circles_app/presentation/user/profile_avatar.dart"; +import "package:circles_app/presentation/user/user_screen_viewmodel.dart"; +import "package:circles_app/theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:image_picker/image_picker.dart"; + +class UserScreen extends StatefulWidget { + @override + _UserScreenState createState() => _UserScreenState(); +} + +class _UserScreenState extends State { + bool _editMode = false; + final _controllerName = TextEditingController(); + final _controllerStatus = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + + @override + void dispose() { + super.dispose(); + _controllerName.dispose(); + _controllerStatus.dispose(); + } + + @override + Widget build(BuildContext context) { + final String userId = ModalRoute.of(context).settings.arguments; + + return StoreConnector( + builder: (context, vm) => _buildScaffold(context, vm), + converter: UserScreenViewModel.fromStore(userId), + distinct: true, + onInitialBuild: _setInitialEditState, + ); + } + + void _setInitialEditState(UserScreenViewModel vm) { + _controllerName.text = vm.user.name; + _controllerStatus.text = vm.user.status ?? ""; + } + + Scaffold _buildScaffold(context, UserScreenViewModel vm) { + return Scaffold( + appBar: CommonAppBar( + title: vm.user.name, + leftAction: _buildLeftAction(vm), + action: _buildRightAction(vm), + ), + body: Form( + key: _formKey, + child: ListView( + children: [ + _buildUserAvatar(vm), + ..._buildUserSection(vm), + ..._buildEditSection(vm), + _buildDirectMessageButton(vm, context), + _buildLogoutButton(vm, context), + ], + ), + ), + ); + } + + _buildLeftAction(UserScreenViewModel vm) => + _editMode ? _buildCancelButton(vm) : null; + + Widget _buildCancelButton(UserScreenViewModel vm) { + return FlatButton( + child: Text( + "Cancel", + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _setInitialEditState(vm); + setState(() { + _editMode = false; + }); + }); + } + + Widget _buildRightAction(UserScreenViewModel vm) { + return Visibility( + visible: vm.isYou, + child: AnimatedSwitcher( + child: _editMode ? _buildSaveButton(vm) : _buildEditButton(), + duration: Duration(milliseconds: 200), + ), + ); + } + + FlatButton _buildSaveButton(UserScreenViewModel vm) { + return FlatButton( + child: Text( + CirclesLocalizations.of(context).save, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + _validateAndSubmit(vm); + }); + } + + FlatButton _buildEditButton() { + return FlatButton( + child: Text( + CirclesLocalizations.of(context).edit, + style: AppTheme.buttonTextStyle, + ), + onPressed: () { + setState(() { + _editMode = true; + }); + }); + } + + Widget _buildUserAvatar(UserScreenViewModel vm) { + return Padding( + padding: const EdgeInsets.all(24), + child: ProfileAvatar( + pictureIconButton: _buildPictureIconButton(vm), + user: vm.user, + ), + ); + } + + Widget _buildPictureIconButton(UserScreenViewModel vm) { + return Visibility( + visible: vm.isYou && !_editMode, + child: Material( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(16)), + child: InkWell( + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: ModalItem( + iconAsset: "assets/graphics/input/icon_pictures.png", + label: CirclesLocalizations.of(context).attachModalGallery, + ), + onPressed: () { + _changeUserAvatar(ImageSource.gallery, context, vm); + }, + ), + CupertinoActionSheetAction( + child: ModalItem( + iconAsset: "assets/graphics/input/icon_camera.png", + label: CirclesLocalizations.of(context).attachModalCamera, + ), + onPressed: () { + _changeUserAvatar(ImageSource.camera, context, vm); + }, + ), + CupertinoActionSheetAction( + child: ModalItem( + iconData: Icons.delete_outline, + label: CirclesLocalizations.of(context).delete, + ), + onPressed: () { + _removeUserAvatar(context, vm); + }, + ), + ], + ), + ); + }, + borderRadius: BorderRadius.all(Radius.circular(16)), + child: Container( + height: 50, + width: 50, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Image.asset("assets/graphics/input/icon_camera.png"), + ), + ), + ), + ), + ); + } + + Future _changeUserAvatar( + ImageSource source, + BuildContext context, + UserScreenViewModel vm, + ) async { + // NOTE: The whole avatar upload will be redone once we have the proper UI + final imageFile = await ImagePicker.pickImage(source: source); + StoreProvider.of(context).dispatch(ChangeAvatarAction( + file: imageFile, + user: vm.user, + )); + await Navigator.of(context).maybePop(); + } + + Future _removeUserAvatar( + BuildContext context, + UserScreenViewModel vm, + ) async { + StoreProvider.of(context).dispatch( + UpdateUserAction(vm.user.rebuild((u) => u..image = null), Completer())); + await Navigator.of(context).maybePop(); + } + + List _buildUserSection(UserScreenViewModel vm) { + if (_editMode) return []; + return [ + Text( + vm.user.name, + style: AppTheme.messageAuthorNameTextStyle, + textAlign: TextAlign.center, + ), + Padding( + padding: const EdgeInsets.only(top: AppTheme.appMargin), + child: Text( + vm.user.status ?? "", + style: AppTheme.messageTextStyle, + textAlign: TextAlign.center, + ), + ), + ]; + } + + List _buildEditSection(UserScreenViewModel vm) { + if (!_editMode) return []; + return [ + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: ErrorLabelTextFormField( + labelText: CirclesLocalizations.of(context).userEditNameLabel, + helperText: CirclesLocalizations.of(context).userEditNameHelper, + maxCharacters: 30, + controller: _controllerName, + validator: (value) { + if (value.isEmpty) { + return CirclesLocalizations.of(context).userEditNameError; + } + return null; + }, + ), + ), + Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: ErrorLabelTextFormField( + labelText: CirclesLocalizations.of(context).userEditStatusLabel, + helperText: CirclesLocalizations.of(context).userEditStatusHelper, + controller: _controllerStatus, + maxCharacters: 200, + validator: (value) { + return null; + }, + ), + ) + ]; + } + + Visibility _buildDirectMessageButton(UserScreenViewModel vm, context) { + return Visibility( + visible: !vm.isYou, + child: Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Center( + child: RoundButton( + text: CirclesLocalizations.of(context).sendDirectMessage, + onTap: () { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text( + CirclesLocalizations.of(context).genericSoonAlertTitle), + content: Text( + CirclesLocalizations.of(context).genericSoonAlertMessage), + actions: [ + PlatformDialogAction( + child: PlatformText( + CirclesLocalizations.of(context).cancel), + onPressed: () { + Navigator.pop(context); + }) + ], + ), + ); + }, + ), + ), + ), + ); + } + + Widget _buildLogoutButton(UserScreenViewModel vm, context) { + return Visibility( + visible: vm.isYou && !_editMode, + child: Padding( + padding: const EdgeInsets.all(AppTheme.appMargin), + child: Center( + child: RoundButton( + text: "Logout", + onTap: () { + showPlatformDialog( + context: context, + builder: (context) => PlatformAlertDialog( + content: Text(CirclesLocalizations.of(context).logOut), + actions: [ + PlatformDialogAction( + child: + PlatformText(CirclesLocalizations.of(context).cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + PlatformDialogAction( + child: + PlatformText(CirclesLocalizations.of(context).logOut), + onPressed: () { + StoreProvider.of(context) + .dispatch(LogOutAction()); + Navigator.pop(context); + }, + ) + ], + ), + ); + }, + ), + ), + ), + ); + } + + void _validateAndSubmit(UserScreenViewModel vm) { + if (_formKey.currentState.validate()) { + final completer = Completer(); + completer.future.whenComplete(() { + setState(() { + _editMode = false; + }); + }); + vm.submit( + vm.user.rebuild((u) => u + ..name = _controllerName.text + ..status = _controllerStatus.text), + completer); + } + } +} diff --git a/lib/presentation/user/user_screen_viewmodel.dart b/lib/presentation/user/user_screen_viewmodel.dart new file mode 100644 index 0000000..457d6e2 --- /dev/null +++ b/lib/presentation/user/user_screen_viewmodel.dart @@ -0,0 +1,42 @@ +import "dart:async"; + +import "package:built_value/built_value.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; + +// ignore: prefer_double_quotes +part 'user_screen_viewmodel.g.dart'; + +abstract class UserScreenViewModel + implements Built { + User get user; + + bool get isYou; + + @BuiltValueField(compare: false) + void Function(User user, Completer completer) get submit; + + UserScreenViewModel._(); + + factory UserScreenViewModel( + [void Function(UserScreenViewModelBuilder) updates]) = + _$UserScreenViewModel; + + static fromStore(String userId) { + return (Store store) { + return UserScreenViewModel((u) => u + ..user = _getUser(store, userId) + ..isYou = userId == store.state.user.uid + ..submit = (user, completer) => + store.dispatch(UpdateUserAction(user, completer))); + }; + } + + static UserBuilder _getUser(Store store, String userId) { + return store.state.groupUsers + .firstWhere((user) => user.uid == userId) + .toBuilder(); + } +} diff --git a/lib/presentation/user/user_screen_viewmodel.g.dart b/lib/presentation/user/user_screen_viewmodel.g.dart new file mode 100644 index 0000000..d99debc --- /dev/null +++ b/lib/presentation/user/user_screen_viewmodel.g.dart @@ -0,0 +1,130 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_screen_viewmodel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$UserScreenViewModel extends UserScreenViewModel { + @override + final User user; + @override + final bool isYou; + @override + final void Function(User user, Completer completer) submit; + + factory _$UserScreenViewModel( + [void Function(UserScreenViewModelBuilder) updates]) => + (new UserScreenViewModelBuilder()..update(updates)).build(); + + _$UserScreenViewModel._({this.user, this.isYou, this.submit}) : super._() { + if (user == null) { + throw new BuiltValueNullFieldError('UserScreenViewModel', 'user'); + } + if (isYou == null) { + throw new BuiltValueNullFieldError('UserScreenViewModel', 'isYou'); + } + if (submit == null) { + throw new BuiltValueNullFieldError('UserScreenViewModel', 'submit'); + } + } + + @override + UserScreenViewModel rebuild( + void Function(UserScreenViewModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UserScreenViewModelBuilder toBuilder() => + new UserScreenViewModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is UserScreenViewModel && + user == other.user && + isYou == other.isYou; + } + + @override + int get hashCode { + return $jf($jc($jc(0, user.hashCode), isYou.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('UserScreenViewModel') + ..add('user', user) + ..add('isYou', isYou) + ..add('submit', submit)) + .toString(); + } +} + +class UserScreenViewModelBuilder + implements Builder { + _$UserScreenViewModel _$v; + + UserBuilder _user; + UserBuilder get user => _$this._user ??= new UserBuilder(); + set user(UserBuilder user) => _$this._user = user; + + bool _isYou; + bool get isYou => _$this._isYou; + set isYou(bool isYou) => _$this._isYou = isYou; + + void Function(User user, Completer completer) _submit; + void Function(User user, Completer completer) get submit => _$this._submit; + set submit(void Function(User user, Completer completer) submit) => + _$this._submit = submit; + + UserScreenViewModelBuilder(); + + UserScreenViewModelBuilder get _$this { + if (_$v != null) { + _user = _$v.user?.toBuilder(); + _isYou = _$v.isYou; + _submit = _$v.submit; + _$v = null; + } + return this; + } + + @override + void replace(UserScreenViewModel other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$UserScreenViewModel; + } + + @override + void update(void Function(UserScreenViewModelBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$UserScreenViewModel build() { + _$UserScreenViewModel _$result; + try { + _$result = _$v ?? + new _$UserScreenViewModel._( + user: user.build(), isYou: isYou, submit: submit); + } catch (_) { + String _$failedField; + try { + _$failedField = 'user'; + user.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'UserScreenViewModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/lib/routes.dart b/lib/routes.dart new file mode 100644 index 0000000..9321bfd --- /dev/null +++ b/lib/routes.dart @@ -0,0 +1,13 @@ +class Routes { + static final home = "/"; + static final login = "/login"; + static final channelNew = "/channel/new"; + static final channelInvite = "/channel/invite"; + static final eventNew = "/event/new"; + static final image = "/image"; + static final imagePinch = "/image/pinch"; + static final imagePicker = "/image/picker"; + static final reaction = "/reaction"; + static final user = "/user"; + static final settings = "/settings"; +} \ No newline at end of file diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..3dafdab --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,459 @@ +import "package:circles_app/util/HexColor.dart"; +import "package:flutter/material.dart"; + +class AppTheme { + static const pixelMultiplier = 1.0; + static const appMargin = 12.0 * pixelMultiplier; + static const avatarSize = 36.0 * pixelMultiplier; + static const appBarSize = 64.0; + + static const fontFamilyPoppinsExtraBold = "Poppins-ExtraBold"; + static const fontFamilyPoppinsRegular = "Poppins-Regular"; + static const fontFamilyEdmondsansRegular = "Edmondsans-Regular"; + static const fontFamilyEdmondsansMedium = "Edmondsans-Medium"; + static const fontFamilyEdmondsansBold = "Edmondsans-Bold"; + + static const colorDarkBlue = Color.fromRGBO(23, 38, 157, 1.0); + static const colorDarkBlueFont = Color.fromRGBO(4, 24, 138, 1.0); + static final colorDarkBlueImageSelection = HexColor("#04188A"); + static const colorDarkGreen = Color.fromRGBO(33, 127, 125, 1.0); + static const colorLightGreen = Color.fromRGBO(207, 244, 234, 1.0); + static const colorMintGreen = Color.fromRGBO(54, 207, 166, 1.0); + static const colorRed = Color.fromRGBO(255, 72, 103, 1.0); + static const colorShadow = Color.fromRGBO(204, 204, 204, 1.0); + static const colorTextDisabled = Color.fromRGBO(153, 153, 153, 1.0); + static const colorTextEnabled = Color.fromRGBO(0, 0, 0, 1.0); + static const colorTextLink = Color.fromRGBO(74, 144, 226, 1.0); + static const colorGrey128 = Color.fromRGBO(128, 128, 128, 1.0); + static const colorGrey128_25 = Color.fromRGBO(128, 128, 128, 0.25); + static const colorGrey128_50 = Color.fromRGBO(128, 128, 128, 0.5); + static const colorGrey155 = Color.fromRGBO(155, 155, 155, 1.0); + static const colorGrey225 = Color.fromRGBO(225, 225, 225, 1.0); + static const colorGrey241 = Color.fromRGBO(241, 241, 241, 1.0); + static final colorWhite_50 = Colors.white.withOpacity(0.5); + + static ThemeData get theme { + return ThemeData.light().copyWith( + primaryColor: Colors.black, + accentColor: Colors.black, + ); + } + + /// Calendar + + static TextStyle get calendarDayTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlue, + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get calendarListEventName { + return theme.textTheme.headline.copyWith( + color: colorDarkBlue, + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + ); + } + + static TextStyle get calendarListGroupName { + return theme.textTheme.headline.copyWith( + color: colorDarkBlue.withAlpha(150), + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + ); + } + + static TextStyle get calendarListTime { + return theme.textTheme.headline.copyWith( + color: Colors.white, + fontSize: 12, + fontFamily: fontFamilyEdmondsansMedium, + ); + } + + /// + + static TextStyle get eventIconMemberTitle { + return theme.textTheme.headline.copyWith( + color: Colors.white, + fontSize: 11, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get eventIconTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlueFont, + fontSize: 11, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get eventIconMemberSubTitle { + return theme.textTheme.headline.copyWith( + color: Colors.white, + fontSize: 8, + height: 0.7, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get eventIconSubTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlueFont, + fontSize: 8, + height: 0.7, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get channelTitle { + return theme.textTheme.headline.copyWith( + color: Colors.black, + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get channelSubTitle { + return TextStyle( + color: AppTheme.colorTextDisabled, + fontSize: 16, + fontFamily: fontFamilyEdmondsansRegular, + ); + } + + static get circleMenuAbbreviationText { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextEnabled, + ); + } + + static TextStyle get circleTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlueFont, + fontSize: 24, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get circleSectionButtonTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlueFont, + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get circleSectionChannelTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlue, + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + ); + } + + static TextStyle get circleSectionTitle { + return theme.textTheme.headline.copyWith( + color: colorDarkBlueFont.withOpacity(0.4), + fontSize: 12, + letterSpacing: 1, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get notificationTitle { + return theme.textTheme.body1.copyWith( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800, + fontFamily: fontFamilyPoppinsExtraBold, + ); + } + + static TextStyle get notificationTime { + return theme.textTheme.body1.copyWith( + color: Colors.white, + fontSize: 12, + fontFamily: fontFamilyEdmondsansRegular, + ); + } + + static TextStyle get notificationBody { + return theme.textTheme.body1.copyWith( + color: Colors.white, + fontSize: 16, + fontFamily: fontFamilyEdmondsansRegular, + ); + } + + static TextStyle get buttonTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansBold, + ); + } + + static TextStyle get buttonMediumTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansMedium, + ); + } + + static TextStyle get inputHintTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextDisabled, + ); + } + + static TextStyle get inputTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextEnabled, + ); + } + + static TextStyle get optionTextStyle { + return TextStyle( + fontSize: 20, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextEnabled, + ); + } + + static TextStyle get messageAuthorNameTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextEnabled, + ); + } + + static TextStyle get messageTimestampTextStyle { + return TextStyle( + fontSize: 12 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextDisabled, + ); + } + + static TextStyle get messageTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + height: 1.5, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextEnabled, + ); + } + + static TextStyle get linkTextStyle { + return TextStyle( + fontSize: 16 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextLink, + ); + } + + static get systemMessageTextStyle { + return TextStyle( + fontSize: 12 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextDisabled, + ); + } + + static get emojiReactionTextStyle { + return TextStyle( + fontSize: 12 * pixelMultiplier, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextEnabled, + ); + } + + static TextStyle get appBarTitleTextStyle { + return TextStyle( + fontSize: 20, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextEnabled, + ); + } + + static TextStyle get appBarTitle2TextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextEnabled, + ); + } + + static TextStyle get appBarSubtitleTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextEnabled, + ); + } + + static TextStyle get appBarActionTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextEnabled, + ); + } + + static TextStyle get appBarActionDisabledTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextDisabled, + ); + } + + static TextStyle get inputMediumTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextEnabled, + ); + } + + static TextStyle get switchTitleTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + color: colorTextEnabled, + ); + } + + static TextStyle get switchSubtitleTextStyle { + return TextStyle( + fontSize: 12, + fontFamily: fontFamilyEdmondsansMedium, + color: colorGrey128, + ); + } + + static TextStyle get topicDetailsNameTextStyle { + return TextStyle( + fontSize: 24, + fontFamily: fontFamilyEdmondsansBold, + color: colorDarkBlueFont, + ); + } + + static TextStyle get topicDetailsDescriptionTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansRegular, + color: colorDarkBlueFont, + ); + } + + static TextStyle get topicDetailsTabTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + color: colorDarkBlueFont, + ); + } + + static TextStyle get topicDetailsItemTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansBold, + color: colorTextEnabled, + ); + } + + static TextStyle get topicDetailsItemSubtitleTextStyle { + return TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansRegular, + color: colorTextEnabled, + ); + } + + static TextStyle get dialogRsvpTextStyle { + return TextStyle( + fontFamily: AppTheme.fontFamilyEdmondsansBold, + fontSize: 16, + ); + } + + static TextStyle get plusManyPicturesTextStyle { + return TextStyle( + fontFamily: AppTheme.fontFamilyPoppinsRegular, + fontSize: 32, + ); + } + + static TextStyle get dialogRsvpYesTextStyle { + return dialogRsvpTextStyle.apply(color: AppTheme.colorMintGreen); + } + + static TextStyle get dialogRsvpMaybeTextStyle { + return dialogRsvpTextStyle.apply(color: AppTheme.colorDarkBlue); + } + + static TextStyle get dialogRsvpNoTextStyle { + return dialogRsvpTextStyle.apply(color: AppTheme.colorRed); + } + + static InputDecorationTheme get inputDecorationEmptyTheme { + return _inputDecorationTheme( + baseColor: colorGrey128, + ); + } + + static InputDecorationTheme get inputDecorationFilledTheme { + return _inputDecorationTheme( + baseColor: colorDarkBlueFont, + ); + } + + static InputDecorationTheme get inputDecorationErrorTheme { + return _inputDecorationTheme( + baseColor: colorRed, + ); + } + + static InputDecorationTheme _inputDecorationTheme({ + baseColor: Color, + textColor: Color, + }) { + return InputDecorationTheme( + border: OutlineInputBorder(borderSide: BorderSide(color: baseColor)), + enabledBorder: + OutlineInputBorder(borderSide: BorderSide(color: baseColor)), + focusedBorder: + OutlineInputBorder(borderSide: BorderSide(color: baseColor)), + errorBorder: OutlineInputBorder(borderSide: BorderSide(color: colorRed)), + errorStyle: TextStyle( + fontSize: 12, + fontFamily: fontFamilyEdmondsansRegular, + color: colorRed, + ), + focusedErrorBorder: + OutlineInputBorder(borderSide: BorderSide(color: colorRed)), + labelStyle: TextStyle( + fontSize: 16, + fontFamily: fontFamilyEdmondsansMedium, + color: baseColor, + ), + helperStyle: TextStyle( + fontSize: 12, + fontFamily: fontFamilyEdmondsansRegular, + color: colorGrey128, + ), + ); + } +} diff --git a/lib/util/HexColor.dart b/lib/util/HexColor.dart new file mode 100644 index 0000000..8aca567 --- /dev/null +++ b/lib/util/HexColor.dart @@ -0,0 +1,13 @@ +import "dart:ui"; + +class HexColor extends Color { + static int _getColorFromHex(String hexColor) { + hexColor = hexColor.toUpperCase().replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF" + hexColor; + } + return int.parse(hexColor, radix: 16); + } + + HexColor(final String hexColor) : super(_getColorFromHex(hexColor)); +} \ No newline at end of file diff --git a/lib/util/cache.dart b/lib/util/cache.dart new file mode 100644 index 0000000..b4a1f3d --- /dev/null +++ b/lib/util/cache.dart @@ -0,0 +1,50 @@ +import "dart:collection"; + +/// Basic Cache based on first-in-first-out +/// +/// When the cache gets full (determined by the [size]) it will start +/// removing the older values. +/// +/// Uses a simple [Queue] to store keys, and start removing the first ones +/// in a "first in-first out" manner, until the queue is smaller than the max +/// [size]. +/// +/// This cache can be used for storing in memory thumbnails and similar. +/// +/// Call to [clear] when no longer used. +/// +class BasicCache { + BasicCache({ + this.size, + }); + + final int size; + final _map = Map(); + final _queue = Queue(); + + bool containsKey(K key) { + return _map.containsKey(key); + } + + V operator [](K key) { + return _map[key]; + } + + void operator []=(K key, V value) { + _map[key] = value; + _queue.add(key); + _deleteOldValues(); + } + + void clear() { + _map.clear(); + _queue.clear(); + } + + void _deleteOldValues() { + while (_queue.length > size) { + final key = _queue.removeFirst(); + _map.remove(key); + } + } +} diff --git a/lib/util/date_formatting.dart b/lib/util/date_formatting.dart new file mode 100644 index 0000000..9071d8f --- /dev/null +++ b/lib/util/date_formatting.dart @@ -0,0 +1,50 @@ +import "package:circles_app/util/logger.dart"; +import "package:flutter/widgets.dart"; +import "package:intl/intl.dart"; + +String formatTime(BuildContext context, DateTime date) { + try { + DateFormat dateFormat; + if (MediaQuery.of(context).alwaysUse24HourFormat) { + dateFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); + } else { + dateFormat = DateFormat.jm(Localizations.localeOf(context).languageCode); + } + return dateFormat.format(date); + } catch (error) { + Logger.e("Error with time format: $error", e: error, s: StackTrace.current); + return ""; + } +} + +String formatDate(BuildContext context, DateTime date) { + try { + final datePattern = "EEE, MMM d"; + final dateFormat = DateFormat(datePattern, Localizations.localeOf(context).languageCode); + return dateFormat.format(date); + } catch (error) { + Logger.e("Error with date format: $error", e: error, s: StackTrace.current); + return ""; + } +} + +String formatDateShort(BuildContext context, DateTime date) { + try { + final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); + return dateFormat.format(date); + } catch (error) { + Logger.e("Error with date format: $error", e: error, s: StackTrace.current); + return ""; + } +} + +String formatCalendarDate(BuildContext context, DateTime date) { + try { + final datePattern = "EEEE d. MMM"; + final dateFormat = DateFormat(datePattern, Localizations.localeOf(context).languageCode); + return dateFormat.format(date); + } catch (error) { + Logger.e("Error with calendar date format: $error", e: error, s: StackTrace.current); + return ""; + } +} \ No newline at end of file diff --git a/lib/util/logger.dart b/lib/util/logger.dart new file mode 100644 index 0000000..cc8e5e3 --- /dev/null +++ b/lib/util/logger.dart @@ -0,0 +1,174 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:redux/redux.dart"; +import "package:intl/intl.dart"; +import "package:firebase_crashlytics/firebase_crashlytics.dart"; + +/// Run this before starting app +configureLogger() { + if (!kReleaseMode) { + // Add standard log output only on debug builds + Logger.addClient(DebugLoggerClient()); + } else { + // Pass all uncaught errors from the framework to Crashlytics. + FlutterError.onError = Crashlytics.instance.recordFlutterError; + Logger.addClient(CrashlyticsLoggerClient()); + } +} + +testsLogger() { + Logger.addClient(DebugLoggerClient()); +} + +class Logger { + static final _clients = []; + + /// Debug level logs + static d( + String message, { + dynamic e, + StackTrace s, + }) { + _clients.forEach((c) => c.onLog( + level: LogLevel.debug, + message: message, + e: e, + s: s, + )); + } + + // Warning level logs + static w( + String message, { + dynamic e, + StackTrace s, + }) { + _clients.forEach((c) => c.onLog( + level: LogLevel.warning, + message: message, + e: e, + s: s, + )); + } + + /// Error level logs + /// Requires a current StackTrace to report correctly on Crashlytics + /// Always reports as non-fatal to Crashlytics + static e( + String message, { + dynamic e, + @required StackTrace s, + }) { + _clients.forEach((c) => c.onLog( + level: LogLevel.error, + message: message, + e: e, + s: s, + )); + } + + static addClient(LoggerClient client) { + _clients.add(client); + } +} + +/// Custom Middleware logger class +class LoggerMiddleware implements MiddlewareClass { + @override + void call(Store store, action, NextDispatcher next) { + next(action); + + Logger.d("Middleware: { ${action.runtimeType} }"); + } +} + +enum LogLevel { debug, warning, error } + +abstract class LoggerClient { + onLog({ + LogLevel level, + String message, + dynamic e, + StackTrace s, + }); +} + +/// Debug logger that just prints to console +class DebugLoggerClient implements LoggerClient { + static final dateFormat = DateFormat("HH:mm:ss.SSS"); + + String _timestamp() { + return dateFormat.format(DateTime.now()); + } + + @override + onLog({ + LogLevel level, + String message, + dynamic e, + StackTrace s, + }) { + switch (level) { + case LogLevel.debug: + debugPrint("${_timestamp()} [DEBUG] $message"); + if (e != null) { + debugPrint(e.toString()); + debugPrint(s.toString() ?? StackTrace.current); + } + break; + case LogLevel.warning: + debugPrint("${_timestamp()} [WARNING] $message"); + if (e != null) { + debugPrint(e.toString()); + debugPrint(s.toString() ?? StackTrace.current.toString()); + } + break; + case LogLevel.error: + debugPrint("${_timestamp()} [ERROR] $message"); + if (e != null) { + debugPrint(e.toString()); + } + // Errors always show a StackTrace + debugPrint(s.toString() ?? StackTrace.current.toString()); + break; + } + } +} + +/// Logger that reports to Crashlytics/Firebase +class CrashlyticsLoggerClient implements LoggerClient { + @override + onLog({ + LogLevel level, + String message, + dynamic e, + StackTrace s, + }) { + final instance = Crashlytics.instance; + switch (level) { + case LogLevel.debug: + instance.log("[DEBUG] $message"); + if (e != null) { + instance.log(e.toString()); + instance.log(s ?? StackTrace.current.toString()); + } + break; + case LogLevel.warning: + instance.log("[WARNING] $message"); + if (e != null) { + instance.log(e.toString()); + instance.log(s ?? StackTrace.current.toString()); + } + break; + case LogLevel.error: + instance.log("[ERROR] $message"); + // Always report a non-fatal for errors + if (e != null) { + instance.recordError(e, s); + } else { + instance.recordError(Exception(message), s); + } + break; + } + } +} diff --git a/lib/util/permissions.dart b/lib/util/permissions.dart new file mode 100644 index 0000000..d082fbb --- /dev/null +++ b/lib/util/permissions.dart @@ -0,0 +1,52 @@ +import "dart:io"; + +import "package:circles_app/native_channels/android_permission_channel.dart"; +import "package:circles_app/native_channels/ios_permission_channel.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/services.dart"; + +const MethodChannel channel = MethodChannel("de.janoodle.timy/permission"); + +/// Obtain the status for storage/photos permissions +/// +/// If the app has no permission, it will request it. +/// Uses [PermissionType.Photos] for iOS and Android. +/// +/// For other platforms always returns false. +Future getStoragePermission() async { + if (Platform.isIOS) { + final status = await IOSPermissionChannel.requestPermission( + permissionType: PermissionType.Photos); + Logger.d("Photo permission status: $status"); + return (status == "AUTHORIZED") ? true : false; + } else if (Platform.isAndroid) { + final result = await AndroidPermissionChannel.requestPermission( + permissionType: PermissionType.Photos); + Logger.d("Photo permission status: $result"); + return result; + } + Logger.e("Invalid platform", s: StackTrace.current); + return false; +} + +/// Requests camera permission +/// +/// If the app has no permission, it will request it. +/// Uses [PermissionType.Camera] for iOS. +/// +/// Android always returns true. +/// +/// For other platforms always returns false. +Future getCameraPermission() async { + if (Platform.isIOS) { + final status = await IOSPermissionChannel.requestPermission( + permissionType: PermissionType.Camera); + Logger.w("Photo permission status: $status"); + return (status == "AUTHORIZED") ? true : false; + } else if (Platform.isAndroid) { + // Android does not need permissions to use camera as "Intent" + return true; + } + Logger.e("Invalid platform", s: StackTrace.current); + return false; +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..5c76ad6 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,730 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.36.4" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.2" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1+1" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.7" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + built_collection: + dependency: "direct main" + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.2" + built_value: + dependency: "direct main" + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "6.7.1" + built_value_generator: + dependency: "direct dev" + description: + name: built_value_generator + url: "https://pub.dartlang.org" + source: hosted + version: "6.7.1" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.9+4" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.11" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.9" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.2" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0+9" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+3" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.5" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.9" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.3" + flutter_linkify: + dependency: "direct main" + description: + name: flutter_linkify + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_image: + dependency: "direct main" + description: + path: "." + ref: "fix/ios_compression" + resolved-ref: e8aec353fb23186ac5858ef1263e4e2b1a358ff0 + url: "https://github.com/btastic/flutter_native_image.git" + source: git + version: "0.0.4" + flutter_platform_widgets: + dependency: "direct main" + description: + name: flutter_platform_widgets + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0" + flutter_redux: + dependency: "direct main" + description: + name: flutter_redux + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + front_end: + dependency: transitive + description: + name: front_end + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.19" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.7" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.7" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+2" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0+2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.1+4" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.8" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.1+1" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + kernel: + dependency: transitive + description: + name: kernel + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.19" + linkify: + dependency: transitive + description: + name: linkify + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.3+2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.5" + media_picker_builder: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "2b02662ebecabd5ef82a3f9defe41749656219fe" + url: "git://github.com/janoodleFTW/media_picker_builder.git" + source: git + version: "1.2.0+1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.7" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.6+3" + mockito: + dependency: "direct dev" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.1" + multi_server_socket: + dependency: transitive + description: + name: multi_server_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.8" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + package_resolver: + dependency: transitive + description: + name: package_resolver + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.10" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.4" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0+1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" + pinch_zoom_image: + dependency: "direct main" + description: + name: pinch_zoom_image + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.5" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.2" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + redux: + dependency: "direct main" + description: + name: redux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.5" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.8" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" + sky_engine: + dependency: transitive + 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.4+4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.5" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.8" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.5" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + 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: "0.0.19" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test: + dependency: "direct dev" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.3" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.5" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.5" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.3" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + vm_service_client: + dependency: transitive + description: + name: vm_service_client + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.6+3" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+12" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.15" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.16" +sdks: + dart: ">=2.4.0 <3.0.0" + flutter: ">=1.5.0 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d5f0cb7 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,148 @@ +name: circles_app +description: Timy group messaging app + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning + +version: 1.0.23+1 + +environment: + sdk: ">=2.2.2 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + # Firebase dependencies + firebase_core: ^0.4.0 + firebase_analytics: ^5.0.0 + cloud_firestore: ^0.12.9 + firebase_auth: ^0.14.0 + google_sign_in: ^4.0.1 # Firebase auth dependency + firebase_storage: ^3.0.5 + firebase_messaging: ^5.1.3 + firebase_crashlytics: ^0.1.0+2 + + # Redux dependencies + redux: ^3.0.0 + flutter_redux: ^0.5.0 + + built_value: ^6.6.0 + built_collection: ^4.2.2 + + image_picker: ^0.6.0+15 + transparent_image: ^1.0.0 + + # We fixed a bug in the plugin + media_picker_builder: + git: + url: git://github.com/janoodleFTW/media_picker_builder.git + ref: master + + flutter_native_image: + git: + url: https://github.com/btastic/flutter_native_image.git + ref: fix/ios_compression + + pinch_zoom_image: ^0.2.5 + flutter_platform_widgets: ^0.12.0 + + flutter_linkify: ^2.1.0 + url_launcher: ^5.0.3 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^0.1.2 + + flutter_localizations: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + + build_runner: ^1.5.2 + built_value_generator: ^6.6.0 + + test: ^1.6.1 + mockito: ^4.1.0 + + flutter_launcher_icons: "^0.7.2" + +flutter_icons: + android: true + ios: true + image_path: "assets/icon/icon.png" + adaptive_icon_foreground: "assets/icon/icon.png" + adaptive_icon_background: "#000000" + +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/placeholder/user_image_placeholder.png + - assets/graphics/avatar_no_picture.png + - assets/graphics/visual_twist.png + - assets/graphics/visual_twist_white_petrol.png + - assets/graphics/icon_notification.png + - assets/graphics/icon_smile.png + - assets/graphics/updates_indicator.png + - assets/graphics/updates_indicator_white.png + - assets/graphics/update_indicator_darkgreen.png + - assets/graphics/channel/create_new_channel.png + - assets/graphics/channel/topic_open.png + - assets/graphics/channel/topic_joined.png + - assets/graphics/channel/padlock.png + - assets/graphics/channel/details_date.png + - assets/graphics/channel/details_location.png + - assets/graphics/channel/details_padlock.png + - assets/graphics/channel/details_members.png + - assets/graphics/channel/event_open.png + - assets/graphics/channel/event_joined.png + - assets/graphics/channel/rsvp/rsvp_maybe.png + - assets/graphics/channel/rsvp/rsvp_maybe_large.png + - assets/graphics/channel/rsvp/rsvp_no_large.png + - assets/graphics/channel/rsvp/rsvp_yes.png + - assets/graphics/channel/rsvp/rsvp_yes_large.png + - assets/graphics/channel/header_calendar_icon.png + - assets/graphics/input/checkbox_active.png + - assets/graphics/input/checkbox_inactive.png + - assets/graphics/input/icon_add_content.png + - assets/graphics/input/icon_camera.png + - assets/graphics/input/icon_pictures.png + - assets/graphics/menu_icon.png + - assets/graphics/menu_more_icon.png + - assets/graphics/drawer/settings.png + - assets/graphics/drawer/events.png + - assets/graphics/drawer/direct_message.png + - assets/graphics/drawer/create_topic.png + - assets/graphics/drawer/account.png + - assets/graphics/upload/indicator_0_try_again.png + - assets/graphics/upload/selected.png + - assets/graphics/calendar/calendar_today.png + + fonts: + - family: Edmondsans-Regular + fonts: + - asset: fonts/Edmondsans-Regular.otf + - family: Edmondsans-Medium + fonts: + - asset: fonts/Edmondsans-Medium.otf + - family: Edmondsans-Bold + fonts: + - asset: fonts/Edmondsans-Bold.otf + - family: Poppins-ExtraBold + fonts: + - asset: fonts/Poppins-ExtraBold.ttf + - family: Poppins-Regular + fonts: + - asset: fonts/Poppins-Regular.ttf diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb new file mode 100644 index 0000000..e69de29 diff --git a/test/data/FirestoreMocks.dart b/test/data/FirestoreMocks.dart new file mode 100644 index 0000000..511f85e --- /dev/null +++ b/test/data/FirestoreMocks.dart @@ -0,0 +1,6 @@ +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:mockito/mockito.dart"; + +class MockDocumentSnapshot extends Mock implements DocumentSnapshot {} + +class MockSnapshotMetadata extends Mock implements SnapshotMetadata {} diff --git a/test/data/channel_repository_test.dart b/test/data/channel_repository_test.dart new file mode 100644 index 0000000..c12a597 --- /dev/null +++ b/test/data/channel_repository_test.dart @@ -0,0 +1,91 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/data/channel_repository.dart"; +import "package:circles_app/model/channel.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/mockito.dart"; + +import "FirestoreMocks.dart"; + +main() { + group("Channels Repository", () { + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..id = "ID" + ..name = "CHANNEL" + ..description = "DESC" + ..visibility = ChannelVisibility.OPEN + ..users = ListBuilder() + ..hasUpdates = false); + + test("shoul map invite", () async { + final channelUser = ChannelUser((cu) => cu + ..id = "CUID" + ..rsvp = RSVP.UNSET); + final inviteMap = ChannelRepository.toChannelUserInviteMap( + user: channelUser, + channel: channel, + invitingUsername: "ANDY PIPKIN", + groupName: "TEST GROUP"); + + expect(inviteMap, { + "uid": "CUID", + "invitation": true, + "rsvp": "UNSET", + "metadata": { + "channel_name": "CHANNEL", + "visibility": "OPEN", + "type": "TOPIC", + "group_name": "TEST GROUP", + "inviting_user": "ANDY PIPKIN", + } + }); + }); + + test("should map Channel to Map", () async { + final map = ChannelRepository.toMap(channel, ["MEMBERID1", "MEMBERID2"]); + expect(map["name"], "CHANNEL"); + expect(map["start_date"], null); + expect(map["has_start_time"], null); + expect(map["venue"], null); + expect(map["visibility"], "OPEN"); + expect(map["description"], "DESC"); + expect(map["authorId"], ""); + expect(map["type"], "TOPIC"); + expect(map["invited_members"], ["MEMBERID1", "MEMBERID2"]); + }); + + test( + "should map Channel with startDate and startTime to Map", + () async { + final map = ChannelRepository.toMap( + channel.rebuild((c) => c + ..startDate = DateTime(2019, 07, 01, 20, 30) + ..hasStartTime = true), + []); + + expect(map["name"], "CHANNEL"); + expect(map["start_date"], + Timestamp.fromDate(DateTime.parse("2019-07-01T20:30:00.000"))); + expect(map["has_start_time"], true); + expect(map["venue"], null); + expect(map["visibility"], "OPEN"); + expect(map["description"], "DESC"); + expect(map["authorId"], ""); + expect(map["invited_members"], []); + }); + + test("should map DocumentSnapshot to Channel", () { + final document = MockDocumentSnapshot(); + when(document["name"]).thenReturn("CHANNEL"); + when(document["visibility"]).thenReturn("OPEN"); + when(document["description"]).thenReturn("DESC"); + when(document.documentID).thenReturn("ID"); + final outChannel = ChannelRepository.fromDocWithUsers( + doc: document, + users: BuiltList(), + ); + expect(outChannel, channel); + }); + }); +} diff --git a/test/data/circle_repository_test.dart b/test/data/circle_repository_test.dart new file mode 100644 index 0000000..3808e39 --- /dev/null +++ b/test/data/circle_repository_test.dart @@ -0,0 +1,29 @@ +import "package:circles_app/data/group_repository.dart"; +import "package:circles_app/model/group.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/mockito.dart"; + + +class MockDocumentSnapshot extends Mock implements DocumentSnapshot {} + +main() { + group("Circle Repository", () { + final circle = Group((c) => c + ..id = "ID" + ..name = "CIRCLE" + ..hexColor = "FFFFFF" + ..abbreviation = "CI" + ); + + test("should map DocumentSnapshot to Circle", () { + final document = MockDocumentSnapshot(); + when(document["name"]).thenReturn("CIRCLE"); + when(document.documentID).thenReturn("ID"); + when(document["color"]).thenReturn("FFFFFF"); + when(document["abbreviation"]).thenReturn("CI"); + final outCircle = GroupRepository.fromDoc(document); + expect(outCircle, circle); + }); + }); +} diff --git a/test/data/data_mocks.dart b/test/data/data_mocks.dart new file mode 100644 index 0000000..14e7cd4 --- /dev/null +++ b/test/data/data_mocks.dart @@ -0,0 +1,30 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/model/user.dart"; + +final mockUser = User((u) => u + ..uid = "userId" + ..name = "name" + ..email = "email"); + +final mockChannelUser = ChannelUser((u) => u + ..id = "userId" + ..rsvp = RSVP.UNSET); + +final mockChannel = Channel((c) => c + ..id = "channelId" + ..name = "name" + ..visibility = ChannelVisibility.OPEN + ..type = ChannelType.EVENT + ..startDate = DateTime(3000, 1, 1) + ..hasUpdates = false + ..users = ListBuilder([mockChannelUser])); + +final mockGroup = Group((g) => g + ..id = "groupdId" + ..name = "group" + ..channels.replace({"channelId": mockChannel}) + ..abbreviation = "g" + ..hexColor = "" + ..image = ""); diff --git a/test/data/message_repository_test.dart b/test/data/message_repository_test.dart new file mode 100644 index 0000000..0f8acaa --- /dev/null +++ b/test/data/message_repository_test.dart @@ -0,0 +1,93 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/data/message_repository.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/mockito.dart"; + +import "FirestoreMocks.dart"; + +main() { + group("Message Repository", () { + final message = Message((m) => m + ..id = "ID" + ..body = "BODY" + ..authorId = "authorId" + ..messageType = MessageType.USER + ..mediaStatus = MediaStatus.ERROR + ..timestamp = DateTime(2019) + ..reactions = BuiltMap.of({ + "USERID": Reaction((r) => r + ..userId = "USERID" + ..userName = "USERNAME" + ..emoji = "EMOJI" + ..timestamp = DateTime(2019)), + }).toBuilder()); + final timestamp = DateTime(2019).millisecondsSinceEpoch.toString(); + + test("should map Mesage to Map", () async { + final map = MessageRepository.toMap(message); + expect(map, { + "body": "BODY", + "author": "authorId", + "reaction": { + "USERID": { + "emoji": "EMOJI", + "user_id": "USERID", + "user_name": "USERNAME", + "timestamp": timestamp, + }, + }, + "type": "USER", + "timestamp": timestamp, + }); + }); + + test("should map DocumentSnapshot to Message", () { + final document = MockDocumentSnapshot(); + when(document.documentID).thenReturn("ID"); + when(document["body"]).thenReturn("BODY"); + when(document["author"]).thenReturn("authorId"); + when(document["reaction"]).thenReturn({ + "USERID": { + "emoji": "EMOJI", + "user_id": "USERID", + "user_name": "USERNAME", + "timestamp": timestamp, + }, + }); + when(document["timestamp"]).thenReturn(timestamp); + final metadata = MockSnapshotMetadata(); + when(document.metadata).thenReturn(metadata); + when(metadata.hasPendingWrites).thenReturn(false); + final outChannel = MessageRepository.fromDoc(document); + expect(outChannel, message); + }); + + test("should check if invalid message", () { + final document = MockDocumentSnapshot(); + // null author + when(document["body"]).thenReturn("BODY"); + when(document["type"]).thenReturn("USER"); + final isValid = MessageRepository.isValidDocument(document); + expect(false, isValid); + }); + + test("should check if valid Message", () { + final document = MockDocumentSnapshot(); + when(document["body"]).thenReturn("BODY"); + when(document["author"]).thenReturn("authorId"); + when(document["type"]).thenReturn("USER"); + final isValid = MessageRepository.isValidDocument(document); + expect(true, isValid); + }); + + test("should check if valid System Message", () { + final document = MockDocumentSnapshot(); + when(document["body"]).thenReturn("BODY"); + when(document["type"]).thenReturn("SYSTEM"); + final isValid = MessageRepository.isValidDocument(document); + expect(true, isValid); + }); + }); +} diff --git a/test/data/user_repository_test.dart b/test/data/user_repository_test.dart new file mode 100644 index 0000000..df8fc2e --- /dev/null +++ b/test/data/user_repository_test.dart @@ -0,0 +1,36 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/mockito.dart"; + +import "FirestoreMocks.dart"; + +main() { + group("User Repository", () { + final user = User((u) => u + ..uid = "ID" + ..name = "NAME" + ..email = "EMAIL" + ..unreadUpdates = MapBuilder({})); + + test("should map user to Map", () { + final map = UserRepository.toMap(user); + expect(map, { + "uid": "ID", + "name": "NAME", + "email": "EMAIL", + }); + }); + + test("should map DocumentSnapshot to User", () { + final document = MockDocumentSnapshot(); + when(document["name"]).thenReturn("NAME"); + when(document["email"]).thenReturn("EMAIL"); + when(document.documentID).thenReturn("ID"); + when(document["unreadUpdates"]).thenReturn({}); + final userFromDoc = UserRepository.fromDoc(document); + expect(userFromDoc, user); + }); + }); +} diff --git a/test/domain/auth_middleware_test.dart b/test/domain/auth_middleware_test.dart new file mode 100644 index 0000000..3a348e8 --- /dev/null +++ b/test/domain/auth_middleware_test.dart @@ -0,0 +1,109 @@ +import "dart:async"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/authentication/auth_middleware.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter/foundation.dart"; +import "package:redux/redux.dart"; +import "package:mockito/mockito.dart"; +import "package:test/test.dart"; +import "package:flutter/widgets.dart" as w; + +class MockUserRepository extends Mock implements UserRepository {} + +class MockMiddleware extends Mock implements MiddlewareClass {} + +// ignore: must_be_immutable +class MockGlobalKey extends Mock implements w.GlobalKey {} + +class MockNavigatorState extends Mock implements w.NavigatorState { + @override + // ignore: invalid_override_different_default_values_named + String toString({DiagnosticLevel minLevel}) => ""; +} + +main() { + group("Authentication Middleware", () { + MockMiddleware _captor; + MockUserRepository _userRepository; + Store _store; + MockGlobalKey _globalKey; + MockNavigatorState _navigatorState; + final _user = User((u) => u + ..uid = "UID" + ..email = "EMAIL" + ..name = "NAME"); + + setUp(() { + _captor = MockMiddleware(); + _userRepository = MockUserRepository(); + _globalKey = MockGlobalKey(); + _navigatorState = MockNavigatorState(); + _store = Store(appReducer, + initialState: AppState.init(), + middleware: + createAuthenticationMiddleware(_userRepository, _globalKey) + ..add(_captor)); + }); + + test("should perform logOut", () { + _store.dispatch(LogOutAction()); + verify(_userRepository.logOut()); + + verify(_captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + + test("should fail logOut", () { + when(_userRepository.logOut()).thenThrow(Exception()); + _store.dispatch(LogOutAction()); + verify(_userRepository.logOut()); + + verify(_captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + + test("should conntect to datasource when authenticated", () { + // ignore: close_sinks + final controller = StreamController(sync: true); + + when(_userRepository.getAuthenticationStateChange()) + .thenAnswer((_) => controller.stream); + _store.dispatch(VerifyAuthenticationState()); + controller.add(_user); + + verify(_userRepository.getAuthenticationStateChange()); + + verify(_captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + + test("should sign user in on LogIn", () { + when(_userRepository.signIn("secret@circles.xyz", "soverysecret")) + .thenAnswer((_) => SynchronousFuture(_user)); + when(_globalKey.currentState).thenReturn(_navigatorState); + + _store.dispatch( + LogIn(email: "secret@circles.xyz", password: "soverysecret")); + verify(_userRepository.signIn("secret@circles.xyz", "soverysecret")); + + verify(_captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + }); +} diff --git a/test/domain/auth_reducer_test.dart b/test/domain/auth_reducer_test.dart new file mode 100644 index 0000000..7165387 --- /dev/null +++ b/test/domain/auth_reducer_test.dart @@ -0,0 +1,32 @@ +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/model/user.dart"; +import "package:redux/redux.dart"; +import "package:test/test.dart"; + +main() { + group("State Reducer", () { + Store _testStore; + final _testUser = User((u) => u + ..uid = "UID" + ..name = "NAME" + ..email = "EMAIL"); + + setUp(() { + _testStore = Store(appReducer, initialState: AppState.init()); + }); + + test("should load user OnAuthenticated into store", () { + expect(_testStore.state.user, null); + _testStore.dispatch(OnAuthenticated(user: _testUser)); + expect(_testStore.state.user, _testUser); + }); + + test("should remove user OnLogoutSuccess from store", () { + _testStore.dispatch(OnAuthenticated(user: _testUser)); + _testStore.dispatch(OnLogoutSuccess()); + expect(_testStore.state.user, null); + }); + }); +} diff --git a/test/domain/channel/channel_middleware_test.dart b/test/domain/channel/channel_middleware_test.dart new file mode 100644 index 0000000..9040330 --- /dev/null +++ b/test/domain/channel/channel_middleware_test.dart @@ -0,0 +1,187 @@ +import "dart:async"; + +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/channel/channel_middleware.dart"; +import "package:circles_app/domain/redux/ui/ui_state.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart"; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../../data/data_mocks.dart"; +import "../redux_mocks.dart"; + +main() { + group("Channel Middleware", () { + test("select channel when loading channels and no other channel selected", + () { + final repo = MockChannelsRepository(); + final captor = MockMiddleware(); + final store = Store( + appReducer, + initialState: AppState.init().rebuild((s) => s + ..selectedGroupId = "groupId" + ..user = mockUser.toBuilder() + // No selected channel previously + ..channelState = ChannelState.init().toBuilder()), + middleware: createChannelsMiddleware(repo, null)..add(captor), + ); + + final controller = StreamController>(sync: true); + + when(repo.getChannelsStream("groupId", "userId")) + .thenAnswer((_) => controller.stream); + + when(repo.getStreamForChannel("groupId", "channel2", "userId")) + .thenAnswer((_) => Stream.empty()); + + store.dispatch(LoadChannels("groupId")); + + // Channel1 is closed, channel2 is open + final channel1 = mockChannel.rebuild((c) => c + ..id = "channel1" + ..visibility = ChannelVisibility.CLOSED); + final channel2 = mockChannel.rebuild((c) => c + ..id = "channel2" + ..visibility = ChannelVisibility.OPEN); + + // Load channels + controller.add([channel1, channel2]); + + // Should select channel 2 because is first open + final action = SelectChannel( + previousChannelId: null, + channel: channel2, + groupId: "groupId", + userId: "userId", + ); + verify(captor.call(any, action, any) as dynamic); + + controller.close(); + }); + + test("select channel when loading channels with last selected channel", () { + final repo = MockChannelsRepository(); + final captor = MockMiddleware(); + final store = Store( + appReducer, + initialState: AppState.init().rebuild((s) => s + ..selectedGroupId = "groupId" + ..user = mockUser.toBuilder() + // previously selected channel for the group is the channel1 + ..uiState.groupUiState["groupId"] = + GroupUiState((s) => s..lastSelectedChannel = "channel1") + ..channelState = ChannelState.init().toBuilder()), + middleware: createChannelsMiddleware(repo, null)..add(captor), + ); + + final controller = StreamController>(sync: true); + + when(repo.getChannelsStream("groupId", "userId")) + .thenAnswer((_) => controller.stream); + + when(repo.getStreamForChannel("groupId", "channel1", "userId")) + .thenAnswer((_) => Stream.empty()); + + store.dispatch(LoadChannels("groupId")); + + // Channel1 is closed, channel2 is open + final channel1 = mockChannel.rebuild((c) => c + ..id = "channel1" + ..visibility = ChannelVisibility.CLOSED); + final channel2 = mockChannel.rebuild((c) => c + ..id = "channel2" + ..visibility = ChannelVisibility.OPEN); + + // Load channels + controller.add([channel1, channel2]); + + // Should select channel 1 because it is the previous selected one + final action = SelectChannel( + previousChannelId: null, + channel: channel1, + groupId: "groupId", + userId: "userId", + ); + verify(captor.call(any, action, any) as dynamic); + + controller.close(); + }); + + test("handle RSVP.YES should update channel", () { + final repo = MockChannelsRepository(); + final captor = MockMiddleware(); + final user = User((u) => u + ..uid = "userId" + ..name = "name" + ..email = "email"); + final store = Store( + appReducer, + initialState: AppState.init().rebuild((s) => s + ..selectedGroupId = "groupId" + ..user = user.toBuilder() + ..groups.replace({"groupId": mockGroup}) + ..channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = "channelId") + .toBuilder()), + middleware: createChannelsMiddleware(repo, null)..add(captor), + ); + when(repo.rsvp("groupId", "channelId", "userId", RSVP.YES)) + .thenAnswer((_) => SynchronousFuture(0)); + + final completer = Completer(); + + store.dispatch(RsvpAction(RSVP.YES, completer)); + + verify(repo.rsvp("groupId", "channelId", "userId", RSVP.YES)); + verifyNever(repo.leaveChannel("groupId", "channelId", "userId")); + + expect(completer.isCompleted, true); + }); + + test("handle RSVP.NO should leave channel", () async { + final repo = MockChannelsRepository(); + final captor = MockMiddleware(); + final user = User((u) => u + ..uid = "userId" + ..name = "name" + ..email = "email"); + final store = Store( + appReducer, + initialState: AppState.init().rebuild((s) => s + ..selectedGroupId = "groupId" + ..user = user.toBuilder() + ..groups.replace({"groupId": mockGroup}) + ..channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = "channelId") + .toBuilder()), + middleware: createChannelsMiddleware(repo, null)..add(captor), + ); + when(repo.rsvp("groupId", "channelId", "userId", RSVP.NO)) + .thenAnswer((_) => SynchronousFuture(0)); + when(repo.leaveChannel("groupId", "channelId", "userId")) + .thenAnswer((_) => SynchronousFuture(0)); + final completer = Completer(); + + store.dispatch(RsvpAction(RSVP.NO, completer)); + + verify(repo.rsvp("groupId", "channelId", "userId", RSVP.NO)); + verify(repo.leaveChannel("groupId", "channelId", "userId")); + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + await completer.future; + + expect(completer.isCompleted, true); + }); + }); +} diff --git a/test/domain/message/message_middleware_test.dart b/test/domain/message/message_middleware_test.dart new file mode 100644 index 0000000..cc6816d --- /dev/null +++ b/test/domain/message/message_middleware_test.dart @@ -0,0 +1,130 @@ +import "dart:async"; + +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/domain/redux/message/message_middleware.dart"; +import "package:circles_app/model/message.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart"; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../../data/data_mocks.dart"; +import "../redux_mocks.dart"; + +main() { + group("Message Middleware", () { + final user = mockUser; + final repository = MockMessageRepository(); + final captor = MockMiddleware(); + final channel = mockChannel; + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "groupId" + ..user = user.toBuilder() + ..groups.replace({"groupId": mockGroup}) + ..channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = "channelId") + .toBuilder()), + middleware: createMessagesMiddleware(repository)..add(captor), + ); + final message = Message((m) => m + ..body = "BODY" + ..timestamp = DateTime(2019) + ..authorId = "authorId"); + + test("should send message", () { + when(repository.sendMessage( + "groupId", + channel.id, + any, + )).thenAnswer((_) => SynchronousFuture(message)); + store.dispatch(SendMessage("BODY")); + verify( + repository.sendMessage( + "groupId", + channel.id, + any, + ), + ); + }); + + test("should receive message", () { + // ignore: close_sinks + final controller = StreamController>(sync: true); + when(repository.getMessagesStream( + "groupId", + "channelId", + "userId", + )).thenAnswer((_) => controller.stream); + store.dispatch(SelectChannel( + channel: channel, + groupId: "groupdId", + userId: "userId", + previousChannelId: "PCID")); + + verify( + repository.getMessagesStream( + "groupId", + channel.id, + "userId", + ), + ); + + controller.add([message]); + + verify( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + + test("should set emoji reaction", () { + when(repository.addReaction( + groupId: "groupId", + channelId: "channelId", + messageId: "ID", + reaction: anyNamed("reaction"), + )).thenAnswer((_) => SynchronousFuture(null)); + + store.dispatch(EmojiReaction("ID", "EMOJI")); + + verify( + repository.addReaction( + reaction: anyNamed("reaction"), + messageId: "ID", + groupId: "groupId", + channelId: "channelId", + ), + ); + }); + + test("should remove emoji reaction", () { + when(repository.removeReaction( + groupId: "groupId", + channelId: "channelId", + messageId: "ID", + userId: "userId", + )).thenAnswer((_) => SynchronousFuture(null)); + + store.dispatch(RemoveEmojiReaction("ID")); + + verify( + repository.removeReaction( + userId: "userId", + messageId: "ID", + groupId: "groupId", + channelId: "channelId", + ), + ); + }); + }); +} diff --git a/test/domain/message/message_reducer_test.dart b/test/domain/message/message_reducer_test.dart new file mode 100644 index 0000000..fd85abc --- /dev/null +++ b/test/domain/message/message_reducer_test.dart @@ -0,0 +1,31 @@ +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/message/message_actions.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:redux/redux.dart"; + +main() { + group("Message Reducer", () { + final user = User((u) => u + ..uid = "ID" + ..name = "NAME" + ..email = "EMAIL"); + final message = Message((m) => m + ..id = "ID" + ..body = "BODY" + ..authorId = "authorId"); + + test("should update messages", () { + final store = Store( + appReducer, + initialState: + AppState.init().rebuild((a) => a..user = user.toBuilder()), + ); + expect(store.state.messagesOnScreen.isEmpty, true); + store.dispatch(UpdateAllMessages([message])); + expect(store.state.messagesOnScreen, [message]); + }); + }); +} diff --git a/test/domain/middleware_test.dart b/test/domain/middleware_test.dart new file mode 100644 index 0000000..7f5f1f1 --- /dev/null +++ b/test/domain/middleware_test.dart @@ -0,0 +1,273 @@ +import "dart:async"; + +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/domain/redux/channel/channel_middleware.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_middleware.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter/foundation.dart"; +import "package:redux/redux.dart"; +import "package:mockito/mockito.dart"; +import "package:test/test.dart"; + +import "redux_mocks.dart"; + +class FutureCallbackMock extends Mock implements Function { + Future call(); +} + +main() { + group("Middleware", () { + final repository = MockGroupRepository(); + final channelsRepository = MockChannelsRepository(); + final captor = MockMiddleware(); + final user = User((u) => u + ..uid = "USERID" + ..name = "NAME" + ..email = "EMAIL"); + final group = Group((c) => c + ..id = "ID" + ..name = "CIRCLE" + ..hexColor = "FFFFFF" + ..abbreviation = "CI"); + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..id = "CHANNELID" + ..name = "CHANNEL" + ..visibility = ChannelVisibility.OPEN + ..users = ListBuilder()); + + test("should load user data", () { + final store = Store( + appReducer, + initialState: + AppState.init().rebuild((a) => a..user = user.toBuilder()), + middleware: createStoreMiddleware(repository) + ..addAll(createChannelsMiddleware(channelsRepository, null)) + ..add(captor), + ); + + // Loads all groups for user and selects one. + final groupController = StreamController>(sync: true); + when(repository.getGroupStream("USERID")) + .thenAnswer((_) => groupController.stream); + + final channelResult = [channel]; + // Load all channels for group and selects one. + final controller = StreamController>(sync: true); + when(channelsRepository.getChannelsStream("ID", "USERID")) + .thenAnswer((_) => controller.stream); + + // Subscribe to selected channel. + final selectedChannelController = StreamController(sync: true); + when(channelsRepository.getStreamForChannel("ID", "CHANNELID", "USERID")) + .thenAnswer((_) => selectedChannelController.stream); + + store.dispatch(ConnectToDataSource()); + groupController.add([group]); + + verify(repository.getGroupStream("USERID")); + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + verify(channelsRepository.getChannelsStream("ID", "USERID")); + controller.add(channelResult); + + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + selectedChannelController.close(); + groupController.close(); + controller.close(); + }); + + test("should create channel", () async { + final globalKey = MockGlobalKey(); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..groups = MapBuilder({"ID": group}) + ..selectedGroupId = "ID" + ..user = user.toBuilder()), + middleware: createStoreMiddleware(repository) + ..addAll(createChannelsMiddleware(channelsRepository, globalKey)) + ..add(captor), + ); + + final channel = Channel((c) => c + ..id = "CHANNELID" + ..type = ChannelType.TOPIC + ..name = "CHANNEL" + ..users = ListBuilder([]) + ..visibility = ChannelVisibility.OPEN); + + final channelResult = Channel((c) => c + ..id = "CHANNELID" + ..type = ChannelType.TOPIC + ..name = "CHANNEL" + ..users = ListBuilder([ + ChannelUser((cu) => cu + ..id = "MEMBERID1" + ..rsvp = RSVP.UNSET), + ChannelUser((cu) => cu + ..id = "USERID" + ..rsvp = RSVP.YES) + ]) + ..visibility = ChannelVisibility.OPEN); + + when(channelsRepository.createChannel( + "ID", + channel, + ["USERID", "MEMBERID1"], + "USERID", + )).thenAnswer((_) => SynchronousFuture(channelResult)); + + when(channelsRepository.markChannelRead("ID", channel.id, "USERID")) + .thenAnswer((_) => SynchronousFuture(null)); + + store.dispatch(CreateChannel( + channel, + BuiltList(["MEMBERID1"]), + Completer(), + )); + + verify(channelsRepository.createChannel( + "ID", channel, ["USERID", "MEMBERID1"], "USERID")); + + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + await Future.delayed(const Duration(milliseconds: 1000)); + + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + + // Verify mark channel read. + verify(channelsRepository.markChannelRead( + "ID", + "CHANNELID", + "USERID", + )); + + // ignore: close_sinks + final selectedChannelController = StreamController(sync: true); + when(channelsRepository.getStreamForChannel( + "ID", + "CHANNELID", + "USERID", + )).thenAnswer((_) => selectedChannelController.stream); + + // Verify channel subscription + verify(channelsRepository.getStreamForChannel( + "ID", + "CHANNELID", + "USERID", + )); + }); + + test("should leave channel", () { + final globalKey = MockGlobalKey(); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..groups = MapBuilder({"CIID": group}) + ..selectedGroupId = "ID" + ..user = user.toBuilder()), + middleware: createStoreMiddleware(repository) + ..addAll(createChannelsMiddleware(channelsRepository, globalKey)) + ..add(captor), + ); + + when(channelsRepository.leaveChannel("CIID", channel.id, "USERID")) + .thenAnswer((_) => SynchronousFuture(channel)); + + store.dispatch(LeaveChannelAction("CIID", channel, "USERID")); + verify(channelsRepository.leaveChannel("CIID", channel.id, "USERID")); + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + + test("should join channel", () { + //JoinChannelAction + final globalKey = MockGlobalKey(); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..groups = MapBuilder({"CIID": group}) + ..selectedGroupId = "ID" + ..user = user.toBuilder()), + middleware: createStoreMiddleware(repository) + ..addAll(createChannelsMiddleware(channelsRepository, globalKey)) + ..add(captor), + ); + + when(channelsRepository.joinChannel("CIID", channel, "USERID")) + .thenAnswer((_) => SynchronousFuture(channel)); + store.dispatch( + JoinChannelAction(groupId: "CIID", channel: channel, user: user)); + verify(channelsRepository.joinChannel("CIID", channel, "USERID")); + verify(captor.call( + any, + TypeMatcher(), + any, + ) as dynamic); + }); + + test("should subscribe to selected channel", () { + //JoinChannelAction + final globalKey = MockGlobalKey(); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..groups = MapBuilder({"CIID": group}) + ..selectedGroupId = "ID" + ..user = user.toBuilder()), + middleware: createStoreMiddleware(repository) + ..addAll(createChannelsMiddleware(channelsRepository, globalKey)) + ..add(captor), + ); + + final selectedChannelController = StreamController(sync: true); + when(channelsRepository.getStreamForChannel("ID", "CHANNELID", "USERID")) + .thenAnswer((_) => selectedChannelController.stream); + + store.dispatch( + SelectChannel(channel: channel, groupId: "ID", userId: "USERID")); + + verify( + channelsRepository.getStreamForChannel("ID", "CHANNELID", "USERID")); + + selectedChannelController.close(); + }); + }); +} diff --git a/test/domain/push/push_middleware_test.dart b/test/domain/push/push_middleware_test.dart new file mode 100644 index 0000000..71ad14c --- /dev/null +++ b/test/domain/push/push_middleware_test.dart @@ -0,0 +1,207 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:circles_app/domain/redux/push/push_middleware.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/model/in_app_notification.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart"; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../redux_mocks.dart"; + +main() { + group("Push Middleware", () { + final userRepo = MockUserRepository(); + final firebaseMessaging = MockFirebaseMessaging(); + final groupRepo = MockGroupRepository(); + final channelRepo = MockChannelsRepository(); + final captor = MockMiddleware(); + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..user.update((u) => u + ..uid = "USERID" + ..email = "EMAIL" + ..name = "NAME")), + middleware: createPushMiddleware( + userRepo, + firebaseMessaging, + groupRepo, + channelRepo, + )..add(captor), + ); + final group = Group((c) => c + ..id = "groupId" + ..name = "CIRCLE" + ..hexColor = "FFFFFF" + ..abbreviation = "CI"); + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..visibility = ChannelVisibility.OPEN + ..id = "CHANNELID" + ..users = ListBuilder([]) + ..name = "CHANNEL"); + + test("should ignore empty payload", () { + final Map payload = {}; + store.dispatch(OnPushNotificationReceivedAction(payload)); + + verifyNever( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + + test("should ignore missing important data", () { + final Map payload = {"notification": {}, "data": {}}; + store.dispatch(OnPushNotificationReceivedAction(payload)); + + verifyNever( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + + test("should ignore non message notifications", () { + final Map payload = { + "notification": {}, + "data": { + "groupId": "groupId", + "channelId": "CHANNELID", + "type": "reaction", + } + }; + store.dispatch(OnPushNotificationReceivedAction(payload)); + + verifyNever( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + + test("should ignore notification when user is in same channel", () { + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) { + return a + ..selectedGroupId = "groupId" + ..channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = channel.id) + .toBuilder(); + }), + middleware: createPushMiddleware( + userRepo, + firebaseMessaging, + groupRepo, + channelRepo, + )..add(captor), + ); + + final Map payload = { + "notification": { + "body": "Hello", + }, + "data": { + "groupId": "groupId", + "channelId": "CHANNELID", + "type": "message", + "username": "USER", + } + }; + store.dispatch(OnPushNotificationReceivedAction(payload)); + + verifyNever( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + + test("should process message notification", () { + when(channelRepo.getChannel("groupId", "CHANNELID", "USERID")) + .thenAnswer((_) => SynchronousFuture(channel)); + when(groupRepo.getGroup("groupId")) + .thenAnswer((_) => SynchronousFuture(group)); + + final Map payload = { + "notification": { + "body": "Hello", + }, + "data": { + "groupId": "groupId", + "channelId": "CHANNELID", + "type": "message", + "username": "USER", + } + }; + store.dispatch(OnPushNotificationReceivedAction(payload)); + + final notification = InAppNotification((i) => i + ..groupId = "groupId" + ..channel = channel.toBuilder() + ..groupName = "CIRCLE" + ..userName = "USER" + ..message = "Hello"); + + verify( + captor.call( + any, + ShowPushNotificationAction(notification), + any, + ) as dynamic, + ); + }); + + test("should select circle on open notification", () { + when(channelRepo.getChannel("CIRCLE-2-ID", "CHANNELID", "USERID")) + .thenAnswer((_) => SynchronousFuture(channel)); + + final Map payload = { + "notification": { + "body": "Hello", + }, + "data": { + "groupId": "CIRCLE-2-ID", + "channelId": "CHANNELID", + "type": "message", + "username": "USER", + } + }; + + store.dispatch(OnPushNotificationOpenAction(payload)); + verify( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + verify( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + }); + }); +} diff --git a/test/domain/push/push_reducer_test.dart b/test/domain/push/push_reducer_test.dart new file mode 100644 index 0000000..fb1ffe8 --- /dev/null +++ b/test/domain/push/push_reducer_test.dart @@ -0,0 +1,42 @@ +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/push/push_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/in_app_notification.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:redux/redux.dart"; + +main() { + group("Push Reducer", () { + final notification = InAppNotification((i) => i + ..message = "HELLO" + ..userName = "USERNAME" + ..groupName = "CIRCLENAME" + ..groupId = "GROUPID" + ..channel.update((c) => c + ..type = ChannelType.TOPIC + ..name = "CHANNEL NAME" + ..visibility = ChannelVisibility.OPEN)); + + test("should update AppState with push notification", () { + final store = Store( + appReducer, + initialState: AppState.init(), + ); + expect(store.state.inAppNotification, null); + store.dispatch(ShowPushNotificationAction(notification)); + expect(store.state.inAppNotification, notification); + }); + + test("should update AppState when push notification dismissed", () { + final store = Store( + appReducer, + initialState: AppState.init() + .rebuild((a) => a..inAppNotification = notification.toBuilder()), + ); + expect(store.state.inAppNotification, notification); + store.dispatch(OnPushNotificationDismissedAction()); + expect(store.state.inAppNotification, null); + }); + }); +} diff --git a/test/domain/reducer_test.dart b/test/domain/reducer_test.dart new file mode 100644 index 0000000..cdba936 --- /dev/null +++ b/test/domain/reducer_test.dart @@ -0,0 +1,229 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/domain/redux/app_selector.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel_state.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/domain/redux/app_actions.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:redux/redux.dart"; +import "package:test/test.dart"; + +main() { + group("State Reducer", () { + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..id = "CHANNELID" + ..name = "CHANNEL" + ..visibility = ChannelVisibility.OPEN + ..users = ListBuilder() + ..hasUpdates = true); + + final group = Group((c) => c + ..id = "ID" + ..name = "CIRCLE" + ..hexColor = "FFFFFF" + ..abbreviation = "CI" + ..channels = MapBuilder()); + + test("should load group into store", () { + final store = Store( + appReducer, + initialState: AppState.init(), + ); + + expect(store.state.groups, MapBuilder().build()); + + store.dispatch(OnGroupsLoaded([group])); + + expect( + store.state.groups, MapBuilder({"ID": group}).build()); + expect(store.state.selectedGroupId, null); + }); + + test("should load topics into store", () { + final store = Store( + appReducer, + initialState: AppState.init() + .rebuild((a) => a..groups = MapBuilder({"ID": group})), + ); + + expect( + store.state.groups["ID"].channels, BuiltMap.of({})); + + store.dispatch(OnChannelsLoaded("ID", [channel])); + + expect(store.state.groups["ID"].channels, + BuiltMap.of({"CHANNELID": channel})); + }); + + test("should select group", () { + final store = Store( + appReducer, + initialState: AppState.init() + .rebuild((a) => a..groups = MapBuilder({"ID": group})), + ); + + expect(store.state.selectedGroupId, null); + + store.dispatch(SelectGroup("ID")); + + expect(store.state.selectedGroupId, "ID"); + }); + + test("should add created channel", () { + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "ID" + ..groups = MapBuilder({"ID": group})), + ); + + expect( + store.state.groups["ID"].channels, BuiltMap.of({})); + store.dispatch(OnChannelCreated(channel)); + expect(store.state.groups["ID"].channels, + BuiltMap.of({"CHANNELID": channel})); + }); + + test("should leave channel", () { + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..id = "CHANNELID" + ..name = "CHANNEL" + ..visibility = ChannelVisibility.OPEN + ..users = ListBuilder([ + ChannelUser((u) => u + ..id = "USERID" + ..rsvp = RSVP.UNSET) + ])); + + final channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = channel.id) + .toBuilder(); + + final Map channels = Map.fromIterable( + [channel], + key: (item) => item.id, + value: (item) => item, + ); + + final initializedGroup = + group.rebuild((c) => c..channels = MapBuilder(channels)); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "ID" + ..channelState = channelState + ..groups = MapBuilder({"ID": initializedGroup})), + ); + + expect(getSelectedChannel(store.state).users, [ + ChannelUser((u) => u + ..id = "USERID" + ..rsvp = RSVP.UNSET) + ]); + expect(store.state.groups["ID"].channels["CHANNELID"].users, [ + ChannelUser((u) => u + ..id = "USERID" + ..rsvp = RSVP.UNSET) + ]); + store.dispatch(LeftChannelAction("ID", "CHANNELID", "USERID")); + expect(getSelectedChannel(store.state).users, []); + expect(store.state.groups["ID"].channels["CHANNELID"].users, []); + }); + + test("should join channel", () { + final channelState = ChannelState.init() + .rebuild((cs) => cs..selectedChannel = channel.id) + .toBuilder(); + + final Map channels = Map.fromIterable( + [channel], + key: (item) => item.id, + value: (item) => item, + ); + + final initializedCircle = + group.rebuild((c) => c..channels = MapBuilder(channels)); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "ID" + ..channelState = channelState + ..groups = MapBuilder({"ID": initializedCircle})), + ); + + expect(getSelectedChannel(store.state).users, []); + expect(store.state.groups["ID"].channels["CHANNELID"].users, []); + + final channelWithUser = channel.rebuild((c) => c + ..users.add(ChannelUser((cu) => cu + ..id = "USERID" + ..rsvp = RSVP.UNSET))); + store.dispatch(JoinedChannelAction("ID", channelWithUser)); + + expect(getSelectedChannel(store.state).users, [ + ChannelUser((u) => u + ..id = "USERID" + ..rsvp = RSVP.UNSET) + ]); + expect(store.state.groups["ID"].channels["CHANNELID"].users, [ + ChannelUser((u) => u + ..id = "USERID" + ..rsvp = RSVP.UNSET) + ]); + }); + + test("should flag channel as read", () { + final channel = Channel((c) => c + ..type = ChannelType.TOPIC + ..id = "CHANNELID" + ..name = "CHANNEL" + ..visibility = ChannelVisibility.OPEN + ..users = ListBuilder() + ..hasUpdates = true); + + final rebuildCircle = group + .rebuild((c) => c..channels = MapBuilder({"CHANNELID": channel})); + + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "ID" + ..groups = MapBuilder({"ID": group})), + ); + + store.dispatch(SelectChannel( + channel: channel, + groupId: rebuildCircle.id, + userId: "UID", + previousChannelId: "PCID")); + final updatedCircle = + store.state.groups.values.toList().firstWhere((c) => c.id == "ID"); + expect( + updatedCircle.channels.values + .firstWhere((i) => i.id == "CHANNELID") + .hasUpdates, + false); + }); + + test("should set join channel error flag", () { + final store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..selectedGroupId = "ID" + ..groups = MapBuilder({"ID": group})), + ); + + expect(store.state.channelState.joinChannelFailed, false); + store.dispatch(JoinChannelFailedAction()); + expect(store.state.channelState.joinChannelFailed, true); + store.dispatch(ClearFailedJoinAction()); + expect(store.state.channelState.joinChannelFailed, false); + }); + }); +} diff --git a/test/domain/redux_mocks.dart b/test/domain/redux_mocks.dart new file mode 100644 index 0000000..77dc15f --- /dev/null +++ b/test/domain/redux_mocks.dart @@ -0,0 +1,34 @@ +import "package:circles_app/data/channel_repository.dart"; +import "package:circles_app/data/group_repository.dart"; +import "package:circles_app/data/file_repository.dart"; +import "package:circles_app/data/message_repository.dart"; +import "package:circles_app/data/user_repository.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:firebase_messaging/firebase_messaging.dart"; +import "package:flutter/foundation.dart"; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; +import "package:flutter/widgets.dart" as w; + +class MockGroupRepository extends Mock implements GroupRepository {} + +class MockChannelsRepository extends Mock implements ChannelRepository {} + +class MockMessageRepository extends Mock implements MessageRepository {} + +class MockUserRepository extends Mock implements UserRepository {} + +class MockFileRepository extends Mock implements FileRepository {} + +class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} + +class MockMiddleware extends Mock implements MiddlewareClass {} + +// ignore: must_be_immutable +class MockGlobalKey extends Mock implements w.GlobalKey {} + +class MockNavigatorState extends Mock implements w.NavigatorState { + @override + // ignore: invalid_override_different_default_values_named + String toString({DiagnosticLevel minLevel}) => ""; +} diff --git a/test/domain/user/user_middleware_test.dart b/test/domain/user/user_middleware_test.dart new file mode 100644 index 0000000..599b108 --- /dev/null +++ b/test/domain/user/user_middleware_test.dart @@ -0,0 +1,48 @@ +import "dart:async"; + +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/authentication/auth_actions.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/domain/redux/user/user_middleware.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart"; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../redux_mocks.dart"; + +main() { + group("User Middleware", () { + final userRepo = MockUserRepository(); + final captor = MockMiddleware(); + final user = User((u) => u + ..uid = "ID" + ..name = "NAME" + ..email = "EMAIL"); + final store = Store( + appReducer, + initialState: AppState.init(), + middleware: createUserMiddleware(userRepo)..add(captor), + ); + + test("Should update user", () { + final controller = StreamController(sync: true); + when(userRepo.getUserStream("ID")).thenAnswer((_) => controller.stream); + + store.dispatch(OnAuthenticated(user: user)); + controller.add(user); + + verify( + captor.call( + any, + TypeMatcher(), + any, + ) as dynamic, + ); + + controller.close(); + }); + }); +} diff --git a/test/domain/user/user_reducer_test.dart b/test/domain/user/user_reducer_test.dart new file mode 100644 index 0000000..6d3187d --- /dev/null +++ b/test/domain/user/user_reducer_test.dart @@ -0,0 +1,57 @@ +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/user/user_actions.dart"; +import "package:circles_app/model/user.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:redux/redux.dart"; + +main() { + group("User Reducer", () { + // Ids of user and userOld are the same + final user = User((u) => u + ..uid = "userId" + ..name = "name" + ..image = "imageUrl" + ..status = "myStatus" + ..email = "my@example.com"); + + final userOld = User((u) => u + ..uid = "userId" + ..name = "nameOld" + ..image = "imageUrlOld" + ..status = "myStatusOld" + ..email = "my@example.com"); + + test("should update user when state empty", () { + final store = Store( + appReducer, + initialState: AppState.init(), + ); + + expect(store.state.user, null); + expect(store.state.groupUsers.contains(user), false); + store.dispatch(OnUserUpdateAction(user)); + expect(store.state.user, user); + expect(store.state.groupUsers.contains(user), true); + }); + + test("should update user when state has data", () { + final store = Store( + appReducer, + initialState: AppState.init(), + ); + + // Load old user first + store.dispatch(OnUserUpdateAction(userOld)); + expect(store.state.user, userOld); + expect(store.state.groupUsers.contains(userOld), true); + expect(store.state.groupUsers.contains(user), false); + + // Update user (e.g. changes status or name) + store.dispatch(OnUserUpdateAction(user)); + expect(store.state.user, user); + expect(store.state.groupUsers.contains(userOld), false); + expect(store.state.groupUsers.contains(user), true); + }); + }); +} diff --git a/test/model/message_test.dart b/test/model/message_test.dart new file mode 100644 index 0000000..7ce8bcf --- /dev/null +++ b/test/model/message_test.dart @@ -0,0 +1,37 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/message.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:flutter_test/flutter_test.dart"; + +main() { + group("Message Model", () { + final message = Message((m) => m + ..body = "" + ..authorId = "USERID" + ..reactions = BuiltMap.of({ + "USER1": Reaction((r) => r + ..emoji = "❤️" + ..userId = "USERID" + ..timestamp = DateTime.now() + ..userName = "USERNAME"), + "USER2": Reaction((r) => r + ..emoji = "❤️" + ..userId = "USERID" + ..timestamp = DateTime.now() + ..userName = "USERNAME"), + "USER3": Reaction((r) => r + ..emoji = "😂" + ..userId = "USERID" + ..timestamp = DateTime.now() + ..userName = "USERNAME"), + }).toBuilder()); + + test("should count emoji in reactions", () { + final reactions = message.reactionsCount(); + expect(reactions, { + "😂": 1, + "❤️": 2, + }); + }); + }); +} diff --git a/test/presentation/channel/create/create_channel_test.dart b/test/presentation/channel/create/create_channel_test.dart new file mode 100644 index 0000000..aac4fb5 --- /dev/null +++ b/test/presentation/channel/create/create_channel_test.dart @@ -0,0 +1,130 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/create/create_channel.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart" as matcher; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../../../data/data_mocks.dart"; +import "../../../domain/redux_mocks.dart"; + +void main() { + group("CreateChannelScreen tests", () { + testWidgets("create open channel", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateChannelScreen(tester); + final topicName = "topic name"; + final purpose = "purpose"; + + // Enter channel name and purpose + await tester.enterText(find.byKey(Key("TopicName")), topicName); + await tester.enterText(find.byKey(Key("Purpose")), purpose); + + // Tap on create channel button + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + final channel = Channel((c) => c + ..name = topicName + ..description = purpose + ..type = ChannelType.TOPIC + ..authorId = mockUser.uid + ..visibility = ChannelVisibility.OPEN); + final createChannel = CreateChannel( + channel, + BuiltList(), + null, + ); + verify(captor.call( + any, + createChannel, + any, + ) as dynamic); + }); + + testWidgets("missing topic name", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateChannelScreen(tester); + + // Everything is empty (channel name is empty) + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + // Tapping on Create should no nothing + verifyNever(captor.call( + any, + matcher.TypeMatcher(), + any, + ) as dynamic); + }); + + testWidgets("create closed channel", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateChannelScreen(tester); + final topicName = "topic name"; + final purpose = "purpose"; + + // Enter channel name and purpose + await tester.enterText(find.byKey(Key("TopicName")), topicName); + await tester.enterText(find.byKey(Key("Purpose")), purpose); + + // Change Switch to Closed + await tester.tap(find.byKey(Key("Visibility"))); + // Wait to load user list + await tester.pumpAndSettle(); + // Select user with name "User2" + await tester.tap(find.text("User2")); + await tester.pumpAndSettle(); + + // Create channel + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + final channel = Channel((c) => c + ..name = topicName + ..description = purpose + ..type = ChannelType.TOPIC + ..authorId = mockUser.uid + ..visibility = ChannelVisibility.CLOSED); + final createChannel = CreateChannel( + channel, + BuiltList(["userId2"]), + null, + ); + verify(captor.call( + any, + createChannel, + any, + ) as dynamic); + }); + }); +} + +Future _pumpCreateChannelScreen(WidgetTester tester) async { + final captor = MockMiddleware(); + Store store; + store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..user = mockUser.toBuilder() + ..groupUsers.addAll([ + mockUser, + mockUser.rebuild((u) => u + ..uid = "userId2" + ..name = "User2") + ])), + middleware: [captor], + ); + await tester.pumpWidget(StoreProvider( + store: store, + child: MaterialApp( + localizationsDelegates: localizationsDelegates, + home: CreateChannelScreen(), + ), + )); + return captor; +} diff --git a/test/presentation/channel/event/create_event_test.dart b/test/presentation/channel/event/create_event_test.dart new file mode 100644 index 0000000..cd597d4 --- /dev/null +++ b/test/presentation/channel/event/create_event_test.dart @@ -0,0 +1,169 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/circles_localization.dart"; +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/domain/redux/channel/channel_actions.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/channel/event/create_event.dart"; +import "package:circles_app/util/logger.dart"; +import "package:flutter/material.dart"; +import "package:flutter_redux/flutter_redux.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:matcher/matcher.dart" as matcher; +import "package:mockito/mockito.dart"; +import "package:redux/redux.dart"; + +import "../../../data/data_mocks.dart"; +import "../../../domain/redux_mocks.dart"; + +void main() { + group("CreateEventScreen tests", () { + testsLogger(); + + testWidgets("create open event", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateEventScreen(tester); + final topicName = "topic name"; + final purpose = "purpose"; + + await _fillCommonValues(tester, topicName, purpose); + + // Tap on create channel button + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + final now = DateTime.now(); + final channel = Channel((c) => c + ..name = topicName + ..description = purpose + ..type = ChannelType.EVENT + ..authorId = mockUser.uid + ..venue = "" + ..startDate = DateTime(now.year, now.month, now.day, 23, 59) + ..hasStartTime = false + ..visibility = ChannelVisibility.OPEN); + final createChannel = CreateChannel( + channel, + BuiltList(), + null, + ); + verify(captor.call( + any, + createChannel, + any, + ) as dynamic); + }); + + testWidgets("missing topic name", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateEventScreen(tester); + + // Everything is empty (channel name is empty) + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + // Tapping on Create should no nothing + verifyNever(captor.call( + any, + matcher.TypeMatcher(), + any, + ) as dynamic); + }); + + testWidgets("create closed event", (WidgetTester tester) async { + final MockMiddleware captor = await _pumpCreateEventScreen(tester); + final topicName = "topic name"; + final purpose = "purpose"; + + await _fillCommonValues(tester, topicName, purpose); + + // Change Switch to Closed + await tester.tap(find.byKey(Key("Visibility"))); + // to load user list + await tester.pumpAndSettle(); + + // TODO: There seems to be a bug in this tap action, skipping test + // Select user with name "User2" + await tester.tap(find.byKey(Key("userId2.InkWell"))); + await tester.pumpAndSettle(); + + // Create channel + await tester.tap(find.byKey(Key("Create"))); + await tester.pumpAndSettle(); + + final now = DateTime.now(); + final channel = Channel((c) => c + ..name = topicName + ..description = purpose + ..type = ChannelType.EVENT + ..authorId = mockUser.uid + ..venue = "" + ..startDate = DateTime(now.year, now.month, now.day, 23, 59) + ..hasStartTime = false + ..visibility = ChannelVisibility.CLOSED); + final createChannel = CreateChannel( + channel, + BuiltList(["userId2"]), + null, + ); + verify(captor.call( + any, + createChannel, + any, + ) as dynamic); + }, skip: true); + }); + + // REF: https://github.com/janoodleFTW/flutter-app/issues/275 + testWidgets("allow change dates multiple times", (WidgetTester tester) async { + await _pumpCreateEventScreen(tester); + final topicName = "topic name"; + final purpose = "purpose"; + + await _fillCommonValues(tester, topicName, purpose); + + // Pick a date + // Simply select today (selected by default) + await tester.tap(find.byKey(Key("EventDate"))); + await tester.pumpAndSettle(); + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); + }); +} + +Future _fillCommonValues( + WidgetTester tester, String topicName, String purpose) async { + // Enter channel name and purpose + await tester.enterText(find.byKey(Key("TopicName")), topicName); + await tester.enterText(find.byKey(Key("Purpose")), purpose); + + // Pick a date + // Simply select today (selected by default) + await tester.tap(find.byKey(Key("EventDate"))); + await tester.pumpAndSettle(); + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); +} + +Future _pumpCreateEventScreen(WidgetTester tester) async { + final captor = MockMiddleware(); + Store store; + store = Store( + appReducer, + initialState: AppState.init().rebuild((a) => a + ..user = mockUser.toBuilder() + ..groupUsers.addAll([ + mockUser, + mockUser.rebuild((u) => u + ..uid = "userId2" + ..name = "User2") + ])), + middleware: [captor], + ); + await tester.pumpWidget(StoreProvider( + store: store, + child: MaterialApp( + localizationsDelegates: localizationsDelegates, + home: CreateEventScreen(), + ), + )); + return captor; +} diff --git a/test/presentation/channel/invite/invite_to_channel_viewmodel_test.dart b/test/presentation/channel/invite/invite_to_channel_viewmodel_test.dart new file mode 100644 index 0000000..6727131 --- /dev/null +++ b/test/presentation/channel/invite/invite_to_channel_viewmodel_test.dart @@ -0,0 +1,57 @@ +import "package:circles_app/domain/redux/app_reducer.dart"; +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/model/group.dart"; +import "package:circles_app/model/user.dart"; +import "package:circles_app/presentation/channel/invite/invite_to_channel_viewmodel.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:redux/redux.dart"; + +main() { + group("Invite To Channel ViewModel", () { + test("should list users that are not already in the channel", () { + final user = User((u) => u + ..uid = "user" + ..email = "email" + ..name = "name"); + + // Group has three users: 1, 2, 3 + final user1 = user.rebuild((u) => u..uid = "user1"); + final user2 = user.rebuild((u) => u..uid = "user2"); + final user3 = user.rebuild((u) => u..uid = "user3"); + + // User 1 and 2 are already in the channel + final channelUser1 = ChannelUser((u) => u + ..id = "user1" + ..rsvp = RSVP.UNSET); + final channelUser2 = ChannelUser((u) => u + ..id = "user2" + ..rsvp = RSVP.UNSET); + + final store = Store(appReducer, + initialState: AppState.init().rebuild((s) => s + ..groupUsers.replace([user1, user2, user3]) + ..selectedGroupId = "groupId" + ..groups.replace({ + "groupId": Group((g) => g + ..id = "groupId" + ..name = "group" + ..hexColor = "" + ..abbreviation = "" + ..channels.replace({ + "channelId": Channel((c) => c + ..id = "channelId" + ..name = "name" + ..visibility = ChannelVisibility.CLOSED + ..type = ChannelType.EVENT + ..users.replace([channelUser1, channelUser2])) + })) + }))); + + final vm = InviteToChannelViewModel.fromStore("channelId")(store); + + // Only user 3 should appear in the list + expect(vm.newUsers, [user3]); + }); + }); +} diff --git a/test/presentation/home/channel_list_viewmodel_test.dart b/test/presentation/home/channel_list_viewmodel_test.dart new file mode 100644 index 0000000..4dfe0e3 --- /dev/null +++ b/test/presentation/home/channel_list_viewmodel_test.dart @@ -0,0 +1,86 @@ +import "package:circles_app/domain/redux/app_state.dart"; +import "package:circles_app/model/channel.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list_item.dart"; +import "package:circles_app/presentation/home/channel_list/channel_list_viewmodel.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:redux/redux.dart"; + +import "../../data/data_mocks.dart"; + +main() { + group("Channel List ViewModel", () { + test("should show list of joined topics", () { + final appState = AppState.init().rebuild((a) => a + ..user = mockUser.toBuilder() + ..selectedGroupId = "groupId" + ..groups.replace({ + "groupId": mockGroup.rebuild((g) => g + ..channels.replace({ + "channel1": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + "channel2": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + "channel3": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + "channel4": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + })) + })); + final store = Store(null, initialState: appState); + + final vm = ChannelListViewModel.fromStore(store); + + // Group header + expect(vm.items[0] is ChannelListHeadingItem, true); + // Events (empty) + expect(vm.items[1] is ChannelListActionItem, true); + // Topics + expect(vm.items[2] is ChannelListActionItem, true); + // Joined topics + expect(vm.items[3] is ChannelListHeadingItem, true); + expect(vm.items[4] is ChannelListChannelItem, true); + expect(vm.items[5] is ChannelListChannelItem, true); + expect(vm.items[6] is ChannelListChannelItem, true); + expect(vm.items[7] is ChannelListChannelItem, true); + }); + + test("should show list of past events", () { + final appState = AppState.init().rebuild((a) => a + ..user = mockUser.toBuilder() + ..selectedGroupId = "groupId" + ..groups.replace({ + "groupId": mockGroup.rebuild((g) => g + ..channels.replace({ + "channel1": mockChannel.rebuild((c) => c + ..startDate = DateTime(2019, 1, 1) + ..type = ChannelType.EVENT), + "channel2": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + "channel3": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + "channel4": mockChannel.rebuild((c) => c + ..type = ChannelType.TOPIC), + })) + })); + final store = Store(null, initialState: appState); + + final vm = ChannelListViewModel.fromStore(store); + + expect(vm.items[0] is ChannelListHeadingItem, true); + // Events + expect(vm.items[1] is ChannelListActionItem, true); + // "previous" + expect(vm.items[2] is ChannelListHeadingItem, true); + // Event in the past + expect(vm.items[3] is ChannelListChannelItem, true); + + // Topics + expect(vm.items[4] is ChannelListActionItem, true); + // Joined topics + expect(vm.items[5] is ChannelListHeadingItem, true); + expect(vm.items[6] is ChannelListChannelItem, true); + expect(vm.items[7] is ChannelListChannelItem, true); + expect(vm.items[8] is ChannelListChannelItem, true); + }); + }); +} diff --git a/test/presentation/reaction/reaction_details_test.dart b/test/presentation/reaction/reaction_details_test.dart new file mode 100644 index 0000000..3611208 --- /dev/null +++ b/test/presentation/reaction/reaction_details_test.dart @@ -0,0 +1,49 @@ +import "package:built_collection/built_collection.dart"; +import "package:circles_app/model/reaction.dart"; +import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_detail_data.dart"; +import "package:circles_app/presentation/channel/reaction/reaction_details.dart"; +import "package:flutter_test/flutter_test.dart"; + +main() { + group("Reaction details tests", () { + test("should sort reaction details as expected", () { + final map = BuiltMap({ + "USER2": Reaction((r) => r + ..userId = "USERID" + ..userName = "Miguel" + ..timestamp = DateTime.now() + ..emoji = emojiPickerOptions[2]), + "USER4": Reaction((r) => r + ..userId = "USERID" + ..userName = "Lara" + ..timestamp = DateTime(2019) + ..emoji = emojiPickerOptions[2]), + "USER1": Reaction((r) => r + ..userId = "USERID" + ..userName = "Lily" + ..timestamp = DateTime.now() + ..emoji = emojiPickerOptions[1]), + "USER5": Reaction((r) => r + ..userId = "USERID" + ..userName = "Droid" + ..timestamp = DateTime.now() + ..emoji = emojiPickerOptions[0]), + }); + + final details = toListOfReactionDetailData(map); + + expect(details, [ + ReactionDetailData((r) => r + ..emoji = emojiPickerOptions[2] + ..names = "Miguel, Lara"), + ReactionDetailData((r) => r + ..emoji = emojiPickerOptions[0] + ..names = "Droid"), + ReactionDetailData((r) => r + ..emoji = emojiPickerOptions[1] + ..names = "Lily"), + ]); + }); + }); +} diff --git a/test/synchronous_error.dart b/test/synchronous_error.dart new file mode 100644 index 0000000..c2bc2a5 --- /dev/null +++ b/test/synchronous_error.dart @@ -0,0 +1,41 @@ +import "dart:async"; + +class SynchronousError implements Future { + + final Object _error; + + SynchronousError(this._error); + + @override + Stream asStream() { + final StreamController controller = StreamController(); + controller.addError(_error); + controller.close(); + return controller.stream; + } + + @override + Future catchError(Function onError, { bool test(dynamic error) }) { + onError(_error); + return Completer().future; + } + + @override + Future then(dynamic f(T value), { Function onError }) { + return SynchronousError(_error); + } + + @override + Future timeout(Duration timeLimit, { dynamic onTimeout() }) { + return Future.error(_error).timeout(timeLimit, onTimeout: onTimeout); + } + + @override + Future whenComplete(dynamic action()) { + try { + return this; + } catch (e, stack) { + return Future.error(e, stack); + } + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9ffb2a2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,10 @@ +import "package:circles_app/circles_app.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + testWidgets("App loads test", (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(CirclesApp()); + + }); +} diff --git a/timy.png b/timy.png new file mode 100644 index 0000000..409803c Binary files /dev/null and b/timy.png differ