Initial commit.
Co-authored-by: Miguel Beltran <m@beltran.work>
72
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
BIN
.gradle/4.10.2/fileChanges/last-build.bin
Normal file
BIN
.gradle/4.10.2/fileHashes/fileHashes.lock
Normal file
0
.gradle/4.10.2/gc.properties
Normal file
10
.metadata
Normal file
|
|
@ -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
|
||||
201
LICENSE
Normal file
|
|
@ -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.
|
||||
173
README.md
Normal file
|
|
@ -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.
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 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. 🚀
|
||||
57
analysis_options.yaml
Normal file
|
|
@ -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
|
||||
25
android/README.md
Normal file
|
|
@ -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.
|
||||
106
android/app/build.gradle
Normal file
|
|
@ -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
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.circles_app">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
4
android/app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Timy DEV</string>
|
||||
</resources>
|
||||
37
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.circles_app">
|
||||
|
||||
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||
In most cases you can leave this as-is, but you if you want to provide
|
||||
additional functionality it is fine to subclass or reimplement
|
||||
FlutterApplication and put your custom class here. -->
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- This keeps the window background of the activity showing
|
||||
until Flutter renders its first frame. It can be removed if
|
||||
there is no splash screen (such as the default splash screen
|
||||
defined in @style/LaunchTheme). -->
|
||||
<meta-data
|
||||
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
|
||||
android:value="true" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
PermissionHandler.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<out String>, grantResults: IntArray) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
result?.success(mapOf(permissions[0] to grantResults[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MethodCall.permission(): String {
|
||||
return argument<String>("permissionType")!!
|
||||
}
|
||||
|
|
@ -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<String>("fileId")
|
||||
val type = call.argument<Int>("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()
|
||||
}
|
||||
|
|
@ -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<String>("groupId")
|
||||
val channelId = call.argument<String>("channelId")
|
||||
val paths = call.argument<List<String>>("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<UploadWorker>()
|
||||
.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>): String {
|
||||
val uid = getUserUid()
|
||||
val firestore = FirebaseFirestore.getInstance()
|
||||
// A suspendCoroutine wraps any callback into a synchronous coroutine
|
||||
val docRef = suspendCoroutine<DocumentReference> { 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<Boolean> { cont ->
|
||||
firestore.getMessageCollection(groupId, channelId)
|
||||
.document(messageId)
|
||||
.update(hashMapOf<String, Any>(
|
||||
"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<out String>): 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<String>, aspectRatio: String) {
|
||||
val firestore = FirebaseFirestore.getInstance()
|
||||
suspendCoroutine<Boolean> { 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<String>, groupId: String, channelId: String, messageId: String, taskId: UUID, applicationContext: Context): List<String> {
|
||||
val storage = FirebaseStorage.getInstance()
|
||||
val urls = mutableListOf<String>()
|
||||
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())
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
android/app/src/main/res/drawable-nodpi/splash.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
10
android/app/src/main/res/drawable/launch_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
5
android/app/src/main/res/values-de/strings.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upload_notification_title">Dateien werden hochgeladen</string>
|
||||
<string name="upload_notification_text">Datei %d von %d</string>
|
||||
</resources>
|
||||
4
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
6
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Timy</string>
|
||||
<string name="upload_notification_title">Uploading files</string>
|
||||
<string name="upload_notification_text">File %d of %d</string>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
Flutter draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.circles_app">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
44
android/build.gradle
Normal file
|
|
@ -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
|
||||
}
|
||||
3
android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||
15
android/settings.gradle
Normal file
|
|
@ -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
|
||||
}
|
||||
BIN
android/store/feature.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/store/hi-res-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/store/screenshot1.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
android/store/screenshot2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/graphics/avatar_no_picture.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
assets/graphics/calendar/calendar_today.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
assets/graphics/channel/create_new_channel.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/graphics/channel/details_date.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/graphics/channel/details_location.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/graphics/channel/details_members.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/graphics/channel/details_padlock.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
assets/graphics/channel/event_joined.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/graphics/channel/event_open.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/graphics/channel/header_calendar_icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/graphics/channel/padlock.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/graphics/channel/rsvp/rsvp_maybe.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/graphics/channel/rsvp/rsvp_maybe_large.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
assets/graphics/channel/rsvp/rsvp_no_large.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/graphics/channel/rsvp/rsvp_yes.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/graphics/channel/rsvp/rsvp_yes_large.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
assets/graphics/channel/topic_joined.png
Normal file
|
After Width: | Height: | Size: 842 B |
BIN
assets/graphics/channel/topic_open.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/graphics/drawer/account.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/graphics/drawer/create_topic.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
assets/graphics/drawer/direct_message.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/graphics/drawer/events.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/graphics/drawer/settings.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/graphics/icon_notification.png
Normal file
|
After Width: | Height: | Size: 436 B |
BIN
assets/graphics/icon_smile.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/graphics/input/checkbox_active.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/graphics/input/checkbox_inactive.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/graphics/input/icon_add_content.png
Normal file
|
After Width: | Height: | Size: 245 B |
BIN
assets/graphics/input/icon_camera.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/graphics/input/icon_pictures.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/graphics/menu_icon.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
assets/graphics/menu_more_icon.png
Normal file
|
After Width: | Height: | Size: 261 B |
BIN
assets/graphics/update_indicator_darkgreen.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/graphics/updates_indicator.png
Normal file
|
After Width: | Height: | Size: 686 B |
BIN
assets/graphics/updates_indicator_white.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/graphics/upload/indicator_0_try_again.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/graphics/upload/selected.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/graphics/visual_twist.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
assets/graphics/visual_twist_white_petrol.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
assets/icon/icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/placeholder/2.0x/user_image_placeholder.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
assets/placeholder/3.0x/user_image_placeholder.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/placeholder/user_image_placeholder.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
65
firebase/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
9
firebase/README.md
Normal file
|
|
@ -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
|
||||
6
firebase/firebase.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
}
|
||||
}
|
||||
51
firebase/firestore.indexes.json
Normal file
|
|
@ -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": []
|
||||
}
|
||||
36
firebase/firestore.rules
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
firebase/functions/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
11
firebase/functions/admin.js
Normal file
|
|
@ -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,
|
||||
}
|
||||
83
firebase/functions/calendar-update.js
Normal file
|
|
@ -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 };
|
||||