Initial commit.

Co-authored-by: Miguel Beltran <m@beltran.work>
This commit is contained in:
Franz Heinfling 2019-10-01 14:14:58 +02:00
commit 64d39ac266
371 changed files with 29295 additions and 0 deletions

72
.gitignore vendored Normal file
View 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

Binary file not shown.

Binary file not shown.

View file

10
.metadata Normal file
View 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
View 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
View 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.
![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 youll need to install [Flutter](https://flutter.dev) and its dependencies. To verify your installation run in the projects root directory:****
```
$ flutter doctor
```
The app is optimised for Android and iOS phones in portrait mode.
**Note:** Additionally youll 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 Firebases `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 youre using Firebases free `Spark Plan`. Therefore were 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. Well need to add this ID to a group in the next step.
*Note: Youll 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 youll 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 youve 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 weve retrieved in **Adding a user*** above |
| name | string | test |
Weve now setup our fist test group. In addition to this step well 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 youll 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 youre building for release youll 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 youll need to run the app.
*Note: Please skip any error that might occur.*
Login with the user youve 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 were 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 youve 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
View 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
View 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
View 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

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Timy DEV</string>
</resources>

View 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>

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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")!!
}

View file

@ -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()
}

View file

@ -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())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View 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>

View 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>

View 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
View 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
}

View file

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
assets/icon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

65
firebase/.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1,6 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
}
}

View 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
View 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
View file

@ -0,0 +1 @@
node_modules/

View 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,
}

View 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 };

Some files were not shown because too many files have changed in this diff Show more