├── .firebaserc ├── .gitignore ├── .metadata ├── LICENSE ├── README.md ├── android ├── .project ├── .settings │ └── org.eclipse.buildship.core.prefs ├── app │ ├── .classpath │ ├── .project │ ├── .settings │ │ └── org.eclipse.buildship.core.prefs │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ ├── com │ │ │ └── example │ │ │ │ └── domore │ │ │ │ └── MainActivity.java │ │ └── consulting │ │ │ └── mabby │ │ │ └── domore │ │ │ └── MainActivity.java │ │ └── res │ │ ├── drawable │ │ └── launch_background.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ └── values │ │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── animations │ ├── loading_animation.flr │ ├── loading_animation_looped.flr │ └── loading_logo.flr └── fonts │ ├── IBMPlexSans-Bold.ttf │ ├── IBMPlexSans-Light.ttf │ ├── IBMPlexSans-Medium.ttf │ ├── IBMPlexSans-Regular.ttf │ └── IBMPlexSans-SemiBold.ttf ├── firebase.json ├── functions ├── package-lock.json ├── package.json ├── src │ ├── generate_thumb.ts │ ├── index.ts │ └── pending_tasks_updater.ts ├── tsconfig.json └── tslint.json ├── ios ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.h │ ├── AppDelegate.m │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── main.m ├── lib ├── main.dart └── src │ ├── App.dart │ ├── blocs │ ├── archive_bloc.dart │ ├── event_bloc.dart │ ├── events_bloc.dart │ ├── home_bloc.dart │ ├── new_event_bloc.dart │ ├── new_image_bloc.dart │ └── task_bloc.dart │ ├── mixins │ ├── tile_mixin.dart │ └── upload_status_mixin.dart │ ├── models │ ├── event_model.dart │ ├── summary_model.dart │ ├── task_model.dart │ └── user_model.dart │ ├── resources │ ├── firebase_storage_provider.dart │ ├── firestore_provider.dart │ └── google_sign_in_provider.dart │ ├── screens │ ├── archive_screen.dart │ ├── event_screen.dart │ ├── events_screen.dart │ ├── gallery_screen.dart │ ├── home_screen.dart │ ├── initial_loading_screen.dart │ ├── login_screen.dart │ ├── new_event_screen.dart │ ├── new_image_screen.dart │ └── task_screen.dart │ ├── services │ ├── auth_service.dart │ └── upload_status_service.dart │ ├── utils.dart │ └── widgets │ ├── action_button.dart │ ├── app_bar.dart │ ├── async_image.dart │ ├── async_thumbnail.dart │ ├── avatar.dart │ ├── big_text_input.dart │ ├── carousel.dart │ ├── event_dropdown.dart │ ├── event_list_tile.dart │ ├── fractionally_screen_sized_box.dart │ ├── gradient_touchable_container.dart │ ├── home_app_bar.dart │ ├── loading_indicator.dart │ ├── logo.dart │ ├── new_item_dialog_button.dart │ ├── new_item_dialog_route.dart │ ├── ocurrance_selector.dart │ ├── populated_drawer.dart │ ├── priority_selector.dart │ ├── search-box.dart │ └── task_list_tile.dart ├── pubspec.yaml └── test └── src ├── resources ├── firestore_provider_test.dart └── google_sign_in_provider_test.dart └── utils_test.dart /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "do-more-app" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.lock 4 | *.log 5 | *.pyc 6 | *.swp 7 | .DS_Store 8 | .atom/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # Visual Studio Code related 20 | .vscode/ 21 | 22 | # Flutter/Dart/Pub related 23 | **/doc/api/ 24 | .dart_tool/ 25 | .flutter-plugins 26 | .packages 27 | .pub-cache/ 28 | .pub/ 29 | build/ 30 | 31 | # Android related 32 | **/android/**/gradle-wrapper.jar 33 | **/android/.gradle 34 | **/android/captures/ 35 | **/android/gradlew 36 | **/android/gradlew.bat 37 | **/android/local.properties 38 | **/android/**/GeneratedPluginRegistrant.java 39 | 40 | # iOS/XCode related 41 | **/ios/**/*.mode1v3 42 | **/ios/**/*.mode2v3 43 | **/ios/**/*.moved-aside 44 | **/ios/**/*.pbxuser 45 | **/ios/**/*.perspectivev3 46 | **/ios/**/*sync/ 47 | **/ios/**/.sconsign.dblite 48 | **/ios/**/.tags* 49 | **/ios/**/.vagrant/ 50 | **/ios/**/DerivedData/ 51 | **/ios/**/Icon? 52 | **/ios/**/Pods/ 53 | **/ios/**/.symlinks/ 54 | **/ios/**/profile 55 | **/ios/**/xcuserdata 56 | **/ios/.generated/ 57 | **/ios/Flutter/App.framework 58 | **/ios/Flutter/Flutter.framework 59 | **/ios/Flutter/Generated.xcconfig 60 | **/ios/Flutter/app.flx 61 | **/ios/Flutter/app.zip 62 | **/ios/Flutter/flutter_assets/ 63 | **/ios/ServiceDefinitions.json 64 | **/ios/Runner/GeneratedPluginRegistrant.* 65 | 66 | # Exceptions to above rules. 67 | !**/ios/**/default.mode1v3 68 | !**/ios/**/default.mode2v3 69 | !**/ios/**/default.pbxuser 70 | !**/ios/**/default.perspectivev3 71 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 72 | 73 | # Firebase api keys 74 | **/GoogleService-Info.plist 75 | **/google-services.json 76 | 77 | 78 | ## Compiled JavaScript files 79 | **/*.js 80 | **/*.js.map 81 | 82 | # Typescript v1 declaration files 83 | typings/ 84 | 85 | node_modules/ -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 5391447fae6209bb21a89e6a5a6583cac1af9b4b 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mariano Uvalle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # do_more (DO>) [![Build Status](https://app.bitrise.io/app/11d283a1ca8ed38e/status.svg?token=rbZPQaJTLG1lyzb9cqGQCg)](https://app.bitrise.io/app/11d283a1ca8ed38e) 2 | 3 | A glorified todo list with a beautiful ui. 4 | 5 | ## Login with Google 6 | ![](https://j.gifs.com/QnO9J5.gif) 7 | 8 | ## Events 9 | Organize your tasks by events, visualize them in their page and add new ones specifying their occurrence. 10 | 11 | ![](https://j.gifs.com/ANm3qj.gif) 12 | 13 | ## Tasks 14 | Add new tasks from the main screen, specify the event they belong to and their priority. 15 | 16 | ![](https://j.gifs.com/Mw9381.gif) 17 | 18 | Filter your tasks by name or by event name. 19 | 20 | ![](https://j.gifs.com/QnO9W5.gif) 21 | 22 | Mark tasks as done and recover them from the archive section. 23 | 24 | ![](https://j.gifs.com/k8oqRv.gif) 25 | 26 | Edit tasks. 27 | 28 | ![](https://j.gifs.com/k8oqRJ.gif) 29 | 30 | ## Media 31 | Add pictures from the main screen and link them to an event, both thumbnails and full-size images are locally cached for faster subsequent access. 32 | 33 | ![](https://j.gifs.com/wVkJjr.gif) 34 | 35 | Gallery screen for every event with thumbnails of all your images. 36 | 37 | ![](https://j.gifs.com/BNn3gQ.gif) 38 | 39 | Full screen gallery. 40 | 41 | ![](https://j.gifs.com/OMZAJr.gif) 42 | 43 | ## Roadmap 44 | 45 | https://trello.com/b/zdKMw2JL/do 46 | 47 | -------------------------------------------------------------------------------- /android/.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | android 4 | Project android created by Buildship. 5 | 6 | 7 | 8 | 9 | org.eclipse.buildship.core.gradleprojectbuilder 10 | 11 | 12 | 13 | 14 | 15 | org.eclipse.buildship.core.gradleprojectnature 16 | 17 | 18 | -------------------------------------------------------------------------------- /android/.settings/org.eclipse.buildship.core.prefs: -------------------------------------------------------------------------------- 1 | connection.project.dir= 2 | eclipse.preferences.version=1 3 | -------------------------------------------------------------------------------- /android/app/.classpath: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | app 4 | Project app created by Buildship. 5 | 6 | 7 | 8 | 9 | org.eclipse.jdt.core.javabuilder 10 | 11 | 12 | 13 | 14 | org.eclipse.buildship.core.gradleprojectbuilder 15 | 16 | 17 | 18 | 19 | 20 | org.eclipse.jdt.core.javanature 21 | org.eclipse.buildship.core.gradleprojectnature 22 | 23 | 24 | -------------------------------------------------------------------------------- /android/app/.settings/org.eclipse.buildship.core.prefs: -------------------------------------------------------------------------------- 1 | connection.project.dir=.. 2 | eclipse.preferences.version=1 3 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 26 | 27 | android { 28 | compileSdkVersion 27 29 | 30 | lintOptions { 31 | disable 'InvalidPackage' 32 | } 33 | 34 | defaultConfig { 35 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 36 | applicationId "consulting.mabby.domore" 37 | minSdkVersion 21 38 | targetSdkVersion 27 39 | versionCode flutterVersionCode.toInteger() 40 | versionName flutterVersionName 41 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 42 | } 43 | 44 | buildTypes { 45 | release { 46 | // TODO: Add your own signing config for the release build. 47 | // Signing with the debug keys for now, so `flutter run --release` works. 48 | signingConfig signingConfigs.debug 49 | } 50 | } 51 | } 52 | 53 | flutter { 54 | source '../..' 55 | } 56 | 57 | dependencies { 58 | testImplementation 'junit:junit:4.12' 59 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 60 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 61 | } 62 | 63 | apply plugin: 'com.google.gms.google-services' 64 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 9 | 10 | 15 | 19 | 26 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/example/domore/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.example.domore; 2 | 3 | import android.os.Bundle; 4 | import io.flutter.app.FlutterActivity; 5 | import io.flutter.plugins.GeneratedPluginRegistrant; 6 | 7 | public class MainActivity extends FlutterActivity { 8 | @Override 9 | protected void onCreate(Bundle savedInstanceState) { 10 | super.onCreate(savedInstanceState); 11 | GeneratedPluginRegistrant.registerWith(this); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/consulting/mabby/domore/MainActivity.java: -------------------------------------------------------------------------------- 1 | package consulting.mabby.domore; 2 | 3 | import android.os.Bundle; 4 | import io.flutter.app.FlutterActivity; 5 | import io.flutter.plugins.GeneratedPluginRegistrant; 6 | 7 | public class MainActivity extends FlutterActivity { 8 | @Override 9 | protected void onCreate(Bundle savedInstanceState) { 10 | super.onCreate(savedInstanceState); 11 | GeneratedPluginRegistrant.registerWith(this); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | jcenter() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:3.2.1' 9 | classpath 'com.google.gms:google-services:3.2.1' 10 | } 11 | } 12 | 13 | allprojects { 14 | repositories { 15 | google() 16 | jcenter() 17 | } 18 | } 19 | 20 | rootProject.buildDir = '../build' 21 | subprojects { 22 | project.buildDir = "${rootProject.buildDir}/${project.name}" 23 | } 24 | subprojects { 25 | project.evaluationDependsOn(':app') 26 | } 27 | 28 | task clean(type: Delete) { 29 | delete rootProject.buildDir 30 | } 31 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() 4 | 5 | def plugins = new Properties() 6 | def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') 7 | if (pluginsFile.exists()) { 8 | pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } 9 | } 10 | 11 | plugins.each { name, path -> 12 | def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() 13 | include ":$name" 14 | project(":$name").projectDir = pluginDirectory 15 | } 16 | -------------------------------------------------------------------------------- /assets/animations/loading_animation.flr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/animations/loading_animation.flr -------------------------------------------------------------------------------- /assets/animations/loading_animation_looped.flr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/animations/loading_animation_looped.flr -------------------------------------------------------------------------------- /assets/animations/loading_logo.flr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/animations/loading_logo.flr -------------------------------------------------------------------------------- /assets/fonts/IBMPlexSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/fonts/IBMPlexSans-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/IBMPlexSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/fonts/IBMPlexSans-Light.ttf -------------------------------------------------------------------------------- /assets/fonts/IBMPlexSans-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/fonts/IBMPlexSans-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/IBMPlexSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/fonts/IBMPlexSans-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/IBMPlexSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/assets/fonts/IBMPlexSans-SemiBold.ttf -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint", 5 | "npm --prefix \"$RESOURCE_DIR\" run build" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "build": "tsc", 6 | "serve": "npm run build && firebase serve --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "main": "lib/index.js", 13 | "dependencies": { 14 | "@google-cloud/storage": "^2.5.0", 15 | "@types/fs-extra": "^5.0.5", 16 | "@types/sharp": "^0.22.1", 17 | "firebase-admin": "~7.0.0", 18 | "firebase-functions": "^2.2.1", 19 | "fs-extra": "^7.0.1", 20 | "sharp": "^0.22.0" 21 | }, 22 | "devDependencies": { 23 | "tslint": "^5.12.0", 24 | "typescript": "^3.2.2" 25 | }, 26 | "private": true 27 | } 28 | -------------------------------------------------------------------------------- /functions/src/generate_thumb.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | import { Storage } from '@google-cloud/storage'; 3 | 4 | const gcs = new Storage(); 5 | 6 | import { tmpdir } from 'os'; 7 | import { join, dirname } from 'path'; 8 | 9 | import * as sharp from 'sharp'; 10 | import * as fs from 'fs-extra'; 11 | 12 | /// Creates a thumbnail for every image that gets uploaded to the storage bucket. 13 | export const generateThumb: functions.CloudFunction = functions.storage.object().onFinalize(async object => { 14 | // Find the bucket where the uploaded file resides. 15 | const bucket = gcs.bucket(object.bucket); 16 | 17 | // Find the path of the file inside the bucket. 18 | const filePathGcs = object.name; 19 | 20 | // Save the name of the file. 21 | const fileName = filePathGcs!.split('/').pop(); 22 | 23 | // Directory where the file is stored inside the bucket. 24 | const bucketDirectory = dirname(filePathGcs!); 25 | const workingDirectory = join(tmpdir(), 'thumbnails'); 26 | const tmpFilePath = join(workingDirectory, fileName!); 27 | 28 | if (fileName!.includes('thumb@') || !object.contentType!.includes('image')) { 29 | console.log('Exiting function, already compressed or no image.'); 30 | return false; 31 | } 32 | 33 | // Ensure that our directory exists. 34 | await fs.ensureDir(workingDirectory); 35 | 36 | // Download the source file to the temporary directory. 37 | await bucket.file(filePathGcs!).download({ 38 | destination: tmpFilePath, 39 | }); 40 | 41 | // Create the thumnail in 124 size. 42 | const thumbnailName = `thumb@${fileName}`; 43 | const thumbnailLocalPath = join(workingDirectory, thumbnailName); 44 | 45 | await sharp(tmpFilePath) 46 | .resize(256, 256) 47 | .toFile(thumbnailLocalPath); 48 | 49 | // Upload the resulting thumnail to the same directory as the original file. 50 | await bucket.upload(thumbnailLocalPath, { 51 | destination: join(bucketDirectory, thumbnailName), 52 | }); 53 | console.log('Thumbnail created successfully') 54 | 55 | // Exit the function deleting all the temprorary files. 56 | return fs.remove(workingDirectory); 57 | }); -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | 3 | import { generateThumb } from './generate_thumb'; 4 | import { pendingTasksUpdater } from './pending_tasks_updater'; 5 | 6 | export const generate_thumb: functions.CloudFunction = generateThumb; 7 | export const pending_tasks_updater: functions.CloudFunction> = pendingTasksUpdater; -------------------------------------------------------------------------------- /functions/src/pending_tasks_updater.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | import * as admin from 'firebase-admin'; 3 | import { DocumentSnapshot } from 'firebase-functions/lib/providers/firestore'; 4 | 5 | type DocumentReference = admin.firestore.DocumentReference; 6 | type FieldValue = admin.firestore.FieldValue; 7 | 8 | admin.initializeApp(); 9 | 10 | 11 | const incrementValue = admin.firestore.FieldValue.increment(1); 12 | const decrementValue = admin.firestore.FieldValue.increment(-1); 13 | const db = admin.firestore(); 14 | 15 | /// Gets the user [DocumentReference] for the current task. 16 | const getUserReference = async (taskSnapShot: DocumentSnapshot) => { 17 | const querySnapshot = await db.collection('users').where('username', '==', taskSnapShot.get('ownerUsername')).get() 18 | return querySnapshot.docs[0].ref; 19 | } 20 | 21 | /// Gets an event document reference for the current task. 22 | const getEventReference = async ( 23 | taskSnapshot: DocumentSnapshot, 24 | userReference: DocumentReference 25 | ) => { 26 | const querySnapshot = await userReference.collection('events').where('name', '==', taskSnapshot.get('event')).get(); 27 | return querySnapshot.docs[0].ref; 28 | } 29 | 30 | /// Increments the necessary fields in the event and user documents linked to this task. 31 | const incrementFromPriority = async ( 32 | priority: number, 33 | eventReference: DocumentReference, 34 | userReference: DocumentReference, 35 | value: FieldValue, 36 | ) => { 37 | let userFieldName: string = ''; 38 | let eventFieldName: string = ''; 39 | 40 | switch (priority) { 41 | case 0: 42 | userFieldName = 'pendingLow'; 43 | eventFieldName = 'lowPriority'; 44 | break; 45 | case 1: 46 | userFieldName = 'pendingMedium'; 47 | eventFieldName = 'mediumPriority'; 48 | break; 49 | case 2: 50 | userFieldName = 'pendingHigh'; 51 | eventFieldName = 'highPriority'; 52 | break; 53 | } 54 | 55 | await eventReference.update({ [eventFieldName]: value }); 56 | await userReference.update({ [userFieldName]: value }); 57 | } 58 | 59 | /// Incrementsj only the 'pendingTasks' field for the provided event. 60 | const incrementPendingTasks = async ( 61 | eventReference: DocumentReference, 62 | value: FieldValue 63 | ) => { 64 | await eventReference.update({ 'pendingTasks': value }); 65 | } 66 | 67 | export const pendingTasksUpdater: functions.CloudFunction> = functions.firestore 68 | .document('tasks/{taskId}') 69 | .onWrite(async (change, _) => { 70 | /// Snapshot of the document before the operation. 71 | /// 72 | /// Only applicable for update and delete operations. 73 | const before: DocumentSnapshot = change.before; 74 | 75 | /// Snapshot of the document after the operation. 76 | /// 77 | /// Only applicable for update and create operations. 78 | const after: DocumentSnapshot = change.after; 79 | 80 | /// Operation performed to the task. 81 | let action: string; 82 | 83 | /// Reference to the user dociment linked to this task. 84 | let userDocument: DocumentReference; 85 | 86 | /// Reference to the event document linked to this task before the operation. 87 | let eventDocument: DocumentReference; 88 | 89 | /// Reference to the event document linked to this task after the operation. 90 | let eventDocumentBefore: DocumentReference | null; 91 | 92 | 93 | if (change.after.exists && change.before.exists) { 94 | /// Exit the funciton if case this is an update operation and the 95 | /// event and priority of the taks haven't changed. 96 | if (before.get('priority') === after.get('priority') && before.get('event') === after.get('event') && before.get('done') === after.get('done')) { 97 | console.log('Nothing to update, exiting function'); 98 | return true; 99 | } 100 | userDocument = await getUserReference(after); 101 | eventDocumentBefore = await getEventReference(before, userDocument); 102 | eventDocument = await getEventReference(after, userDocument); 103 | action = 'update'; 104 | } else if (!change.before.exists) { 105 | userDocument = await getUserReference(after); 106 | eventDocument = await getEventReference(after, userDocument); 107 | action = 'create'; 108 | } else { 109 | userDocument = await getUserReference(before); 110 | eventDocument = await getEventReference(before, userDocument); 111 | action = 'delete'; 112 | } 113 | 114 | switch (action) { 115 | case 'create': 116 | await incrementFromPriority(after.get('priority'), eventDocument, userDocument, incrementValue); 117 | await incrementPendingTasks(eventDocument, incrementValue); 118 | break; 119 | case 'delete': 120 | await incrementFromPriority(before.get('priority'), eventDocument, userDocument, decrementValue); 121 | await incrementPendingTasks(eventDocument, decrementValue); 122 | break; 123 | case 'update': 124 | if (before.get('done') !== after.get('done')) { 125 | const value = after.get('done') ? decrementValue : incrementValue; 126 | await incrementFromPriority(after.get('priority'), eventDocument, userDocument, value); 127 | await incrementPendingTasks(eventDocument, value); 128 | } 129 | if (before.get('priority') !== after.get('priority')) { 130 | await incrementFromPriority(before.get('priority'), eventDocumentBefore!, userDocument, decrementValue); 131 | await incrementFromPriority(after.get('priority'), eventDocument, userDocument, incrementValue); 132 | } 133 | if (before.get('event') !== after.get('event')) { 134 | await incrementPendingTasks(eventDocumentBefore!, decrementValue); 135 | await incrementPendingTasks(eventDocument, incrementValue); 136 | } 137 | break; 138 | } 139 | 140 | console.log('Successfully updated user and event due to task update'); 141 | return true; 142 | }); -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2015" 10 | }, 11 | "compileOnSave": true, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /functions/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | // -- Strict errors -- 4 | // These lint rules are likely always a good idea. 5 | 6 | // Force function overloads to be declared together. This ensures readers understand APIs. 7 | "adjacent-overload-signatures": true, 8 | 9 | // Do not allow the subtle/obscure comma operator. 10 | "ban-comma-operator": true, 11 | 12 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. 13 | "no-namespace": true, 14 | 15 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. 16 | "no-parameter-reassignment": true, 17 | 18 | // Force the use of ES6-style imports instead of /// imports. 19 | "no-reference": true, 20 | 21 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the 22 | // code currently being edited (they may be incorrectly handling a different type case that does not exist). 23 | "no-unnecessary-type-assertion": true, 24 | 25 | // Disallow nonsensical label usage. 26 | "label-position": true, 27 | 28 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. 29 | "no-conditional-assignment": true, 30 | 31 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). 32 | "no-construct": true, 33 | 34 | // Do not allow super() to be called twice in a constructor. 35 | "no-duplicate-super": true, 36 | 37 | // Do not allow the same case to appear more than once in a switch block. 38 | "no-duplicate-switch-case": true, 39 | 40 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this 41 | // rule. 42 | "no-duplicate-variable": [true, "check-parameters"], 43 | 44 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should 45 | // instead use a separate variable name. 46 | "no-shadowed-variable": true, 47 | 48 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. 49 | "no-empty": [true, "allow-empty-catch"], 50 | 51 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. 52 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. 53 | "no-floating-promises": true, 54 | 55 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when 56 | // deployed. 57 | "no-implicit-dependencies": true, 58 | 59 | // The 'this' keyword can only be used inside of classes. 60 | "no-invalid-this": true, 61 | 62 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. 63 | "no-string-throw": true, 64 | 65 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. 66 | "no-unsafe-finally": true, 67 | 68 | // Do not allow variables to be used before they are declared. 69 | "no-use-before-declare": true, 70 | 71 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); 72 | "no-void-expression": [true, "ignore-arrow-function-shorthand"], 73 | 74 | // Disallow duplicate imports in the same file. 75 | "no-duplicate-imports": true, 76 | 77 | 78 | // -- Strong Warnings -- 79 | // These rules should almost never be needed, but may be included due to legacy code. 80 | // They are left as a warning to avoid frustration with blocked deploys when the developer 81 | // understand the warning and wants to deploy anyway. 82 | 83 | // Warn when an empty interface is defined. These are generally not useful. 84 | "no-empty-interface": {"severity": "warning"}, 85 | 86 | // Warn when an import will have side effects. 87 | "no-import-side-effect": {"severity": "warning"}, 88 | 89 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for 90 | // most values and let for values that will change. 91 | "no-var-keyword": {"severity": "warning"}, 92 | 93 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. 94 | "triple-equals": {"severity": "warning"}, 95 | 96 | // Warn when using deprecated APIs. 97 | "deprecation": {"severity": "warning"}, 98 | 99 | // -- Light Warnings -- 100 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" 101 | // if TSLint supported such a level. 102 | 103 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. 104 | // (Even better: check out utils like .map if transforming an array!) 105 | "prefer-for-of": {"severity": "warning"}, 106 | 107 | // Warns if function overloads could be unified into a single function with optional or rest parameters. 108 | "unified-signatures": {"severity": "warning"}, 109 | 110 | // Prefer const for values that will not change. This better documents code. 111 | "prefer-const": {"severity": "warning"}, 112 | 113 | // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. 114 | "trailing-comma": {"severity": "warning"} 115 | }, 116 | 117 | "defaultSeverity": "error" 118 | } 119 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def parse_KV_file(file, separator='=') 14 | file_abs_path = File.expand_path(file) 15 | if !File.exists? file_abs_path 16 | return []; 17 | end 18 | pods_ary = [] 19 | skip_line_start_symbols = ["#", "/"] 20 | File.foreach(file_abs_path) { |line| 21 | next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } 22 | plugin = line.split(pattern=separator) 23 | if plugin.length == 2 24 | podname = plugin[0].strip() 25 | path = plugin[1].strip() 26 | podpath = File.expand_path("#{path}", file_abs_path) 27 | pods_ary.push({:name => podname, :path => podpath}); 28 | else 29 | puts "Invalid plugin specification: #{line}" 30 | end 31 | } 32 | return pods_ary 33 | end 34 | 35 | target 'Runner' do 36 | # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock 37 | # referring to absolute paths on developers' machines. 38 | system('rm -rf .symlinks') 39 | system('mkdir -p .symlinks/plugins') 40 | 41 | # Flutter Pods 42 | generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig') 43 | if generated_xcode_build_settings.empty? 44 | puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." 45 | end 46 | generated_xcode_build_settings.map { |p| 47 | if p[:name] == 'FLUTTER_FRAMEWORK_DIR' 48 | symlink = File.join('.symlinks', 'flutter') 49 | File.symlink(File.dirname(p[:path]), symlink) 50 | pod 'Flutter', :path => File.join(symlink, File.basename(p[:path])) 51 | end 52 | } 53 | 54 | # Plugin Pods 55 | plugin_pods = parse_KV_file('../.flutter-plugins') 56 | plugin_pods.map { |p| 57 | symlink = File.join('.symlinks', 'plugins', p[:name]) 58 | File.symlink(p[:path], symlink) 59 | pod p[:name], :path => File.join(symlink, 'ios') 60 | } 61 | end 62 | 63 | post_install do |installer| 64 | installer.pods_project.targets.each do |target| 65 | target.build_configurations.each do |config| 66 | config.build_settings['ENABLE_BITCODE'] = 'NO' 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @interface AppDelegate : FlutterAppDelegate 5 | 6 | @end 7 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.m: -------------------------------------------------------------------------------- 1 | #include "AppDelegate.h" 2 | #include "GeneratedPluginRegistrant.h" 3 | 4 | @implementation AppDelegate 5 | 6 | - (BOOL)application:(UIApplication *)application 7 | didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { 8 | [GeneratedPluginRegistrant registerWithRegistry:self]; 9 | // Override point for customization after application launch. 10 | return [super application:application didFinishLaunchingWithOptions:launchOptions]; 11 | } 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AYM1607/do_more/c415a2d1396e45facd11e6b60f2821424df99216/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | do_more 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | Editor 26 | CFBundleURLSchemes 27 | 28 | com.googleusercontent.apps.854370768062-bsmn2u8jngin4g6ior8crnir42t8p9u4 29 | 30 | 31 | 32 | CFBundleVersion 33 | $(FLUTTER_BUILD_NUMBER) 34 | LSRequiresIPhoneOS 35 | 36 | NSCameraUsageDescription 37 | Allow Do more to take pictures to add it to your gallery 38 | NSMicrophoneUsageDescription 39 | Allow Do more to take videos and add them to your gallery 40 | NSPhotoLibraryUsageDescription 41 | Allow Do more to upload photos that already took 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIMainStoryboardFile 45 | Main 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | UIViewControllerBasedStatusBarAppearance 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /ios/Runner/main.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import "AppDelegate.h" 4 | 5 | int main(int argc, char* argv[]) { 6 | @autoreleasepool { 7 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | import 'src/App.dart'; 5 | 6 | main() async { 7 | SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light); 8 | await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); 9 | runApp(App()); 10 | } 11 | -------------------------------------------------------------------------------- /lib/src/App.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'screens/archive_screen.dart'; 4 | import 'screens/event_screen.dart'; 5 | import 'screens/events_screen.dart'; 6 | import 'screens/home_screen.dart'; 7 | import 'screens/initial_loading_screen.dart'; 8 | import 'screens/login_screen.dart'; 9 | import 'screens/new_event_screen.dart'; 10 | import 'screens/new_image_screen.dart'; 11 | import 'screens/task_screen.dart'; 12 | 13 | class App extends StatelessWidget { 14 | Widget build(BuildContext context) { 15 | return MaterialApp( 16 | title: 'Do more', 17 | //home: Text('Start'), 18 | onGenerateRoute: routes, 19 | theme: ThemeData( 20 | // Accent color is set to be used by the floating action button. 21 | accentColor: Color(0xFF707070), 22 | iconTheme: IconThemeData( 23 | color: Colors.white, 24 | ), 25 | canvasColor: Color.fromRGBO(23, 25, 29, 1.0), 26 | cardColor: Color.fromRGBO(36, 39, 44, 1.0), 27 | cursorColor: Color.fromRGBO(112, 112, 112, 1), 28 | fontFamily: 'IBM Plex Sans', 29 | ), 30 | ); 31 | } 32 | 33 | Route routes(RouteSettings settings) { 34 | final List routeTokens = settings.name.split('/'); 35 | print(routeTokens); 36 | if (routeTokens.first == 'login') { 37 | return MaterialPageRoute( 38 | builder: (BuildContext context) { 39 | return LoginScreen(); 40 | }, 41 | ); 42 | } else if (routeTokens.first == 'home') { 43 | return MaterialPageRoute( 44 | builder: (BuildContext context) { 45 | return HomeScreen(); 46 | }, 47 | ); 48 | } else if (routeTokens.first == 'newTask') { 49 | return MaterialPageRoute( 50 | builder: (BuildContext context) { 51 | return TaskScreen(); 52 | }, 53 | ); 54 | } else if (routeTokens.first == 'editTask') { 55 | return MaterialPageRoute( 56 | builder: (BuildContext context) { 57 | return TaskScreen( 58 | isEdit: true, 59 | taskId: routeTokens[1], 60 | ); 61 | }, 62 | ); 63 | } else if (routeTokens.first == 'newImage') { 64 | String eventName; 65 | if (routeTokens.length > 1) { 66 | eventName = routeTokens[1]; 67 | } 68 | return MaterialPageRoute( 69 | builder: (BuildContext context) { 70 | return NewImageScreen( 71 | defaultEventName: eventName, 72 | ); 73 | }, 74 | ); 75 | } else if (routeTokens.first == 'event') { 76 | return MaterialPageRoute( 77 | builder: (BuildContext context) { 78 | return EventScreen( 79 | eventName: routeTokens[1], 80 | ); 81 | }, 82 | ); 83 | } else if (routeTokens.first == 'events') { 84 | return MaterialPageRoute( 85 | builder: (BuildContext context) { 86 | return EventsScreen(); 87 | }, 88 | ); 89 | } else if (routeTokens.first == 'newEvent') { 90 | return MaterialPageRoute( 91 | builder: (BuildContext contex) { 92 | return NewEventScreen(); 93 | }, 94 | ); 95 | } else if (routeTokens.first == 'archive') { 96 | return MaterialPageRoute( 97 | builder: (BuildContext context) { 98 | return ArchiveScreen(); 99 | }, 100 | ); 101 | } 102 | // Default route. 103 | return MaterialPageRoute( 104 | builder: (BuildContext context) { 105 | return InitialLoadingScreen(); 106 | }, 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/src/blocs/archive_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:rxdart/rxdart.dart'; 2 | 3 | import '../utils.dart' show kTaskListPriorityTransforemer; 4 | import '../models/task_model.dart'; 5 | import '../resources/firestore_provider.dart'; 6 | import '../resources/google_sign_in_provider.dart'; 7 | import '../services/auth_service.dart'; 8 | 9 | export '../services/auth_service.dart' show FirebaseUser; 10 | 11 | class ArchiveBloc { 12 | /// An instance of the auth service. 13 | AuthService _auth = authService; 14 | 15 | /// An instance of the firestore provider. 16 | FirestoreProvider _firestore = firestoreProvider; 17 | 18 | final _tasks = BehaviorSubject>(); 19 | 20 | // Stream getters. 21 | /// An observable of the current logged in user. 22 | Observable get userStream => _auth.userStream; 23 | 24 | /// An observable of the done tasks linked to the current user. 25 | Observable> get tasks => 26 | _tasks.stream.transform(kTaskListPriorityTransforemer); 27 | 28 | void fetchTasks() async { 29 | final userModel = await _auth.getCurrentUserModel(); 30 | _firestore.getUserTasks(userModel.username, done: true).pipe(_tasks); 31 | } 32 | 33 | void undoTask(TaskModel task) { 34 | _firestore.updateTask(task.id, done: false); 35 | } 36 | 37 | dispose() async { 38 | await _tasks.drain(); 39 | _tasks.close(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/blocs/event_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:meta/meta.dart'; 5 | import 'package:rxdart/rxdart.dart'; 6 | 7 | import '../mixins/upload_status_mixin.dart'; 8 | import '../models/event_model.dart'; 9 | import '../models/task_model.dart'; 10 | import '../models/user_model.dart'; 11 | import '../resources/firestore_provider.dart'; 12 | import '../resources/firebase_storage_provider.dart'; 13 | import '../services/auth_service.dart'; 14 | import '../utils.dart' 15 | show kTaskListPriorityTransforemer, getImageThumbnailPath; 16 | 17 | /// A business logic component that manages the state for an event screen. 18 | class EventBloc extends Object with UploadStatusMixin { 19 | /// The name of the event being shown. 20 | final String eventName; 21 | 22 | /// An instance of a firestore provider. 23 | final FirestoreProvider _firestore = firestoreProvider; 24 | 25 | /// An instance of the firebase sotrage provider. 26 | final FirebaseStorageProvider _storage = storageProvider; 27 | 28 | /// An instace of the auth service. 29 | final AuthService _auth = authService; 30 | 31 | /// A subject of list of task model. 32 | final _tasks = BehaviorSubject>(); 33 | 34 | /// A subject of the list of image paths for this event. 35 | final _imagesPaths = BehaviorSubject>(); 36 | 37 | /// A subject of String paths. 38 | final _imagesFetcher = PublishSubject(); 39 | 40 | /// A subject of String paths. 41 | final _imagesThumbnailsFetcher = PublishSubject(); 42 | 43 | /// A subject of a cache that contains the image files. 44 | final _thumbnails = BehaviorSubject>>(); 45 | 46 | /// A subject of a cache that contains the image files. 47 | final _images = BehaviorSubject>>(); 48 | 49 | /// The event being managed by this bloc. 50 | EventModel _event; 51 | 52 | /// The representation of the current signed in user. 53 | UserModel _user; 54 | 55 | /// Whether the event and user models have been fetched; 56 | Future _ready; 57 | // Stream getters. 58 | /// An observable of the tasks linked to the event. 59 | Observable> get eventTasks => 60 | _tasks.stream.transform(kTaskListPriorityTransforemer); 61 | 62 | /// An observable of the list of paths of images linked to this event. 63 | Observable> get imagesPaths => _imagesPaths.stream; 64 | 65 | /// An observable of a cache of the images thumbnails files. 66 | Observable>> get thumbnails => _thumbnails.stream; 67 | 68 | /// An observable of a cache of the images files. 69 | Observable>> get images => _images.stream; 70 | 71 | // Sinks getters. 72 | /// Starts the fetching process for an image given its path. 73 | Function(String) get fetchImage => _imagesFetcher.sink.add; 74 | 75 | /// Starts the fetching process for an image thumbail given its path. 76 | Function(String) get fetchThumbnail => _imagesThumbnailsFetcher.sink.add; 77 | 78 | EventBloc({ 79 | @required this.eventName, 80 | }) { 81 | initializeSnackBarStatus(); 82 | _ready = _initUserAndEvent(); 83 | _imagesFetcher.transform(_imagesTransformer()).pipe(_images); 84 | _imagesThumbnailsFetcher 85 | .transform(_imagesTransformer(isThumbnail: true)) 86 | .pipe(_thumbnails); 87 | } 88 | 89 | /// Initializes the value for the User and the Event models. 90 | Future _initUserAndEvent() async { 91 | final userModelFuture = _auth.getCurrentUserModel(); 92 | _user = await userModelFuture; 93 | _event = await _firestore.getEvent( 94 | (await userModelFuture).id, 95 | eventName: eventName, 96 | ); 97 | } 98 | 99 | /// Returns a stream transformer that creates a cache map from image storage 100 | /// bucket paths. 101 | ScanStreamTransformer>> _imagesTransformer({ 102 | bool isThumbnail = false, 103 | }) { 104 | final accumulator = (Map> cache, String path, _) { 105 | if (isThumbnail) { 106 | path = getImageThumbnailPath(path); 107 | } 108 | // Do not re-fetch the image if it resolved already. 109 | if (cache.containsKey(path)) { 110 | return cache; 111 | } 112 | cache[path] = _storage.getFile(path); 113 | return cache; 114 | }; 115 | 116 | return ScanStreamTransformer(accumulator, >{}); 117 | } 118 | 119 | /// Fetches the tasks for the current user that a part of the currently 120 | /// selected event. 121 | Future fetchTasks() async { 122 | await _ready; 123 | _firestore.getUserTasks(_user.username, event: eventName).pipe(_tasks); 124 | } 125 | 126 | /// Fetches the paths of all the images linked to this event. 127 | Future fetchImagesPaths() async { 128 | await _ready; 129 | _firestore.getEventObservable(_user.id, _event.id).transform( 130 | StreamTransformer>.fromHandlers( 131 | handleData: (event, sink) { 132 | sink.add(event.media); 133 | }, 134 | ), 135 | ).pipe(_imagesPaths); 136 | } 137 | 138 | /// Marks a task as done in the database. 139 | void markTaskAsDone(TaskModel task) async { 140 | _firestore.updateTask( 141 | task.id, 142 | done: true, 143 | ); 144 | } 145 | 146 | void dispose() async { 147 | await disposeUploadStatusMixin(); 148 | await _imagesThumbnailsFetcher.drain(); 149 | _imagesThumbnailsFetcher.close(); 150 | await _imagesFetcher.drain(); 151 | _imagesFetcher.close(); 152 | await _thumbnails.drain(); 153 | _thumbnails.close(); 154 | await _images.drain(); 155 | _images.close(); 156 | await _imagesPaths.drain(); 157 | _imagesPaths.close(); 158 | await _tasks.drain(); 159 | _tasks.close(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/src/blocs/events_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../models/event_model.dart'; 6 | import '../resources/firestore_provider.dart'; 7 | import '../resources/google_sign_in_provider.dart'; 8 | import '../services/auth_service.dart'; 9 | 10 | export '../services/auth_service.dart' show FirebaseUser; 11 | 12 | class EventsBloc { 13 | /// An instance of the auth service. 14 | AuthService _auth = authService; 15 | 16 | /// An instance of the firestore provider. 17 | FirestoreProvider _firestore = firestoreProvider; 18 | 19 | /// A subject of list of event model. 20 | final _events = BehaviorSubject>(); 21 | 22 | /// An observable of the current logged in user. 23 | Observable get userStream => _auth.userStream; 24 | 25 | /// An observable of the events linked to the current user. 26 | Observable> get events => _events.stream; 27 | 28 | /// Initiates the fetching process of events linked to the current user. 29 | Future fetchEvents() async { 30 | final userModel = await _auth.getCurrentUserModel(); 31 | _firestore.getUserEvents(userModel.id).pipe(_events); 32 | } 33 | 34 | dispose() async { 35 | await _events.drain(); 36 | _events.close(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/blocs/home_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../utils.dart' show kTaskListPriorityTransforemer; 6 | import '../mixins/upload_status_mixin.dart'; 7 | import '../models/task_model.dart'; 8 | import '../resources/firestore_provider.dart'; 9 | import '../services/auth_service.dart'; 10 | 11 | export '../services/auth_service.dart' show FirebaseUser; 12 | 13 | /// A business logic component that manages the state of the home screen. 14 | class HomeBloc extends Object with UploadStatusMixin { 15 | /// An instance of the auth service. 16 | final AuthService _auth = authService; 17 | 18 | /// An instance of the firebase repository. 19 | final FirestoreProvider _firestore = firestoreProvider; 20 | 21 | /// A subject of list of task model. 22 | final _tasks = BehaviorSubject>(); 23 | 24 | /// A subject of the search box updates. 25 | final _searchBoxText = BehaviorSubject(seedValue: ''); 26 | 27 | // Stream getters. 28 | 29 | // The result has to be a combination of the tasks stream and the text 30 | // stream, because otherwise, the tasks stream has no way of knowing when 31 | // there's a new update in the text. 32 | // 33 | // The transformations have to be applied after the combination, 34 | // otherwhise the value used for filtering is outdated and the list output is 35 | // not synchronized with the current value of the searhc box text. 36 | /// An observalbe of the taks of a user. 37 | Observable> get userTasks => 38 | Observable.combineLatest2, List>( 39 | _searchBoxText.stream, 40 | _tasks.stream, 41 | (text, tasks) { 42 | return tasks; 43 | }, 44 | ) 45 | .transform(_searchBoxTransformer()) 46 | .transform(kTaskListPriorityTransforemer); 47 | 48 | /// An observable of the current logged in user. 49 | Observable get userStream => _auth.userStream; 50 | 51 | HomeBloc() { 52 | initializeSnackBarStatus(); 53 | } 54 | 55 | // TODO: Include the priority in the filtering. 56 | /// Returns a stream transformer that filters the task with the text from 57 | /// the search box. 58 | StreamTransformer, List> _searchBoxTransformer() { 59 | return StreamTransformer.fromHandlers( 60 | handleData: (taskList, sink) { 61 | sink.add( 62 | taskList.where( 63 | (TaskModel task) { 64 | if (_searchBoxText.value == '') { 65 | return true; 66 | } 67 | // Return true if the text in the search box matches the title 68 | // or the text of the task. 69 | return task.event 70 | .toLowerCase() 71 | .contains(_searchBoxText.value.toLowerCase()) || 72 | task.text 73 | .toLowerCase() 74 | .contains(_searchBoxText.value.toLowerCase()); 75 | }, 76 | ).toList(), 77 | ); 78 | }, 79 | ); 80 | } 81 | 82 | /// Fetches the tasks for the current user. 83 | Future fetchTasks() async { 84 | final user = await _auth.currentUser; 85 | _firestore.getUserTasks(user.email).pipe(_tasks); 86 | } 87 | 88 | /// Returns a future of the avatar url for the current user. 89 | Future getUserAvatarUrl() async { 90 | final user = await _auth.currentUser; 91 | return user.photoUrl; 92 | } 93 | 94 | /// Returns a future of the display name for the current user. 95 | Future getUserDisplayName() async { 96 | final user = await _auth.currentUser; 97 | return user.displayName; 98 | } 99 | 100 | /// Marks a task as done in the database. 101 | void markTaskAsDone(TaskModel task) async { 102 | _firestore.updateTask( 103 | task.id, 104 | done: true, 105 | ); 106 | } 107 | 108 | /// Updates the serach box text. 109 | void updateSearchBoxText(String newText) { 110 | _searchBoxText.add(newText); 111 | } 112 | 113 | void dispose() async { 114 | await disposeUploadStatusMixin(); 115 | await _searchBoxText.drain(); 116 | _searchBoxText.close(); 117 | await _tasks.drain(); 118 | _tasks.close(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/src/blocs/new_event_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../utils.dart' show Validators; 6 | import '../models/event_model.dart'; 7 | import '../models/user_model.dart'; 8 | import '../resources/firestore_provider.dart'; 9 | import '../services/auth_service.dart'; 10 | 11 | /// Business loginc componente that manages the state of the new event screen. 12 | class NewEventBloc extends Object with Validators { 13 | /// An instance of the auth service. 14 | final AuthService _auth = authService; 15 | 16 | /// An instance of the firebase repository. 17 | final FirestoreProvider _firestore = firestoreProvider; 18 | 19 | /// A subject of the name of the event. 20 | final _eventName = BehaviorSubject(); 21 | 22 | /// A subject of the encoded ocurrance of this event. 23 | final _ocurrance = BehaviorSubject>(); 24 | 25 | // Streams getters. 26 | /// An observable of the name of the event. 27 | Observable get eventName => 28 | _eventName.stream.transform(stringNotEmptyValidator); 29 | 30 | /// An observable of the encoded ocurrance of this event. 31 | Observable> get ocurrance => 32 | _ocurrance.stream.transform(occuranceArrayValidator); 33 | 34 | /// An observable of the submit enabled flag. 35 | /// 36 | /// Only emits true when the event occurs at least once a week and the event 37 | /// name is not empty. 38 | Observable get submitEnabled => 39 | Observable.combineLatest2(eventName, ocurrance, (a, b) => true); 40 | 41 | //Sinks getters. 42 | /// Changes the current task event name. 43 | Function(String) get changeEventName => _eventName.sink.add; 44 | 45 | /// Change the current ocurrance. 46 | Function(List) get changeOcurrance => _ocurrance.sink.add; 47 | 48 | //TODO: use a transaction to make the updates in firestore be atomic. 49 | /// Adds the event to the database. 50 | Future submit() async { 51 | final UserModel userModel = await _auth.getCurrentUserModel(); 52 | final event = EventModel( 53 | when: _ocurrance.value, 54 | name: _eventName.value, 55 | pendigTasks: 0, 56 | mediumPriority: 0, 57 | highPriority: 0, 58 | lowPriority: 0, 59 | media: [], 60 | ); 61 | await _firestore.updateUser(userModel.id, 62 | events: [_eventName.value]..addAll(userModel.events)); 63 | return _firestore.addEvent(userModel.id, event); 64 | } 65 | 66 | dispose() async { 67 | await _eventName.drain(); 68 | _eventName.close(); 69 | await _ocurrance.drain(); 70 | _ocurrance.close(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/blocs/new_image_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:rxdart/rxdart.dart'; 5 | 6 | import '../models/event_model.dart'; 7 | import '../models/user_model.dart'; 8 | import '../services/auth_service.dart'; 9 | import '../resources/firebase_storage_provider.dart'; 10 | import '../resources/firestore_provider.dart'; 11 | 12 | /// A business logic component that manages the state for the new image screen. 13 | class NewImageBloc { 14 | /// An instance of the auth service. 15 | final AuthService _auth = authService; 16 | 17 | /// An instance of the firestore provider. 18 | final FirestoreProvider _firestore = firestoreProvider; 19 | 20 | /// An instance of the firebase storage provider. 21 | final FirebaseStorageProvider _storage = storageProvider; 22 | 23 | /// A subject of the current picture file. 24 | final _picture = BehaviorSubject(); 25 | 26 | /// A subject of the current user model. 27 | final _user = BehaviorSubject(); 28 | 29 | /// A subject of the current media event name. 30 | final _eventName = BehaviorSubject(); 31 | 32 | /// Default event name. 33 | final String defaultEventName; 34 | 35 | NewImageBloc({this.defaultEventName}) { 36 | if (defaultEventName != null && defaultEventName != '') { 37 | _eventName.sink.add(defaultEventName); 38 | } 39 | setCurrentUser(); 40 | } 41 | 42 | //Stream getters. 43 | /// An observable of the picture file. 44 | Observable get picture => _picture.stream; 45 | 46 | /// An observable of the user model. 47 | Observable get userModelStream => _user.stream; 48 | 49 | /// An observable of the media event name. 50 | Observable get eventName => _eventName.stream; 51 | 52 | /// An observable of the submit enabled flag. 53 | Observable get submitEnabled => 54 | Observable.combineLatest2(_picture, _eventName, (a, b) => true); 55 | 56 | //Sink getters. 57 | /// Changes the current picture file. 58 | Function(File) get changePicture => _picture.sink.add; 59 | 60 | /// Changes the current media event name. 61 | Function(String) get changeEventName => _eventName.sink.add; 62 | 63 | /// Fetches and updates the current user. 64 | Future setCurrentUser() async { 65 | final user = await _auth.currentUser; 66 | final userModel = await _firestore.getUser(username: user.email); 67 | _user.add(userModel); 68 | } 69 | 70 | /// Saves the current picture to the database. 71 | Future submit() async { 72 | final user = _user.value; 73 | final StorageUploadTask uploadTask = 74 | _storage.uploadFile(_picture.value, folder: '${user.id}/'); 75 | final storageSnapshot = await uploadTask.onComplete; 76 | final imagePath = storageSnapshot.ref.path; 77 | EventModel event = 78 | await _firestore.getEvent(user.id, eventName: _eventName.value); 79 | final newMediaList = List.from(event.media)..add(imagePath); 80 | await _firestore.updateEvent(user.id, event.id, media: newMediaList); 81 | } 82 | 83 | void dispose() { 84 | _eventName.close(); 85 | _picture.close(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/src/blocs/task_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../utils.dart' show Validators; 6 | import '../models/task_model.dart'; 7 | import '../models/user_model.dart'; 8 | import '../resources/firestore_provider.dart'; 9 | import '../services/auth_service.dart'; 10 | 11 | /// Business logic component that manages the state for the task screen. 12 | class TaskBloc extends Object with Validators { 13 | /// An instance of the auth service. 14 | final AuthService _auth = authService; 15 | 16 | /// An instance of the firebase repository. 17 | final FirestoreProvider _firestore = firestoreProvider; 18 | 19 | /// A subject of user model. 20 | /// 21 | /// Needed to access the username of the owner of the task. 22 | final _user = BehaviorSubject(); 23 | 24 | /// A subject of task event name. 25 | /// 26 | /// Only needed if in edit mode. This will receive updates when the task to be 27 | /// edited is fetched. 28 | final _eventName = BehaviorSubject(); 29 | 30 | /// A subject of task text. 31 | final _taskText = BehaviorSubject(); 32 | 33 | /// A subject of the text of the current global task. 34 | /// 35 | /// Only needed if in edit mode. This will receive updates when the task to be 36 | /// edited is fetched. 37 | final _textInitialValue = BehaviorSubject(); 38 | 39 | /// The priority of the current task. 40 | TaskPriority priority = TaskPriority.high; 41 | 42 | /// The id of the current task. 43 | /// 44 | /// Only needed if in edit mode. Not needed by the UI. 45 | final String taskId; 46 | 47 | // Stream getters. 48 | /// An observable of the current user model. 49 | Observable get userModelStream => _user.stream; 50 | 51 | /// An observable of the current task event name. 52 | Observable get eventName => _eventName.stream; 53 | 54 | /// An observable of the current task text. 55 | /// 56 | /// Emits an error if the string added is empty. 57 | Observable get taskText => 58 | _taskText.stream.transform(stringNotEmptyValidator); 59 | 60 | /// An observable of the submit enabled flag. 61 | /// 62 | /// Only emits true when an event is selected and the task text is not empty. 63 | Observable get submitEnabled => 64 | Observable.combineLatest2(eventName, taskText, (a, b) => true); 65 | 66 | /// An observable of the text of the global selected task. 67 | /// 68 | /// Only needed in edit mode. 69 | Observable get textInitialvalue => _textInitialValue.stream; 70 | 71 | //Sinks getters. 72 | /// Changes the current task event name. 73 | Function(String) get changeEventName => _eventName.sink.add; 74 | 75 | ///Changes the current task text. 76 | Function(String) get changeTaskText => _taskText.sink.add; 77 | 78 | TaskBloc({this.taskId}) { 79 | setCurrentUser(); 80 | } 81 | 82 | /// Changes the current task priority. 83 | void setPriority(TaskPriority newPriority) { 84 | priority = newPriority; 85 | } 86 | 87 | //TODO: Figure out how to update the event and user properties if needed. 88 | // as in the number of pending high tasks for example. 89 | /// Saves or updates the current task in the database. 90 | Future submit(isEdit) { 91 | if (isEdit) { 92 | return _firestore.updateTask( 93 | taskId, 94 | text: _taskText.value, 95 | priority: TaskModel.ecodedPriority(priority), 96 | ); 97 | } 98 | final newTask = TaskModel( 99 | text: _taskText.value, 100 | priority: priority, 101 | event: _eventName.value, 102 | ownerUsername: _user.value.username, 103 | done: false, 104 | ); 105 | return _firestore.addTask(newTask); 106 | } 107 | 108 | /// Fetches and updates the current user. 109 | Future setCurrentUser() async { 110 | final user = await _auth.currentUser; 111 | final userModel = await _firestore.getUser(username: user.email); 112 | _user.add(userModel); 113 | } 114 | 115 | /// Grabs the data from the current global task and pipes it to the local 116 | /// streams. 117 | void populateWithCurrentTask() async { 118 | final task = await _firestore.getTask(taskId); 119 | _textInitialValue.sink.add(task.text); 120 | changeEventName(task.event); 121 | changeTaskText(task.text); 122 | } 123 | 124 | void dispose() { 125 | _textInitialValue.close(); 126 | _taskText.close(); 127 | _user.close(); 128 | _eventName.close(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/src/mixins/tile_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | mixin Tile { 4 | BoxDecoration tileDecoration(Color color) { 5 | return BoxDecoration( 6 | color: color, 7 | borderRadius: BorderRadius.only( 8 | topRight: Radius.circular(8), 9 | bottomRight: Radius.circular(8), 10 | ), 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/mixins/upload_status_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../utils.dart'; 6 | import '../services/upload_status_service.dart'; 7 | 8 | mixin UploadStatusMixin { 9 | /// An instance of the upload status service. 10 | final UploadStatusService uploadStatusSer = uploadStatusService; 11 | 12 | /// A subject of a flag that indicates if there is a snack bar showing. 13 | final snackBarStatusSubject = BehaviorSubject(seedValue: false); 14 | 15 | /// An observable of a flag that indicates whether or not a snackBar is 16 | /// currently showing. 17 | ValueObservable get snackBarStatus => snackBarStatusSubject.stream; 18 | 19 | /// Updates the snack bar status. 20 | Function(bool) get updateSnackBarStatus => snackBarStatusSubject.sink.add; 21 | 22 | /// An observable of the status of files being uploaded. 23 | Observable get uploadStatus => uploadStatusSer.status; 24 | 25 | /// Disposes instance variables that are part of the mixin. 26 | Future disposeUploadStatusMixin() async { 27 | await snackBarStatusSubject.drain(); 28 | snackBarStatusSubject.close(); 29 | } 30 | 31 | /// Initializes the [snacBarStatusSubject]. 32 | /// 33 | /// The raw status coming from the [UploadStatusService] is transformed and 34 | /// turned into a stream that emits [true] when the snackbar should be visible 35 | /// and false otherwise. This is piped to [the snackBarStatusSubject]. 36 | void initializeSnackBarStatus() { 37 | uploadStatusService.status 38 | .transform(StreamTransformer.fromHandlers( 39 | handleData: (value, sink) { 40 | sink.add(value.numberOfFiles != 0); 41 | }, 42 | )) 43 | .transform(DistinctStreamTransformer()) 44 | .pipe(snackBarStatusSubject); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/models/event_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// A user's event. 4 | /// 5 | /// Represents all the data linked to an event. 6 | class EventModel { 7 | /// The event id in the database. 8 | final String id; 9 | 10 | /// The event name. 11 | final String name; 12 | 13 | /// The amount of pending tasks linked to this event. 14 | final int pendigTasks; 15 | 16 | // TODO: Create a data model for [when]. It should support both days and times. 17 | /// A representation of the ocurrance of this event. 18 | /// 19 | /// This list whould contain five items each representing a day of the week. 20 | /// If the value for a day is true then the event happens at that day. 21 | final List when; 22 | 23 | /// The media files linked to this event. 24 | /// 25 | /// The list items are data bucket paths. 26 | final List media; 27 | 28 | /// The amount of high priority pending tasks linked to this event. 29 | final int highPriority; 30 | 31 | /// The amount of medium priority pending tasks linked to this event. 32 | final int mediumPriority; 33 | 34 | /// The amount of low priority pending tasks linked to this event. 35 | final int lowPriority; 36 | 37 | int get pendingTasks => highPriority + mediumPriority + lowPriority; 38 | 39 | EventModel({ 40 | this.id, 41 | @required this.name, 42 | @required this.pendigTasks, 43 | @required this.when, 44 | @required this.media, 45 | @required this.highPriority, 46 | @required this.mediumPriority, 47 | @required this.lowPriority, 48 | }); 49 | 50 | /// Creates an [EventModel] from a firestore map. 51 | /// 52 | /// The database id for the event is not provided inside the map but should 53 | /// always be specified. 54 | EventModel.fromFirestore(Map firestoreMap, 55 | {@required String id}) 56 | : id = id, 57 | name = firestoreMap["name"], 58 | pendigTasks = firestoreMap["pendingTasks"], 59 | when = firestoreMap["when"].cast(), 60 | media = firestoreMap["media"].cast(), 61 | highPriority = firestoreMap["highPriority"], 62 | mediumPriority = firestoreMap["mediumPriority"], 63 | lowPriority = firestoreMap["lowPriority"]; 64 | 65 | /// Returns a map that contains all the event's fields. 66 | /// 67 | /// The id field does not need to be included, it is provided separately. 68 | Map toFirestoreMap() { 69 | return { 70 | "name": name, 71 | "pendingTasks": pendigTasks, 72 | "when": when, 73 | "media": media, 74 | "highPriority": highPriority, 75 | "mediumPriority": mediumPriority, 76 | "lowPriority": lowPriority, 77 | }; 78 | } 79 | 80 | @override 81 | int get hashCode => 82 | id.hashCode ^ 83 | name.hashCode ^ 84 | pendigTasks.hashCode ^ 85 | when.hashCode ^ 86 | media.hashCode ^ 87 | highPriority.hashCode ^ 88 | mediumPriority.hashCode ^ 89 | lowPriority.hashCode; 90 | 91 | @override 92 | bool operator ==(Object other) => 93 | identical(this, other) || 94 | other is EventModel && 95 | runtimeType == other.runtimeType && 96 | id == other.id && 97 | name == other.name && 98 | pendigTasks == other.pendigTasks && 99 | //when == other.when && 100 | //media == other.media && 101 | //tasks == other.tasks && 102 | highPriority == other.highPriority && 103 | mediumPriority == other.mediumPriority && 104 | lowPriority == other.lowPriority; 105 | } 106 | -------------------------------------------------------------------------------- /lib/src/models/summary_model.dart: -------------------------------------------------------------------------------- 1 | /// A summary of a user's week. 2 | /// 3 | /// It represents how many tasks were completed and added by a user every day. 4 | class SummaryModel { 5 | /// Amount of tasks completed on Monday. 6 | int completedMonday; 7 | 8 | /// Amount od tasks added on Monday. 9 | int addedMonday; 10 | 11 | /// Amount of tasks completed on Tuesday. 12 | int completedTuesday; 13 | 14 | /// Amount od tasks added on Tuesday. 15 | int addedTuesday; 16 | 17 | /// Amount of tasks completed on Wednesday. 18 | int completedWednesday; 19 | 20 | /// Amount od tasks added on Wednesday. 21 | int addedWednesday; 22 | 23 | /// Amount of tasks completed on Thursday. 24 | int completedThursday; 25 | 26 | /// Amount od tasks added on Thursday. 27 | int addedThursday; 28 | 29 | /// Amount of tasks completed on Friday. 30 | int completedFriday; 31 | 32 | /// Amount od tasks added on Friday. 33 | int addedFriday; 34 | 35 | SummaryModel({ 36 | this.completedMonday = 0, 37 | this.addedMonday = 0, 38 | this.completedTuesday = 0, 39 | this.addedTuesday = 0, 40 | this.completedWednesday = 0, 41 | this.addedWednesday = 0, 42 | this.completedThursday = 0, 43 | this.addedThursday = 0, 44 | this.completedFriday = 0, 45 | this.addedFriday = 0, 46 | }); 47 | 48 | /// Creates a [SummaryModel] from a map. 49 | SummaryModel.fromMap(Map map) { 50 | completedMonday = map["completedMonday"]; 51 | addedMonday = map["addedMonday"]; 52 | completedTuesday = map["completedTuesday"]; 53 | addedTuesday = map["addedTuesday"]; 54 | completedWednesday = map["completedWednesday"]; 55 | addedWednesday = map["addedWednesday"]; 56 | completedThursday = map["completedThursday"]; 57 | addedThursday = map["addedThursday"]; 58 | completedFriday = map["completedFriday"]; 59 | addedFriday = map["addedFriday"]; 60 | } 61 | 62 | /// Returns a map representation of the model. 63 | Map toMap() { 64 | return { 65 | "completedMonday": completedMonday, 66 | "addedMonday": addedMonday, 67 | "completedTuesday": completedTuesday, 68 | "addedTuesday": addedTuesday, 69 | "completedWednesday": completedWednesday, 70 | "addedWednesday": addedWednesday, 71 | "completedThursday": completedThursday, 72 | "addedThursday": addedThursday, 73 | "completedFriday": completedFriday, 74 | "addedFriday": addedFriday, 75 | }; 76 | } 77 | 78 | @override 79 | int get hashCode => 80 | completedMonday.hashCode ^ 81 | addedMonday.hashCode ^ 82 | completedTuesday.hashCode ^ 83 | addedTuesday.hashCode ^ 84 | completedWednesday.hashCode ^ 85 | addedWednesday.hashCode ^ 86 | completedThursday.hashCode ^ 87 | addedThursday.hashCode ^ 88 | completedFriday.hashCode ^ 89 | addedFriday.hashCode; 90 | 91 | @override 92 | bool operator ==(Object other) => 93 | identical(this, other) || 94 | other is SummaryModel && 95 | runtimeType == other.runtimeType && 96 | completedMonday == other.completedMonday && 97 | addedMonday == other.addedMonday && 98 | completedTuesday == other.completedTuesday && 99 | addedTuesday == other.addedTuesday && 100 | completedWednesday == other.completedWednesday && 101 | addedWednesday == other.addedWednesday && 102 | completedThursday == other.completedThursday && 103 | addedThursday == other.addedThursday && 104 | completedFriday == other.completedFriday && 105 | addedFriday == other.addedFriday; 106 | } 107 | -------------------------------------------------------------------------------- /lib/src/models/task_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// A user's task. 4 | /// 5 | /// Represents a task linked to a user. 6 | class TaskModel { 7 | /// The task id in the database. 8 | final String id; 9 | 10 | /// The task's text. 11 | final String text; 12 | 13 | /// The priority of this task. 14 | final TaskPriority priority; 15 | 16 | /// The username of the user that owns this task. 17 | /// 18 | /// It represents an email. 19 | final String ownerUsername; 20 | 21 | /// Wether a task has been marked as done or not. 22 | final bool done; 23 | 24 | /// The name of the event that contains this task. 25 | final String event; 26 | 27 | /// Creates a task model. 28 | TaskModel({ 29 | this.id, 30 | @required this.text, 31 | @required this.priority, 32 | @required this.ownerUsername, 33 | @required this.done, 34 | @required this.event, 35 | }); 36 | 37 | /// Creates a task model from a map. 38 | /// 39 | /// The database id for the event is not provided inside the map but should 40 | /// always be specified. 41 | TaskModel.fromFirestore(Map firestoreMap, 42 | {@required String id}) 43 | : id = id, 44 | text = firestoreMap["text"], 45 | priority = decodedPriority(firestoreMap["priority"]), 46 | ownerUsername = firestoreMap["ownerUsername"], 47 | done = firestoreMap["done"], 48 | event = firestoreMap["event"]; 49 | 50 | /// Returns a map representation of the task. 51 | /// 52 | /// Encodes properties where required. 53 | Map toFirestoreMap() { 54 | return { 55 | "text": text, 56 | "priority": ecodedPriority(priority), 57 | "ownerUsername": ownerUsername, 58 | "done": done, 59 | "event": event, 60 | }; 61 | } 62 | 63 | /// Returns a text representation of the task priority. 64 | String getPriorityText() { 65 | switch (priority) { 66 | case TaskPriority.low: 67 | return 'Low'; 68 | break; 69 | case TaskPriority.medium: 70 | return 'Medium'; 71 | break; 72 | case TaskPriority.high: 73 | return 'High'; 74 | break; 75 | default: 76 | return 'None'; 77 | } 78 | } 79 | 80 | /// Returns a [TaskPriority] from an integer. 81 | static TaskPriority decodedPriority(int priority) { 82 | switch (priority) { 83 | case 0: 84 | return TaskPriority.low; 85 | break; 86 | case 1: 87 | return TaskPriority.medium; 88 | break; 89 | case 2: 90 | return TaskPriority.high; 91 | break; 92 | default: 93 | return TaskPriority.none; 94 | } 95 | } 96 | 97 | /// Returns an int from a [TaskPriority]. 98 | static int ecodedPriority(TaskPriority priority) { 99 | switch (priority) { 100 | case TaskPriority.low: 101 | return 0; 102 | break; 103 | case TaskPriority.medium: 104 | return 1; 105 | break; 106 | case TaskPriority.high: 107 | return 2; 108 | break; 109 | default: 110 | return -1; 111 | } 112 | } 113 | 114 | @override 115 | int get hashCode => 116 | id.hashCode ^ 117 | text.hashCode ^ 118 | priority.hashCode ^ 119 | ownerUsername.hashCode ^ 120 | done.hashCode ^ 121 | event.hashCode; 122 | 123 | @override 124 | bool operator ==(Object other) => 125 | identical(this, other) || 126 | runtimeType == other.runtimeType && 127 | other is TaskModel && 128 | id == other.id && 129 | text == other.text && 130 | priority == other.priority && 131 | ownerUsername == other.ownerUsername && 132 | done == other.done && 133 | event == other.event; 134 | } 135 | 136 | /// A representation of the priority of a task. 137 | enum TaskPriority { 138 | high, 139 | medium, 140 | low, 141 | none, 142 | } 143 | -------------------------------------------------------------------------------- /lib/src/models/user_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | import 'summary_model.dart'; 3 | 4 | /// An app user. 5 | /// 6 | /// Represents all of a users data. 7 | class UserModel { 8 | /// The document id that corresponds to the user in the database. 9 | final String id; 10 | 11 | /// The users email address. 12 | final String username; 13 | 14 | /// An array of task ids. 15 | final List tasks; 16 | 17 | /// An array of event names. 18 | final List events; 19 | 20 | /// Added and finished tasks for the current week. 21 | final SummaryModel summary; 22 | 23 | /// Pending high priority tasks. 24 | final int pendingHigh; 25 | 26 | /// Pendign medium priority tasks. 27 | final int pendingMedium; 28 | 29 | /// Pending low priority tasks. 30 | final int pendingLow; 31 | 32 | /// Creates a user model. 33 | UserModel({ 34 | this.id, 35 | @required this.username, 36 | @required this.tasks, 37 | @required this.summary, 38 | @required this.pendingHigh, 39 | @required this.pendingMedium, 40 | @required this.pendingLow, 41 | @required this.events, 42 | }); 43 | 44 | ///Create a [UserModel] from a map. 45 | /// 46 | /// The database id for the event is not provided inside the map but should 47 | /// always be specified. 48 | UserModel.fromFirestore(Map firestoreMap, 49 | {@required String id}) 50 | : id = id, 51 | username = firestoreMap["username"], 52 | tasks = firestoreMap["tasks"].cast(), 53 | events = firestoreMap["events"].cast(), 54 | summary = 55 | SummaryModel.fromMap(firestoreMap["summary"].cast()), 56 | pendingHigh = firestoreMap["pendingHigh"], 57 | pendingMedium = firestoreMap["pendingMedium"], 58 | pendingLow = firestoreMap["pendingLow"]; 59 | 60 | /// Returns a map representation of the user model. 61 | Map toFirestoreMap() { 62 | return { 63 | "username": username, 64 | "tasks": tasks, 65 | "events": events, 66 | "summary": summary.toMap(), 67 | "pendingHigh": pendingHigh, 68 | "pendingMedium": pendingMedium, 69 | "pendingLow": pendingLow, 70 | }; 71 | } 72 | 73 | @override 74 | int get hashCode => 75 | id.hashCode ^ 76 | username.hashCode ^ 77 | summary.hashCode ^ 78 | pendingHigh.hashCode ^ 79 | pendingMedium.hashCode ^ 80 | pendingLow.hashCode; 81 | 82 | @override 83 | bool operator ==(Object other) => 84 | identical(this, other) || 85 | runtimeType == other.runtimeType && 86 | other is UserModel && 87 | id == other.id && 88 | username == other.username && 89 | summary == other.summary && 90 | pendingHigh == other.pendingHigh && 91 | pendingMedium == other.pendingMedium && 92 | pendingLow == other.pendingLow; 93 | } 94 | -------------------------------------------------------------------------------- /lib/src/resources/firebase_storage_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:typed_data'; 4 | 5 | import 'package:firebase_storage/firebase_storage.dart'; 6 | import 'package:path_provider/path_provider.dart'; 7 | import 'package:uuid/uuid.dart'; 8 | 9 | import '../services/upload_status_service.dart'; 10 | 11 | export 'package:firebase_storage/firebase_storage.dart' 12 | show StorageUploadTask, StorageTaskSnapshot; 13 | 14 | /// A connection to the firebase sotrage bucket. 15 | class FirebaseStorageProvider { 16 | /// An instance of the upload status service. 17 | final UploadStatusService _uploadStatus = uploadStatusService; 18 | 19 | /// The reference to the root path of the storage bucket. 20 | final StorageReference _storage; 21 | 22 | /// An instance of a uuid generator. 23 | final Uuid _uuid; 24 | 25 | // [FirebaseStorage] and [Uuid] instances can be injected for testing purposes. 26 | // Don't remove. 27 | FirebaseStorageProvider([StorageReference storage, Uuid uuid]) 28 | : _storage = storage ?? FirebaseStorage.instance.ref(), 29 | _uuid = uuid ?? Uuid(); 30 | 31 | /// Uploads a given file to the firebase storage bucket. 32 | /// 33 | /// It returns a [StorageUploadTask] which contains the status of the upload. 34 | /// The [type] parameter allows you to specify the extension of the file bein 35 | /// uploaded, it defaults to png. 36 | StorageUploadTask uploadFile(File file, 37 | {String folder = '', String type = 'png'}) { 38 | folder = folder.startsWith('/') ? folder.substring(1) : folder; 39 | folder = folder.endsWith('/') ? folder : folder += '/'; 40 | final String fileId = _uuid.v1(); 41 | final StorageReference fileReference = 42 | _storage.child('$folder$fileId.$type'); 43 | final uploadTask = fileReference.putFile( 44 | file, 45 | StorageMetadata(contentType: 'image/png'), 46 | ); 47 | _uploadStatus.addNewUpload(uploadTask); 48 | return uploadTask; 49 | } 50 | 51 | /// Deletes a file from the firebase storage bucket given its path. 52 | Future deleteFile(String path) { 53 | return _storage.child(path).delete(); 54 | } 55 | 56 | // TODO: Add tests for this method. 57 | // TODO: Delete the tmp file when the app closes. 58 | // TODO: Add support for requests taking too long not only errors. 59 | /// Returns a future of the file from the path. 60 | /// 61 | /// Downloads the raw bytes from the sotrage buckets and converts it to a file. 62 | /// The fetching process is repeated up to [retries] times with a [retryDelay] 63 | /// delay in between tries. 64 | Future getFile( 65 | String path, { 66 | int retries = 3, 67 | Duration retryDelay = const Duration(seconds: 2), 68 | }) async { 69 | final Directory tmp = await getTemporaryDirectory(); 70 | final fileName = path.split('/').last; 71 | final file = File('${tmp.path}/$fileName'); 72 | 73 | // Don't re-fetch if the file is already in the temp directory. 74 | if (await file.exists()) { 75 | return file; 76 | } 77 | Uint8List bytes; 78 | // Repeat until the fetching process is successful or the number of retries 79 | // is excceded. 80 | while (bytes == null && retries > 0) { 81 | final fileReference = _storage.child(path); 82 | // Assing null to metadata if there's an error during the fetching process. 83 | // aka the file potentially doesn't exist. 84 | final metadata = 85 | await fileReference.getMetadata().catchError((error) => null); 86 | // Restart the fetching process if there was an error. 87 | if (metadata == null) { 88 | print('retrying'); 89 | await Future.delayed(retryDelay); 90 | retries -= 1; 91 | continue; 92 | } 93 | bytes = await fileReference.getData(metadata.sizeBytes); 94 | print('done getting data'); 95 | } 96 | if (bytes == null) { 97 | return null; 98 | } 99 | return file.writeAsBytes(bytes); 100 | } 101 | } 102 | 103 | final storageProvider = FirebaseStorageProvider(); 104 | -------------------------------------------------------------------------------- /lib/src/resources/google_sign_in_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_auth/firebase_auth.dart'; 4 | import 'package:google_sign_in/google_sign_in.dart'; 5 | import 'package:rxdart/rxdart.dart'; 6 | 7 | export 'package:firebase_auth/firebase_auth.dart' show FirebaseUser; 8 | 9 | /// Google authentication provider. 10 | /// 11 | /// Connects to both Google and Firebase to authenticate a user. 12 | class GoogleSignInProvider { 13 | final GoogleSignIn _googleSignIn; 14 | final FirebaseAuth _auth; 15 | 16 | // Instances of [GoogleSignIn] and [FirebaseAuth] can be injected for testing 17 | // purposes. Don't remove. 18 | GoogleSignInProvider([GoogleSignIn googleSignIn, FirebaseAuth firebaseAuth]) 19 | : _googleSignIn = googleSignIn ?? GoogleSignIn(), 20 | _auth = firebaseAuth ?? FirebaseAuth.instance; 21 | 22 | /// A stream of [FirebaseUser] that notifies when a user signs in and out. 23 | Observable get onAuthStateChange => 24 | Observable(_auth.onAuthStateChanged); 25 | 26 | /// Returns a future of [FirebaseUser]. 27 | /// 28 | /// Initiates the Google authentication flow. Returns null if the flow fails 29 | /// or gets cancelled. 30 | /// 31 | /// If the Google account is being used for the first time, a Firebase user 32 | /// is also created thus this method also works as sign up. 33 | Future signIn() async { 34 | try { 35 | final GoogleSignInAccount googleUser = await _googleSignIn.signIn(); 36 | final GoogleSignInAuthentication googleAuth = 37 | await googleUser.authentication; 38 | 39 | final AuthCredential credential = GoogleAuthProvider.getCredential( 40 | accessToken: googleAuth.accessToken, 41 | idToken: googleAuth.idToken, 42 | ); 43 | 44 | final FirebaseUser user = await _auth.signInWithCredential(credential); 45 | return user; 46 | } catch (e) { 47 | print('Error signing in with Google: $e'); 48 | } 49 | return null; 50 | } 51 | 52 | /// Returns a [FirebaseUser] if already signed in, returns null otherwhise. 53 | Future getCurrentUser() async { 54 | return await _auth.currentUser(); 55 | } 56 | 57 | /// Signs a user out. 58 | /// 59 | /// Deletes the firebase user from disk and disconnects the Google user too. 60 | Future signOut() async { 61 | await _googleSignIn.disconnect(); 62 | await _auth.signOut(); 63 | } 64 | } 65 | 66 | final signInProvider = GoogleSignInProvider(); 67 | -------------------------------------------------------------------------------- /lib/src/screens/archive_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide AppBar; 2 | 3 | import '../blocs/archive_bloc.dart'; 4 | import '../models/task_model.dart'; 5 | import '../widgets/app_bar.dart'; 6 | import '../widgets/loading_indicator.dart'; 7 | import '../widgets/populated_drawer.dart'; 8 | import '../widgets/task_list_tile.dart'; 9 | 10 | class ArchiveScreen extends StatefulWidget { 11 | _ArchiveScreenstate createState() => _ArchiveScreenstate(); 12 | } 13 | 14 | class _ArchiveScreenstate extends State { 15 | final bloc = ArchiveBloc(); 16 | 17 | initState() { 18 | super.initState(); 19 | bloc.fetchTasks(); 20 | } 21 | 22 | Widget build(BuildContext context) { 23 | return StreamBuilder( 24 | stream: bloc.userStream, 25 | builder: (context, AsyncSnapshot userSnap) { 26 | String userAvatarUrl, userDisplayName = '', userEmail = ''; 27 | 28 | if (userSnap.hasData) { 29 | userAvatarUrl = userSnap.data.photoUrl; 30 | userDisplayName = userSnap.data.displayName; 31 | userEmail = userSnap.data.email; 32 | } 33 | 34 | return Scaffold( 35 | drawer: PopulatedDrawer( 36 | userAvatarUrl: userAvatarUrl, 37 | userDisplayName: userDisplayName, 38 | userEmail: userEmail, 39 | selectedScreen: Screen.archive, 40 | ), 41 | appBar: AppBar( 42 | title: 'Archive', 43 | hasDrawer: true, 44 | ), 45 | body: StreamBuilder( 46 | stream: bloc.tasks, 47 | builder: (context, AsyncSnapshot> tasksSnap) { 48 | if (!tasksSnap.hasData) { 49 | return Center( 50 | child: LoadingIndicator(), 51 | ); 52 | } 53 | 54 | return buildList(tasksSnap.data); 55 | }, 56 | ), 57 | ); 58 | }, 59 | ); 60 | } 61 | 62 | Widget buildList(List tasks) { 63 | return ListView( 64 | padding: EdgeInsets.only(top: 15), 65 | children: tasks 66 | .map( 67 | (task) => Padding( 68 | padding: EdgeInsets.only(bottom: 15), 69 | child: TaskListTile( 70 | task: task, 71 | onUndo: () => bloc.undoTask(task), 72 | ), 73 | ), 74 | ) 75 | .toList(), 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/src/screens/events_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide AppBar; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import '../blocs/events_bloc.dart'; 5 | import '../models/event_model.dart'; 6 | import '../widgets/app_bar.dart'; 7 | import '../widgets/event_list_tile.dart'; 8 | import '../widgets/loading_indicator.dart'; 9 | import '../widgets/populated_drawer.dart'; 10 | 11 | class EventsScreen extends StatefulWidget { 12 | @override 13 | _EventsScreenState createState() => _EventsScreenState(); 14 | } 15 | 16 | class _EventsScreenState extends State { 17 | /// An instance of the bloc for this screen. 18 | final EventsBloc bloc = EventsBloc(); 19 | 20 | initState() { 21 | super.initState(); 22 | bloc.fetchEvents(); 23 | } 24 | 25 | Widget build(BuildContext context) { 26 | return StreamBuilder( 27 | stream: bloc.userStream, 28 | builder: (context, AsyncSnapshot userSnap) { 29 | String userAvatarUrl, userDisplayName = '', userEmail = ''; 30 | 31 | if (userSnap.hasData) { 32 | userAvatarUrl = userSnap.data.photoUrl; 33 | userDisplayName = userSnap.data.displayName; 34 | userEmail = userSnap.data.email; 35 | } 36 | 37 | return Scaffold( 38 | floatingActionButton: FloatingActionButton( 39 | child: Icon(FontAwesomeIcons.plus), 40 | onPressed: () => Navigator.of(context).pushNamed('newEvent/'), 41 | ), 42 | drawer: PopulatedDrawer( 43 | userAvatarUrl: userAvatarUrl, 44 | userDisplayName: userDisplayName, 45 | userEmail: userEmail, 46 | selectedScreen: Screen.events, 47 | ), 48 | appBar: AppBar( 49 | title: 'My Events', 50 | hasDrawer: true, 51 | ), 52 | body: StreamBuilder( 53 | stream: bloc.events, 54 | builder: (context, AsyncSnapshot> eventsSnap) { 55 | if (!eventsSnap.hasData) { 56 | return Center( 57 | child: LoadingIndicator(), 58 | ); 59 | } 60 | return buildList(eventsSnap.data); 61 | }, 62 | ), 63 | ); 64 | }, 65 | ); 66 | } 67 | 68 | Widget buildList(List events) { 69 | return ListView( 70 | padding: EdgeInsets.only(top: 15), 71 | children: events 72 | .map( 73 | (event) => Padding( 74 | padding: EdgeInsets.only(bottom: 15), 75 | child: EventListTile( 76 | event: event, 77 | ), 78 | ), 79 | ) 80 | .toList(), 81 | ); 82 | } 83 | 84 | @override 85 | void dispose() { 86 | bloc.dispose(); 87 | super.dispose(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/src/screens/gallery_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:rxdart/rxdart.dart'; 6 | 7 | import '../widgets/async_image.dart'; 8 | import '../widgets/async_thumbnail.dart'; 9 | import '../widgets/carousel.dart'; 10 | import '../widgets/fractionally_screen_sized_box.dart'; 11 | import '../widgets/loading_indicator.dart'; 12 | 13 | /// A screen that shows a series of puctures in full resolution with zoom 14 | /// capability and navigation controls. 15 | class GalleryScreen extends StatelessWidget { 16 | /// An observable that emits a list of all the paths of the images the gallery 17 | /// will show. 18 | final ValueObservable> pathsStream; 19 | 20 | /// An observable that emits a map that caches the files corresponding to the 21 | /// images to be shown. 22 | final ValueObservable>> cacheStream; 23 | 24 | /// An observable that emits a map that caches the files corresponding to the 25 | /// thumbnails of the images to be shown. 26 | final ValueObservable>> thumbnailCaceStream; 27 | 28 | /// A function to be called when an image needs to be fetched. 29 | final Function(String) fetchImage; 30 | 31 | /// The initial screen (picture) to be shown. 32 | /// 33 | /// If provided, the gallery will automatically show this image upon opening. 34 | final int initialScreen; 35 | 36 | /// A controller for the underlaying [PageView]. 37 | final PageController _controller; 38 | 39 | /// Creates a screen that shows a series of puctures in full resolution with zoom 40 | /// capability and navigation controls 41 | /// 42 | /// [patshStream], [cacheStream] and [fetchImage] must not be null. 43 | GalleryScreen({ 44 | @required this.pathsStream, 45 | @required this.cacheStream, 46 | this.initialScreen = 0, 47 | @required this.fetchImage, 48 | @required this.thumbnailCaceStream, 49 | }) : assert(pathsStream != null), 50 | assert(cacheStream != null), 51 | assert(fetchImage != null), 52 | assert(thumbnailCaceStream != null), 53 | _controller = PageController( 54 | initialPage: initialScreen, 55 | keepPage: false, 56 | ); 57 | 58 | Widget build(BuildContext context) { 59 | return Scaffold( 60 | body: Stack( 61 | children: [ 62 | StreamBuilder( 63 | stream: pathsStream, 64 | builder: 65 | (BuildContext context, AsyncSnapshot> listSnap) { 66 | // Wait until the images paths have been fetched. 67 | if (!listSnap.hasData) { 68 | return buildImagePlaceholder(); 69 | } 70 | 71 | return PageView.builder( 72 | // Do not allow page changes on swipe. User manual controls, 73 | // the [PageView] gestures interfere with zooming otherwhise. 74 | physics: new NeverScrollableScrollPhysics(), 75 | controller: _controller, 76 | itemCount: listSnap.data.length, 77 | itemBuilder: (BuildContext context, int index) { 78 | final imagePath = listSnap.data[index]; 79 | fetchImage(imagePath); 80 | 81 | return AsyncImage( 82 | cacheId: imagePath, 83 | cacheMap: cacheStream, 84 | ); 85 | }, 86 | ); 87 | }, 88 | ), 89 | buildCloseButton(context), 90 | buildCarousel(), 91 | ], 92 | ), 93 | ); 94 | } 95 | 96 | /// Jumps to the specified page. 97 | void animateToPage(int index) { 98 | final curve = Curves.easeInOut; 99 | final duration = Duration(milliseconds: 300); 100 | _controller.animateToPage( 101 | index, 102 | curve: curve, 103 | duration: duration, 104 | ); 105 | } 106 | 107 | /// Builds the button that closes this screen. 108 | Widget buildCloseButton(BuildContext context) { 109 | return Positioned( 110 | left: 20, 111 | top: 60, 112 | child: IconButton( 113 | onPressed: () => Navigator.of(context).pop(), 114 | icon: Icon( 115 | Icons.close, 116 | size: 40, 117 | ), 118 | ), 119 | ); 120 | } 121 | 122 | Widget buildCarousel() { 123 | return StreamBuilder( 124 | stream: thumbnailCaceStream, 125 | builder: (context, AsyncSnapshot>> snap) { 126 | Widget carousel = Container(); 127 | 128 | if (snap.hasData) { 129 | final cache = snap.data; 130 | List carouselChildren = []; 131 | 132 | cache.keys.forEach( 133 | (key) { 134 | carouselChildren.add( 135 | AsyncThumbnail( 136 | cacheId: key, 137 | cacheStream: thumbnailCaceStream, 138 | ), 139 | ); 140 | }, 141 | ); 142 | 143 | carousel = Carousel( 144 | onChanged: (index) => animateToPage(index), 145 | itemCount: cache.length, 146 | initialItem: initialScreen, 147 | children: carouselChildren, 148 | ); 149 | } 150 | 151 | return Positioned( 152 | bottom: 20, 153 | child: FractionallyScreenSizedBox( 154 | widthFactor: 1, 155 | child: Column( 156 | children: [ 157 | carousel, 158 | ], 159 | ), 160 | ), 161 | ); 162 | }, 163 | ); 164 | } 165 | 166 | /// Builds a placeholder for an image. 167 | Widget buildImagePlaceholder() { 168 | return Container( 169 | color: Colors.black, 170 | child: Center( 171 | child: LoadingIndicator(), 172 | ), 173 | ); 174 | } 175 | } 176 | 177 | enum Page { previous, next } 178 | -------------------------------------------------------------------------------- /lib/src/screens/home_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 | 6 | import '../utils.dart' show showUploadStatusSnackBar; 7 | import '../models/task_model.dart'; 8 | import '../blocs/home_bloc.dart'; 9 | import '../widgets/home_app_bar.dart'; 10 | import '../widgets/new_item_dialog_route.dart'; 11 | import '../widgets/task_list_tile.dart'; 12 | import '../widgets/loading_indicator.dart'; 13 | import '../widgets/populated_drawer.dart'; 14 | import '../widgets/search-box.dart'; 15 | 16 | class HomeScreen extends StatefulWidget { 17 | createState() => _HomeScreenState(); 18 | } 19 | 20 | class _HomeScreenState extends State { 21 | static const _searchBoxHeight = 50.0; 22 | 23 | /// An instance of the bloc for this screen. 24 | final HomeBloc bloc = HomeBloc(); 25 | 26 | /// The context of the scaffold being shown. 27 | /// 28 | /// Needed for showing snackbars. 29 | BuildContext _scaffoldContext; 30 | 31 | /// A stream subscription to the snackbar status. 32 | StreamSubscription _snackBarStatusSubscription; 33 | 34 | @override 35 | initState() { 36 | super.initState(); 37 | bloc.fetchTasks(); 38 | initializeSnackBarListener(); 39 | } 40 | 41 | Widget build(BuildContext context) { 42 | return StreamBuilder( 43 | stream: bloc.userStream, 44 | builder: (BuildContext context, AsyncSnapshot userSnap) { 45 | String userAvatarUrl, userDisplayName = '', userEmail = ''; 46 | 47 | if (userSnap.hasData) { 48 | userAvatarUrl = userSnap.data.photoUrl; 49 | userDisplayName = userSnap.data.displayName; 50 | userEmail = userSnap.data.email; 51 | } 52 | 53 | return Scaffold( 54 | drawer: PopulatedDrawer( 55 | userAvatarUrl: userAvatarUrl, 56 | userDisplayName: userDisplayName, 57 | userEmail: userEmail, 58 | selectedScreen: Screen.home, 59 | ), 60 | floatingActionButton: FloatingActionButton( 61 | child: Icon(FontAwesomeIcons.plus), 62 | backgroundColor: Color(0xFF707070), 63 | onPressed: () => Navigator.of(context).push(NewItemDialogRoute()), 64 | ), 65 | floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, 66 | appBar: HomeAppBar( 67 | avatarUrl: userAvatarUrl, 68 | subtitle: 'Hello $userDisplayName!', 69 | ), 70 | body: Builder( 71 | builder: (BuildContext context) { 72 | _scaffoldContext = context; 73 | return StreamBuilder( 74 | stream: bloc.userTasks, 75 | builder: (BuildContext context, 76 | AsyncSnapshot> snap) { 77 | if (!snap.hasData) { 78 | return Center( 79 | child: LoadingIndicator(), 80 | ); 81 | } 82 | return Stack( 83 | overflow: Overflow.visible, 84 | children: [ 85 | _buildTasksList(snap.data), 86 | // This container is needed to make it seem like the search box is 87 | // part of the app bar. 88 | Container( 89 | height: _searchBoxHeight / 2, 90 | width: double.infinity, 91 | color: Theme.of(context).cardColor, 92 | ), 93 | SearchBox( 94 | onChanged: bloc.updateSearchBoxText, 95 | height: 50.0, 96 | ), 97 | ], 98 | ); 99 | }, 100 | ); 101 | }, 102 | ), 103 | ); 104 | }, 105 | ); 106 | } 107 | 108 | Widget _buildTasksList(List tasks) { 109 | return ListView( 110 | padding: EdgeInsets.only(top: _searchBoxHeight + 15), 111 | children: tasks 112 | .map((task) => Container( 113 | child: TaskListTile( 114 | task: task, 115 | onDone: () => bloc.markTaskAsDone(task), 116 | onEventPressed: () { 117 | // Include the event name in the route. 118 | Navigator.of(context).pushNamed('event/${task.event}'); 119 | }, 120 | onEditPressed: () { 121 | // Include the id of the task to be edited in the route. 122 | Navigator.of(context).pushNamed('editTask/${task.id}'); 123 | }, 124 | ), 125 | padding: EdgeInsets.only(bottom: 12), 126 | )) 127 | .toList() 128 | ..add(Container(height: 70)), 129 | ); 130 | } 131 | 132 | void initializeSnackBarListener() { 133 | _snackBarStatusSubscription = bloc.snackBarStatus.listen((bool visible) { 134 | if (visible) { 135 | showUploadStatusSnackBar( 136 | _scaffoldContext, 137 | bloc.uploadStatus, 138 | bloc.updateSnackBarStatus, 139 | ); 140 | } else { 141 | Scaffold.of(_scaffoldContext).hideCurrentSnackBar(); 142 | } 143 | }); 144 | } 145 | 146 | @override 147 | void dispose() { 148 | _snackBarStatusSubscription.cancel(); 149 | bloc.dispose(); 150 | super.dispose(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/src/screens/initial_loading_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flare_flutter/flare_actor.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import '../services/auth_service.dart'; 7 | 8 | class InitialLoadingScreen extends StatelessWidget { 9 | /// An instance of the auth service. 10 | final AuthService _auth = authService; 11 | 12 | Widget build(BuildContext context) { 13 | return Scaffold( 14 | body: Container( 15 | color: Theme.of(context).canvasColor, 16 | child: FutureBuilder( 17 | future: Future.delayed( 18 | Duration(seconds: 2), 19 | () => 'Done', 20 | ), 21 | builder: (BuildContext context, AsyncSnapshot snapshot) { 22 | if (snapshot.hasData) { 23 | redirectUser(context); 24 | } 25 | return Center( 26 | child: Container( 27 | height: 150, 28 | width: 150, 29 | child: FlareActor( 30 | 'assets/animations/loading_logo.flr', 31 | animation: 'Flip', 32 | fit: BoxFit.contain, 33 | ), 34 | ), 35 | ); 36 | }, 37 | ), 38 | ), 39 | ); 40 | } 41 | 42 | /// Pushed a new route depending on the user status. 43 | /// 44 | /// Redirect to the home screen if there's a user stored locally, redirect 45 | /// to the login screen otherwise. 46 | void redirectUser(BuildContext context) async { 47 | final user = await _auth.currentUser; 48 | final routeToBePushed = user == null ? 'login/' : 'home/'; 49 | Navigator.of(context).pushReplacementNamed(routeToBePushed); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/screens/login_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 | 6 | import '../services/auth_service.dart'; 7 | import '../widgets/logo.dart'; 8 | import '../widgets/gradient_touchable_container.dart'; 9 | 10 | class LoginScreen extends StatelessWidget { 11 | /// An instance of the auth service. 12 | final AuthService _authService = authService; 13 | 14 | Widget build(BuildContext context) { 15 | return Scaffold( 16 | body: Container( 17 | color: Theme.of(context).canvasColor, 18 | child: Column( 19 | children: [ 20 | Expanded( 21 | child: Center( 22 | child: Logo(), 23 | ), 24 | flex: 2, 25 | ), 26 | Expanded( 27 | child: Center( 28 | child: GradientTouchableContainer( 29 | onTap: () => onLoginButtonTap(context), 30 | height: 50, 31 | width: 310, 32 | radius: 25, 33 | child: getButtonBody(), 34 | ), 35 | ), 36 | flex: 1, 37 | ), 38 | ], 39 | ), 40 | ), 41 | ); 42 | } 43 | 44 | Widget getButtonBody() { 45 | return Row( 46 | mainAxisSize: MainAxisSize.min, 47 | crossAxisAlignment: CrossAxisAlignment.center, 48 | children: [ 49 | Text( 50 | 'LOGIN', 51 | style: TextStyle( 52 | color: Colors.white, 53 | fontSize: 15, 54 | fontWeight: FontWeight.w600, 55 | ), 56 | ), 57 | SizedBox( 58 | width: 10, 59 | ), 60 | Icon( 61 | FontAwesomeIcons.google, 62 | color: Colors.white, 63 | size: 24, 64 | ), 65 | ], 66 | ); 67 | } 68 | 69 | /// Signs in a user. 70 | /// 71 | /// Redirects to thehome screen if the login was successful. 72 | Future onLoginButtonTap(BuildContext context) async { 73 | final user = await _authService.googleLoginAndSignup(); 74 | if (user != null) { 75 | Navigator.of(context).pushReplacementNamed('home/'); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/src/screens/new_event_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide AppBar; 2 | 3 | import '../utils.dart' show kSmallTextStyle; 4 | import '../blocs/new_event_bloc.dart'; 5 | import '../widgets/app_bar.dart'; 6 | import '../widgets/big_text_input.dart'; 7 | import '../widgets/gradient_touchable_container.dart'; 8 | import '../widgets/ocurrance_selector.dart'; 9 | 10 | class NewEventScreen extends StatefulWidget { 11 | _NewEventScreenState createState() => _NewEventScreenState(); 12 | } 13 | 14 | class _NewEventScreenState extends State { 15 | /// An instance of the bloc corresponding to this screen. 16 | final bloc = NewEventBloc(); 17 | Widget build(BuildContext context) { 18 | return Scaffold( 19 | appBar: AppBar( 20 | title: 'New Event', 21 | ), 22 | body: SafeArea( 23 | child: Padding( 24 | padding: EdgeInsets.only(top: 15.0, left: 20.0, right: 20.0), 25 | child: Column( 26 | children: [ 27 | BigTextInput( 28 | onChanged: bloc.changeEventName, 29 | maxCharacters: 16, 30 | hint: 'My event...', 31 | ), 32 | SizedBox( 33 | height: 15, 34 | ), 35 | OcurranceSelector( 36 | onChange: bloc.changeOcurrance, 37 | ), 38 | SizedBox( 39 | height: 15, 40 | ), 41 | StreamBuilder( 42 | stream: bloc.submitEnabled, 43 | builder: (context, submitSnap) { 44 | return GradientTouchableContainer( 45 | height: 40, 46 | radius: 8, 47 | isExpanded: true, 48 | enabled: submitSnap.hasData, 49 | onTap: () => onSubmit(context), 50 | child: Text( 51 | 'Submit', 52 | style: kSmallTextStyle, 53 | ), 54 | ); 55 | }, 56 | ), 57 | ], 58 | ), 59 | ), 60 | ), 61 | ); 62 | } 63 | 64 | void onSubmit(BuildContext context) async { 65 | await bloc.submit(); 66 | Navigator.of(context).pop(); 67 | } 68 | 69 | @override 70 | void dispose() { 71 | bloc.dispose(); 72 | super.dispose(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/screens/new_image_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart' hide AppBar; 5 | import 'package:image_picker/image_picker.dart'; 6 | 7 | import '../utils.dart'; 8 | import '../blocs/new_image_bloc.dart'; 9 | import '../models/user_model.dart'; 10 | import '../widgets/app_bar.dart'; 11 | import '../widgets/event_dropdown.dart'; 12 | import '../widgets/fractionally_screen_sized_box.dart'; 13 | import '../widgets/gradient_touchable_container.dart'; 14 | 15 | /// A screen that prompts the user for an image and uploads it to 16 | /// firebase storage. 17 | class NewImageScreen extends StatefulWidget { 18 | /// The name of the event to be preselected in the dropdown menu. 19 | final String defaultEventName; 20 | 21 | /// Creates a new screen that prompts the user for an image and uploads it to 22 | /// firevase storage. 23 | NewImageScreen({ 24 | this.defaultEventName, 25 | }); 26 | 27 | _NewImageScreenState createState() => _NewImageScreenState(); 28 | } 29 | 30 | class _NewImageScreenState extends State { 31 | /// An instance of the bloc for this scree. 32 | NewImageBloc bloc; 33 | 34 | initState() { 35 | bloc = NewImageBloc( 36 | defaultEventName: widget.defaultEventName, 37 | ); 38 | super.initState(); 39 | } 40 | 41 | Widget build(BuildContext context) { 42 | return Scaffold( 43 | appBar: AppBar( 44 | title: 'Add image', 45 | ), 46 | body: Padding( 47 | padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 10.0), 48 | child: Column( 49 | crossAxisAlignment: CrossAxisAlignment.center, 50 | children: [ 51 | SizedBox( 52 | height: 10, 53 | ), 54 | Center( 55 | child: GestureDetector( 56 | onTap: () => takePicture(), 57 | child: Container( 58 | height: 300, 59 | color: Theme.of(context).cardColor, 60 | child: StreamBuilder( 61 | stream: bloc.picture, 62 | builder: (BuildContext context, AsyncSnapshot snap) { 63 | if (!snap.hasData) { 64 | return Center( 65 | child: Text( 66 | 'Tap to take picture', 67 | style: kSmallTextStyle, 68 | ), 69 | ); 70 | } 71 | return Image.file( 72 | snap.data, 73 | fit: BoxFit.cover, 74 | ); 75 | }, 76 | ), 77 | ), 78 | ), 79 | ), 80 | SizedBox( 81 | height: 10, 82 | ), 83 | buildEventSection(bloc), 84 | SizedBox( 85 | height: 10, 86 | ), 87 | StreamBuilder( 88 | stream: bloc.submitEnabled, 89 | builder: 90 | (BuildContext context, AsyncSnapshot submitSnap) { 91 | return GradientTouchableContainer( 92 | height: 40, 93 | isExpanded: true, 94 | radius: 8, 95 | enabled: submitSnap.hasData, 96 | onTap: () => onSubmit(), 97 | child: Text( 98 | 'Submit', 99 | style: kSmallTextStyle, 100 | ), 101 | ); 102 | }), 103 | ], 104 | ), 105 | ), 106 | ); 107 | } 108 | 109 | Widget buildEventSection(NewImageBloc bloc) { 110 | return Row( 111 | children: [ 112 | Text( 113 | 'Event', 114 | style: kBigTextStyle, 115 | ), 116 | Spacer(), 117 | FractionallyScreenSizedBox( 118 | widthFactor: 0.7, 119 | child: StreamBuilder( 120 | stream: bloc.userModelStream, 121 | builder: (BuildContext context, AsyncSnapshot snap) { 122 | List events = []; 123 | 124 | if (snap.hasData) { 125 | events = snap.data.events; 126 | } 127 | 128 | return StreamBuilder( 129 | stream: bloc.eventName, 130 | builder: (BuildContext context, AsyncSnapshot snap) { 131 | return EventDropdown( 132 | isExpanded: true, 133 | value: snap.data, 134 | onChanged: bloc.changeEventName, 135 | hint: Text('Event'), 136 | items: events.map((String event) { 137 | return EventDropdownMenuItem( 138 | value: event, 139 | child: Text( 140 | event, 141 | style: TextStyle(color: Colors.white), 142 | ), 143 | ); 144 | }).toList(), 145 | ); 146 | }, 147 | ); 148 | }, 149 | ), 150 | ), 151 | ], 152 | ); 153 | } 154 | 155 | /// Saves the image to the storage bucket. 156 | void onSubmit() async { 157 | bloc.submit(); 158 | Navigator.of(context).pop(); 159 | } 160 | 161 | // TODO: Add size limit for free users. 162 | /// Prompts the user to take a picture. 163 | /// 164 | /// Updates the file in the bloc. 165 | Future takePicture() async { 166 | final File imgFile = await ImagePicker.pickImage( 167 | source: ImageSource.camera, 168 | ); 169 | bloc.changePicture(imgFile); 170 | } 171 | 172 | void dispose() { 173 | bloc.dispose(); 174 | super.dispose(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/src/screens/task_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide AppBar; 2 | 3 | import '../utils.dart'; 4 | import '../blocs/task_bloc.dart'; 5 | import '../models/user_model.dart'; 6 | import '../widgets/app_bar.dart'; 7 | import '../widgets/event_dropdown.dart'; 8 | import '../widgets/big_text_input.dart'; 9 | import '../widgets/fractionally_screen_sized_box.dart'; 10 | import '../widgets/gradient_touchable_container.dart'; 11 | import '../widgets/priority_selector.dart'; 12 | 13 | class TaskScreen extends StatefulWidget { 14 | /// Wether the Screen is going to edit a current task or create a new one. 15 | final bool isEdit; 16 | 17 | /// Id of the task to edit if in edit mode. 18 | final String taskId; 19 | 20 | /// Creates a screen capable of editing of creating a new task. 21 | /// 22 | /// [taskId] must be provided and cannot be null if [isEdit] is set to true. 23 | TaskScreen({ 24 | this.isEdit = false, 25 | this.taskId, 26 | }); 27 | 28 | @override 29 | State createState() => _TaskScreenState(); 30 | } 31 | 32 | class _TaskScreenState extends State { 33 | /// An instance of this screen's bloc. 34 | TaskBloc bloc; 35 | 36 | initState() { 37 | if (widget.isEdit) { 38 | bloc = TaskBloc(taskId: widget.taskId); 39 | bloc.populateWithCurrentTask(); 40 | } else { 41 | bloc = TaskBloc(); 42 | } 43 | super.initState(); 44 | } 45 | 46 | Widget build(BuildContext context) { 47 | return Scaffold( 48 | appBar: AppBar( 49 | title: widget.isEdit ? 'Edit task' : 'Add task', 50 | ), 51 | body: SingleChildScrollView( 52 | child: SafeArea( 53 | child: Padding( 54 | padding: const EdgeInsets.only(top: 15.0, left: 20.0, right: 20.0), 55 | child: Column( 56 | children: [ 57 | StreamBuilder( 58 | stream: bloc.textInitialvalue, 59 | builder: 60 | (BuildContext context, AsyncSnapshot snapshot) { 61 | String textFieldInitialValue = ''; 62 | if (snapshot.hasData) { 63 | textFieldInitialValue = snapshot.data; 64 | } 65 | return BigTextInput( 66 | initialValue: widget.isEdit ? textFieldInitialValue : '', 67 | height: 95, 68 | onChanged: bloc.changeTaskText, 69 | maxCharacters: 220, 70 | hint: 'Do something...', 71 | ); 72 | }, 73 | ), 74 | SizedBox( 75 | height: 15, 76 | ), 77 | StreamBuilder( 78 | stream: bloc.userModelStream, 79 | builder: (BuildContext context, 80 | AsyncSnapshot userSnapshot) { 81 | List events = []; 82 | 83 | if (userSnapshot.hasData) { 84 | events = userSnapshot.data.events; 85 | } 86 | return buildDropdownSection(events); 87 | }, 88 | ), 89 | SizedBox( 90 | height: 15, 91 | ), 92 | buildPrioritySelectorSection(), 93 | SizedBox( 94 | height: 20, 95 | ), 96 | StreamBuilder( 97 | stream: bloc.submitEnabled, 98 | builder: (context, submitSnap) { 99 | return GradientTouchableContainer( 100 | height: 40, 101 | radius: 8, 102 | isExpanded: true, 103 | enabled: submitSnap.hasData, 104 | onTap: () => onSubmit(context), 105 | child: Text( 106 | 'Submit', 107 | style: kSmallTextStyle, 108 | ), 109 | ); 110 | }, 111 | ), 112 | ], 113 | ), 114 | ), 115 | ), 116 | ), 117 | ); 118 | } 119 | 120 | Widget buildDropdownSection(List events) { 121 | return Row( 122 | children: [ 123 | Text( 124 | 'Event', 125 | style: kBigTextStyle, 126 | ), 127 | Spacer(), 128 | FractionallyScreenSizedBox( 129 | widthFactor: 0.6, 130 | child: StreamBuilder( 131 | stream: bloc.eventName, 132 | builder: (BuildContext context, AsyncSnapshot snap) { 133 | return EventDropdown( 134 | isExpanded: true, 135 | value: snap.data, 136 | onChanged: bloc.changeEventName, 137 | hint: Text('Event'), 138 | items: events.map((String name) { 139 | return EventDropdownMenuItem( 140 | value: name, 141 | child: Text( 142 | name, 143 | style: TextStyle(color: Colors.white), 144 | ), 145 | ); 146 | }).toList(), 147 | ); 148 | }, 149 | ), 150 | ), 151 | ], 152 | ); 153 | } 154 | 155 | Widget buildPrioritySelectorSection() { 156 | return Row( 157 | children: [ 158 | Text( 159 | 'Priority', 160 | style: kBigTextStyle, 161 | ), 162 | Spacer(), 163 | FractionallyScreenSizedBox( 164 | widthFactor: 0.6, 165 | child: PrioritySelector( 166 | onChage: bloc.setPriority, 167 | ), 168 | ), 169 | ], 170 | ); 171 | } 172 | 173 | /// Updates or creates a task. 174 | /// 175 | /// The action is determined by the [isEdit] property. 176 | void onSubmit(BuildContext context) async { 177 | await bloc.submit(widget.isEdit); 178 | Navigator.of(context).pop(); 179 | } 180 | 181 | void dispose() { 182 | bloc.dispose(); 183 | super.dispose(); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /lib/src/services/auth_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | 5 | import '../resources/firestore_provider.dart'; 6 | import '../resources/google_sign_in_provider.dart'; 7 | import '../models/summary_model.dart'; 8 | import '../models/user_model.dart'; 9 | 10 | export '../resources/google_sign_in_provider.dart' show FirebaseUser; 11 | 12 | class AuthService { 13 | /// An instance of [GoogleSignInProvider]. 14 | final GoogleSignInProvider _googleSignInProvider = signInProvider; 15 | 16 | /// An instance of the firestore provider. 17 | final FirestoreProvider _firestoreProvider = firestoreProvider; 18 | 19 | /// A subject of Firebase user. 20 | final _user = BehaviorSubject(); 21 | 22 | // Stream getters. 23 | /// An observable of the current [FirebaseUser] 24 | Observable get userStream => _user.stream; 25 | 26 | /// A future of the current [FirebaseUser]. 27 | Future get currentUser => 28 | _googleSignInProvider.getCurrentUser(); 29 | 30 | AuthService() { 31 | _googleSignInProvider.onAuthStateChange.pipe(_user); 32 | } 33 | 34 | /// Logs in or Signs up a user. 35 | /// 36 | /// Checks if the account used to sign up is already registered, log the user 37 | /// in if it is, create a new user otherwise. 38 | Future googleLoginAndSignup() async { 39 | final user = await _googleSignInProvider.signIn(); 40 | 41 | if (user == null) { 42 | return null; 43 | } 44 | // Create a new user in Firestore if this is the first time signing in. 45 | if (!await _firestoreProvider.userExists(user.email)) { 46 | final newUserModel = UserModel( 47 | username: user.email, 48 | tasks: [], 49 | events: [], 50 | summary: SummaryModel(), 51 | pendingHigh: 0, 52 | pendingMedium: 0, 53 | pendingLow: 0, 54 | ); 55 | await _firestoreProvider.createUser(newUserModel, user.uid); 56 | } 57 | 58 | return user; 59 | } 60 | 61 | /// Sings out the current user. 62 | Future signOut() { 63 | return _googleSignInProvider.signOut(); 64 | } 65 | 66 | /// Returns the model that represents the current logged in user. 67 | Future getCurrentUserModel() async { 68 | final user = await _googleSignInProvider.getCurrentUser(); 69 | if (user != null) { 70 | return _firestoreProvider.getUser(username: user.email); 71 | } 72 | return null; 73 | } 74 | 75 | void dispose() { 76 | _user.close(); 77 | } 78 | } 79 | 80 | final authService = AuthService(); 81 | -------------------------------------------------------------------------------- /lib/src/services/upload_status_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_storage/firebase_storage.dart'; 2 | import 'package:rxdart/rxdart.dart'; 3 | 4 | /// A service that maintains a record of the upload operations made to 5 | /// the firebase storage bucket. 6 | class UploadStatusService { 7 | /// A subject of the current status of uploads. 8 | final _status = BehaviorSubject(); 9 | 10 | /// A map that contains the information about all the current upload tasks. 11 | Map> _tasksData = 12 | >{}; 13 | 14 | /// An observable of the status for all current upload tasks. 15 | Observable get status => _status.stream; 16 | 17 | /// Creates a new service that maintains a record of the upload operations 18 | /// made to the firebase storage bucket. 19 | /// 20 | /// Avoid multiple instantiaion of this service. Either use the 21 | /// [uploadStatusService] singleton or instantiate once, the state will be 22 | /// lost otherwise. 23 | UploadStatusService(); 24 | 25 | /// Adds a new upload task to be tracked. 26 | /// 27 | /// The task gets automatically removed when done. 28 | void addNewUpload(StorageUploadTask task) async { 29 | // Initialize the map entry with initial values. 30 | _tasksData[task] = [0, 0]; 31 | task.events.listen( 32 | (StorageTaskEvent event) { 33 | // Update the map with the current values. 34 | final snap = event.snapshot; 35 | _tasksData[task] = [ 36 | snap.bytesTransferred, 37 | snap.totalByteCount, 38 | ]; 39 | _sendUpdate(); 40 | }, 41 | ); 42 | await task.onComplete; 43 | _tasksData.remove(task); 44 | _sendUpdate(); 45 | } 46 | 47 | /// Updates the _status subject with the most current data. 48 | void _sendUpdate() { 49 | int totalBytes = 0, bytestTransferred = 0; 50 | double percentage = 0.0; 51 | _tasksData.forEach( 52 | (_, data) { 53 | bytestTransferred += data[0]; 54 | totalBytes += data[1]; 55 | }, 56 | ); 57 | if (bytestTransferred != 0) { 58 | percentage = bytestTransferred / totalBytes; 59 | } 60 | _status.sink.add(UploadStatus( 61 | percentage: (percentage * 100).toStringAsFixed(2), 62 | numberOfFiles: _tasksData.length, 63 | )); 64 | } 65 | 66 | dispose() async { 67 | await _status.drain(); 68 | _status.close(); 69 | } 70 | } 71 | 72 | /// The status of an upload. 73 | class UploadStatus { 74 | /// Percentage of the upload. 75 | final String percentage; 76 | 77 | /// Number of files being uploaded. 78 | /// 79 | /// It can be 0 if nothing is currently being uploaded. 80 | final int numberOfFiles; 81 | 82 | /// Creates an [UploadStatus] instance. 83 | UploadStatus({ 84 | this.percentage, 85 | this.numberOfFiles, 86 | }); 87 | } 88 | 89 | final uploadStatusService = UploadStatusService(); 90 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:rxdart/rxdart.dart'; 5 | 6 | import './models/event_model.dart'; 7 | import './models/task_model.dart'; 8 | import './services/upload_status_service.dart'; 9 | 10 | const kLowPriorityColor = Color(0xFF06AD12); 11 | const kMediumPriorityColor = Color(0xFFF6A93B); 12 | const kHighPriorityColor = Color(0xFFF42850); 13 | 14 | const kBigTextStyle = TextStyle( 15 | color: Colors.white, 16 | fontSize: 24, 17 | fontWeight: FontWeight.w600, 18 | ); 19 | const kSmallTextStyle = TextStyle( 20 | color: Colors.white, 21 | fontSize: 16, 22 | fontWeight: FontWeight.w500, 23 | ); 24 | 25 | const kTileBigTextStyle = TextStyle( 26 | color: Colors.white, 27 | fontSize: 24, 28 | fontWeight: FontWeight.bold, 29 | ); 30 | 31 | const kTileSubtitleStyle = TextStyle( 32 | color: Colors.white, 33 | fontSize: 10, 34 | fontWeight: FontWeight.w300, 35 | ); 36 | 37 | const kBlueGradient = LinearGradient( 38 | begin: Alignment.topLeft, 39 | end: Alignment.bottomRight, 40 | stops: [0, 1.0], 41 | colors: [Color.fromRGBO(32, 156, 227, 1.0), Color.fromRGBO(45, 83, 216, 1.0)], 42 | ); 43 | 44 | Color getColorFromPriority(TaskPriority priority) { 45 | switch (priority) { 46 | case TaskPriority.low: 47 | return kLowPriorityColor; 48 | break; 49 | case TaskPriority.medium: 50 | return kMediumPriorityColor; 51 | break; 52 | case TaskPriority.high: 53 | return kHighPriorityColor; 54 | break; 55 | default: 56 | return Colors.white; 57 | } 58 | } 59 | 60 | Color getColorFromEvent(EventModel event) { 61 | if (event.highPriority != 0) { 62 | return kHighPriorityColor; 63 | } else if (event.mediumPriority != 0) { 64 | return kMediumPriorityColor; 65 | } 66 | return kLowPriorityColor; 67 | } 68 | 69 | mixin Validators { 70 | final stringNotEmptyValidator = 71 | StreamTransformer.fromHandlers( 72 | handleData: (String string, EventSink sink) { 73 | if (string.isEmpty) { 74 | sink.addError('Text cannot be empty'); 75 | } else { 76 | sink.add(string); 77 | } 78 | }, 79 | ); 80 | 81 | final occuranceArrayValidator = 82 | StreamTransformer, List>.fromHandlers( 83 | handleData: (List array, EventSink> sink) { 84 | if (array.length == 5 && array.contains(true)) { 85 | sink.add(array); 86 | } else { 87 | sink.addError('Event has to ocurr at least once'); 88 | } 89 | }, 90 | ); 91 | } 92 | 93 | /// Returns a stream transformer that sorts tasks by priority. 94 | final StreamTransformer, List> 95 | kTaskListPriorityTransforemer = 96 | StreamTransformer.fromHandlers(handleData: (tasksList, sink) { 97 | tasksList.sort((a, b) => TaskModel.ecodedPriority(b.priority) 98 | .compareTo(TaskModel.ecodedPriority(a.priority))); 99 | sink.add(tasksList); 100 | }); 101 | 102 | /// Gets the path of an image thumbnail from its original path. 103 | String getImageThumbnailPath(String path) { 104 | List tokens = path.split('/'); 105 | tokens.last = 'thumb@' + tokens.last; 106 | return tokens.join('/'); 107 | } 108 | 109 | /// Shows an upload status snack bar. 110 | /// 111 | /// Takes the data from the [uploadStatus] stream and shows in the snack bar. 112 | /// Calls [onSuccessfullyClosed] with [false] when all files are done being 113 | /// uploaded. 114 | void showUploadStatusSnackBar( 115 | BuildContext scaffoldContext, 116 | Observable uploadStatus, 117 | Function(bool) onSuccessfullyClosed, 118 | ) { 119 | assert(scaffoldContext != null); 120 | assert(uploadStatus != null); 121 | assert(onSuccessfullyClosed != null); 122 | final scaffoldState = Scaffold.of(scaffoldContext); 123 | scaffoldState.showSnackBar(SnackBar( 124 | /// The snack bar shouldn't close until the files are done uploading 125 | /// hence the long duration. It gets closed programatically. 126 | duration: Duration(hours: 1), 127 | content: StreamBuilder( 128 | stream: uploadStatus, 129 | builder: (BuildContext context, AsyncSnapshot snap) { 130 | if (!snap.hasData) { 131 | return Text(''); 132 | } 133 | print('Number of files: ${snap.data.numberOfFiles}'); 134 | if (snap.data.numberOfFiles == 0) { 135 | return Text('Done!'); 136 | } 137 | return Row( 138 | children: [ 139 | Spacer(), 140 | Text('${snap.data.numberOfFiles} pending'), 141 | Spacer(flex: 5), 142 | Text(snap.data.percentage + '%'), 143 | Spacer(), 144 | ], 145 | ); 146 | }, 147 | ), 148 | )); 149 | } 150 | 151 | /// A transformer that only emits a value if it is different than the last one 152 | /// emitted. 153 | /// 154 | /// The [==] operator is to check for inequality, be sure that the objects 155 | /// passing through the stream behave correctly with theses operator. 156 | class DistinctStreamTransformer extends StreamTransformerBase { 157 | final StreamTransformer transformer; 158 | 159 | DistinctStreamTransformer() : transformer = _buildTransformer(); 160 | 161 | @override 162 | Stream bind(Stream stream) => transformer.bind(stream); 163 | 164 | static StreamTransformer _buildTransformer() { 165 | return StreamTransformer((Stream input, bool cancelOnError) { 166 | T last; 167 | StreamController controller; 168 | StreamSubscription subscription; 169 | 170 | controller = StreamController( 171 | sync: true, 172 | onListen: () { 173 | subscription = input.listen( 174 | (T value) { 175 | if (!(last == value)) { 176 | last = value; 177 | controller.add(value); 178 | } 179 | }, 180 | onError: controller.addError, 181 | onDone: controller.close, 182 | cancelOnError: cancelOnError, 183 | ); 184 | }, 185 | onPause: ([Future resumeSignal]) => 186 | subscription.pause(resumeSignal), 187 | onResume: () => subscription.resume(), 188 | onCancel: () { 189 | return subscription.cancel(); 190 | }); 191 | 192 | return controller.stream.listen(null); 193 | }); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /lib/src/widgets/action_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A button that represents a card action. 4 | class ActionButton extends StatelessWidget { 5 | /// Function to be called when the button is pressed 6 | final VoidCallback onPressed; 7 | 8 | /// Background color of the button. 9 | final Color color; 10 | 11 | /// Text and icon color. 12 | final Color textColor; 13 | 14 | /// Icon to be placed before the text. 15 | final IconData leadingIconData; 16 | 17 | /// Icon to be placed after the text. 18 | final IconData trailingIconData; 19 | 20 | /// Text for the button. 21 | final String text; 22 | 23 | /// Width of the button. 24 | final double width; 25 | 26 | /// Height of the button. 27 | final double height; 28 | 29 | /// Border radius for the button. 30 | final double radius; 31 | 32 | ActionButton({ 33 | @required this.onPressed, 34 | this.color = const Color(0xFF2C2F34), 35 | this.textColor = Colors.white, 36 | this.leadingIconData, 37 | this.trailingIconData, 38 | this.width, 39 | this.height, 40 | this.radius = 6.0, 41 | @required this.text, 42 | }); 43 | 44 | Widget build(BuildContext context) { 45 | return ConstrainedBox( 46 | constraints: BoxConstraints( 47 | minHeight: 25, 48 | minWidth: 60, 49 | ), 50 | child: GestureDetector( 51 | onTap: onPressed, 52 | child: Container( 53 | width: width, 54 | height: height, 55 | padding: EdgeInsets.all(2), 56 | decoration: BoxDecoration( 57 | color: color, 58 | borderRadius: BorderRadius.circular(radius), 59 | ), 60 | child: buildButtonBody(), 61 | ), 62 | ), 63 | ); 64 | } 65 | 66 | /// Returns the button body. 67 | Widget buildButtonBody() { 68 | final children = []; 69 | if (leadingIconData != null) { 70 | children.addAll([ 71 | Icon( 72 | leadingIconData, 73 | color: textColor, 74 | size: 16, 75 | ), 76 | SizedBox( 77 | width: 3, 78 | ) 79 | ]); 80 | } 81 | children.add( 82 | Text( 83 | text, 84 | style: TextStyle( 85 | fontSize: 13, 86 | fontWeight: FontWeight.w500, 87 | color: textColor, 88 | ), 89 | ), 90 | ); 91 | if (trailingIconData != null) { 92 | children.addAll([ 93 | SizedBox( 94 | width: 3, 95 | ), 96 | Icon( 97 | trailingIconData, 98 | color: textColor, 99 | size: 14, 100 | ), 101 | ]); 102 | } 103 | return Row( 104 | crossAxisAlignment: CrossAxisAlignment.center, 105 | mainAxisAlignment: MainAxisAlignment.center, 106 | mainAxisSize: MainAxisSize.min, 107 | children: children, 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /lib/src/widgets/app_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide AppBar; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | /// A custom app bar to match the DO> design rules. 5 | /// 6 | /// This app bar is meant to be usen in screens that are not the home screen. 7 | /// It will always contain a back button that pops the current screen. 8 | class AppBar extends StatelessWidget implements PreferredSizeWidget { 9 | /// The title for the app bar. 10 | final String title; 11 | 12 | /// Widget to be shown on the bottom of the app bar. 13 | final PreferredSizeWidget bottom; 14 | 15 | /// The size of only the app bar part. 16 | /// 17 | /// It will vary depending on the existance of the bottom widget. 18 | final double _appBarHeight; 19 | 20 | /// Whether to show a back button or a menu button. 21 | final bool hasDrawer; 22 | AppBar({ 23 | this.title = '', 24 | this.bottom, 25 | this.hasDrawer = false, 26 | }) : _appBarHeight = bottom == null ? 140.0 : 120.0; 27 | 28 | /// The preferred size of the app bar. 29 | /// 30 | /// Consider the size of the bottom widget if there is one. 31 | Size get preferredSize => 32 | Size.fromHeight(_appBarHeight + (bottom?.preferredSize?.height ?? 0)); 33 | 34 | Widget build(BuildContext context) { 35 | Widget result = Container( 36 | height: preferredSize.height, 37 | child: Column( 38 | crossAxisAlignment: CrossAxisAlignment.start, 39 | children: [ 40 | buildButton(context), 41 | SizedBox( 42 | height: 10, 43 | ), 44 | Padding( 45 | padding: const EdgeInsets.only(left: 20.0), 46 | child: Text( 47 | title, 48 | style: TextStyle( 49 | fontSize: 40, 50 | fontWeight: FontWeight.w700, 51 | color: Colors.white, 52 | ), 53 | ), 54 | ), 55 | ], 56 | ), 57 | ); 58 | 59 | if (bottom != null) { 60 | result = Column( 61 | crossAxisAlignment: CrossAxisAlignment.start, 62 | children: [ 63 | Flexible( 64 | child: ConstrainedBox( 65 | constraints: const BoxConstraints(maxHeight: 140.0), 66 | child: result, 67 | ), 68 | ), 69 | bottom, 70 | ], 71 | ); 72 | } 73 | return Material( 74 | elevation: 10.0, 75 | child: Container( 76 | color: Theme.of(context).canvasColor, 77 | child: SafeArea( 78 | child: result, 79 | ), 80 | ), 81 | ); 82 | } 83 | 84 | Widget buildButton(BuildContext context) { 85 | return IconButton( 86 | icon: hasDrawer 87 | ? Icon( 88 | FontAwesomeIcons.bars, 89 | ) 90 | : Icon( 91 | FontAwesomeIcons.arrowLeft, 92 | color: Color.fromRGBO(112, 112, 112, 1), 93 | ), 94 | onPressed: hasDrawer 95 | ? () => Scaffold.of(context).openDrawer() 96 | : () => Navigator.of(context).pop(), 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/widgets/async_image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:photo_view/photo_view.dart'; 6 | import 'package:rxdart/rxdart.dart'; 7 | 8 | import './loading_indicator.dart'; 9 | 10 | /// A widget that shows an image given a cache map and its cache id. 11 | /// 12 | /// A placeholder is shown while it loads. 13 | class AsyncImage extends StatelessWidget { 14 | /// The id of the image inside the [cacheMap]. 15 | final String cacheId; 16 | 17 | /// A cache that maps an image path to its file. 18 | final Observable>> cacheMap; 19 | 20 | /// Creates a widget that shows an image given a cache map and its cache id. 21 | /// 22 | /// A placeholder is shown while the image loads. 23 | AsyncImage({ 24 | @required this.cacheId, 25 | @required this.cacheMap, 26 | }) : assert(cacheId != null), 27 | assert(cacheMap != null); 28 | 29 | Widget build(BuildContext context) { 30 | return StreamBuilder( 31 | stream: cacheMap, 32 | builder: (BuildContext context, 33 | AsyncSnapshot>> imagesCacheSnap) { 34 | // Wait until the images cache has data. 35 | if (!imagesCacheSnap.hasData) { 36 | return buildImagePlaceholder(); 37 | } 38 | 39 | return FutureBuilder( 40 | future: imagesCacheSnap.data[cacheId], 41 | builder: (BuildContext context, AsyncSnapshot imageFileSnap) { 42 | // Wait until the future of the file for this image resolves. 43 | if (!imageFileSnap.hasData) { 44 | return buildImagePlaceholder(); 45 | } 46 | 47 | return PhotoView( 48 | imageProvider: FileImage(imageFileSnap.data), 49 | minScale: .2, 50 | maxScale: 2.0, 51 | ); 52 | }, 53 | ); 54 | }, 55 | ); 56 | } 57 | 58 | Widget buildImagePlaceholder() { 59 | return Container( 60 | color: Colors.black, 61 | child: Center( 62 | child: LoadingIndicator(), 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/src/widgets/async_thumbnail.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:rxdart/rxdart.dart'; 6 | 7 | import '../widgets/loading_indicator.dart'; 8 | 9 | /// An Widget that displays an image given a stream of a cache and the id for 10 | /// this image. 11 | class AsyncThumbnail extends StatelessWidget { 12 | /// A stream of a cache that maps an image path to the future of its file. 13 | final Observable>> cacheStream; 14 | 15 | /// The id of the image to be displayed. 16 | /// 17 | /// The image will load undefinitely if the the provided id is not contained 18 | /// in the provided cache. 19 | final String cacheId; 20 | 21 | /// Creates a widget that displays an image given a stream of a cache and 22 | /// the id for this image. 23 | /// 24 | /// Neither [cacheStream] nor [cacheId] can be null. 25 | AsyncThumbnail({ 26 | @required this.cacheStream, 27 | @required this.cacheId, 28 | }) : assert(cacheId != null), 29 | assert(cacheStream != null); 30 | 31 | Widget build(BuildContext context) { 32 | return StreamBuilder( 33 | stream: cacheStream, 34 | builder: (BuildContext context, 35 | AsyncSnapshot>> thumbailsCacheSnap) { 36 | // Wait until the images cache has data. 37 | if (!thumbailsCacheSnap.hasData) { 38 | return _buildThumbnailPlaceholder(); 39 | } 40 | 41 | return FutureBuilder( 42 | future: thumbailsCacheSnap.data[cacheId], 43 | builder: 44 | (BuildContext context, AsyncSnapshot thumbnailFileSnap) { 45 | // Wait until the future of the file for this image resolves. 46 | if (!thumbnailFileSnap.hasData) { 47 | return _buildThumbnailPlaceholder(); 48 | } 49 | 50 | return ClipRRect( 51 | borderRadius: BorderRadius.circular(8.0), 52 | child: Image.file(thumbnailFileSnap.data), 53 | ); 54 | }, 55 | ); 56 | }, 57 | ); 58 | } 59 | 60 | // TODO: Find a better animation for the placeholder. 61 | Widget _buildThumbnailPlaceholder() { 62 | return Container( 63 | decoration: BoxDecoration( 64 | borderRadius: BorderRadius.circular(8.0), 65 | color: Colors.grey, 66 | ), 67 | child: Center( 68 | child: LoadingIndicator( 69 | size: 30, 70 | ), 71 | ), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/widgets/avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | class Avatar extends StatelessWidget { 5 | /// The url of hte image to be displayed. 6 | final String imageUrl; 7 | 8 | /// The size of the Avatar. 9 | final double size; 10 | 11 | Avatar({ 12 | this.imageUrl, 13 | this.size = 60.0, 14 | }); 15 | 16 | Widget build(BuildContext context) { 17 | return imageUrl == null 18 | ? Container( 19 | height: size, 20 | width: size, 21 | decoration: BoxDecoration( 22 | color: Colors.white, 23 | borderRadius: BorderRadius.circular(size / 2), 24 | ), 25 | child: Center( 26 | child: Icon( 27 | FontAwesomeIcons.question, 28 | ), 29 | ), 30 | ) 31 | : ClipOval( 32 | child: Image.network( 33 | imageUrl, 34 | height: size, 35 | width: size, 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/widgets/big_text_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A 3 line text input with progress indicator that matches the mocks. 4 | class BigTextInput extends StatefulWidget { 5 | /// The height of the input. 6 | final double height; 7 | 8 | /// The width of the input. 9 | final double width; 10 | 11 | /// Wether the containing card show elevation or not. 12 | final bool elevated; 13 | 14 | /// Method to be executed when the text is updated. 15 | final Function(String) onChanged; 16 | 17 | /// The initial value for the input. 18 | final String initialValue; 19 | 20 | /// Hint to be shown in the input. 21 | final String hint; 22 | 23 | /// The maximum amount of character to be allowed on the input. 24 | final int maxCharacters; 25 | 26 | BigTextInput({ 27 | @required this.onChanged, 28 | this.height, 29 | this.width, 30 | this.elevated = true, 31 | this.initialValue = '', 32 | this.hint = '', 33 | @required this.maxCharacters, 34 | }) : assert(maxCharacters != null); 35 | 36 | @override 37 | _BigTextInputState createState() => _BigTextInputState(); 38 | } 39 | 40 | class _BigTextInputState extends State { 41 | /// Custom controller for the text input. 42 | TextEditingController _controller; 43 | 44 | /// Flag to indicate if the initial value has been set or not. 45 | /// 46 | /// Without this flag the text property of the controller will be set 47 | /// continuously and the cursor inside the text field will return to the 48 | /// origin when tapped. 49 | bool initialValueWasSet = false; 50 | 51 | /// Setus up the controller. 52 | void initState() { 53 | _controller = TextEditingController(text: widget.initialValue); 54 | _controller.addListener(controllerListener); 55 | super.initState(); 56 | } 57 | 58 | // Set the text property of the controller if the newly rendered widget 59 | // contains a non empty initial value. It should only be done once, otherwise 60 | // the cursor will continuously return to the start of the input when tapped. 61 | void didUpdateWidget(oldWidget) { 62 | if (widget.initialValue != '' && !initialValueWasSet) { 63 | _controller.text = widget.initialValue; 64 | initialValueWasSet = true; 65 | } 66 | super.didUpdateWidget(oldWidget); 67 | } 68 | 69 | /// Calls [onChanged] when updates are sent by the controller. 70 | void controllerListener() { 71 | widget.onChanged(_controller.text); 72 | } 73 | 74 | Widget build(BuildContext context) { 75 | return Material( 76 | elevation: widget.elevated ? 10 : 0, 77 | child: ConstrainedBox( 78 | constraints: BoxConstraints( 79 | minWidth: 100, 80 | minHeight: 50, 81 | ), 82 | child: Container( 83 | width: widget.width, 84 | height: widget.height, 85 | decoration: BoxDecoration( 86 | color: Theme.of(context).cardColor, 87 | borderRadius: BorderRadius.circular(8.0), 88 | ), 89 | child: TextField( 90 | controller: _controller, 91 | maxLines: 3, 92 | maxLength: widget.maxCharacters, 93 | maxLengthEnforced: true, 94 | cursorColor: Theme.of(context).cursorColor, 95 | textInputAction: TextInputAction.done, 96 | style: TextStyle( 97 | color: Colors.white, 98 | fontSize: 16, 99 | ), 100 | decoration: InputDecoration( 101 | border: InputBorder.none, 102 | contentPadding: EdgeInsets.only(left: 5.0, right: 5.0, top: 5.0), 103 | counterStyle: TextStyle(color: Colors.white), 104 | hintText: widget.hint, 105 | hintStyle: TextStyle( 106 | color: Theme.of(context).cursorColor, 107 | ), 108 | ), 109 | ), 110 | ), 111 | ), 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/src/widgets/carousel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:rxdart/rxdart.dart'; 5 | 6 | // TODO: Add a builder constructor. to avoid accessing non existing keys in the 7 | // cache map. 8 | // 9 | // If you open the gallery screen without seeing all the items in the media 10 | // screen, the cache will not conatin all the items in the paths list and thus 11 | // the list will call methods on null. 12 | 13 | /// A widget that shows a list of widgets and lets you navigate through them. 14 | /// 15 | /// Touch events on individual items will make the list animate to show that 16 | /// parituclar item in the center of the screen. 17 | class Carousel extends StatefulWidget { 18 | /// The default spacing between items. 19 | static const kDefaultSeparatorSize = 10.0; 20 | 21 | /// The default size of the individual items in the carousel. 22 | static const kDefaultItemSize = 100.0; 23 | 24 | /// The size of the individual items to be shown. 25 | final double itemSize; 26 | 27 | /// The spacing between every item. 28 | final double spacing; 29 | 30 | /// The index of the item to be shown in the center. 31 | final int initialItem; 32 | 33 | /// The amount of items to be shown in the carousel. 34 | final int itemCount; 35 | 36 | /// Widgets to be shown in the carousel. 37 | final List children; 38 | 39 | /// Function to the be called when the widget on the center of the screen] 40 | /// changes. 41 | final Function(int) onChanged; 42 | 43 | Carousel({ 44 | this.itemSize = kDefaultItemSize, 45 | this.spacing = kDefaultSeparatorSize, 46 | this.initialItem = 0, 47 | this.itemCount, 48 | @required this.children, 49 | this.onChanged, 50 | }) { 51 | assert(children != null); 52 | assert(children.length > 0); 53 | if (itemCount != null) { 54 | assert(children.length == itemCount); 55 | } 56 | if (initialItem != 0) { 57 | assert(itemCount != null); 58 | assert(initialItem < itemCount); 59 | } 60 | } 61 | 62 | _CarouselState createState() => _CarouselState(); 63 | } 64 | 65 | class _CarouselState extends State { 66 | /// Spacing necessary to show the first and last items on the center of the 67 | /// screen. 68 | double _edgeSpacing; 69 | 70 | /// The space between each item of the carousel 71 | double _spaceBetweenItems; 72 | 73 | /// The controller for the underlaying [ListView]. 74 | ScrollController _controller; 75 | 76 | /// Subject of the index of the center widget. 77 | BehaviorSubject _widgetIndex; 78 | 79 | /// The current subscription to the BehaviorSubject. 80 | StreamSubscription _subscription; 81 | 82 | initState() { 83 | super.initState(); 84 | _spaceBetweenItems = widget.itemSize + widget.spacing; 85 | _widgetIndex = BehaviorSubject(seedValue: widget.initialItem); 86 | _controller = ScrollController( 87 | initialScrollOffset: getOffsetFromIndex(widget.initialItem), 88 | ); 89 | _controller.addListener(updateCurrentWidgetIndex); 90 | } 91 | 92 | void didUpdateWidget(Carousel oldWidget) { 93 | super.didUpdateWidget(oldWidget); 94 | updateSubscription(); 95 | } 96 | 97 | Future updateSubscription() async { 98 | if (_subscription != null) { 99 | await _subscription.cancel(); 100 | } 101 | _subscription = _widgetIndex 102 | .debounce(Duration(milliseconds: 400)) 103 | .listen((int index) => widget.onChanged(index)); 104 | } 105 | 106 | /// Moves the item in index [itemIndex] to the center of the screen. 107 | void scrollToItem(int itemIndex) { 108 | final position = _spaceBetweenItems * itemIndex; 109 | _controller.animateTo( 110 | position, 111 | curve: Curves.easeInOut, 112 | duration: Duration(milliseconds: 300), 113 | ); 114 | } 115 | 116 | /// Gets the offset from the left of the screen necessary to center the item 117 | /// on [index] on the middle of the screen. 118 | double getOffsetFromIndex(int index) => _spaceBetweenItems * index; 119 | 120 | /// Adds a new item to the [_widgetIndex] subject if the the item on the 121 | /// center of the screen changes. 122 | void updateCurrentWidgetIndex() { 123 | int newWidgetIndex = getIndexFromScrollOffset(_controller.offset); 124 | if (newWidgetIndex != _widgetIndex.value) { 125 | _widgetIndex.sink.add(newWidgetIndex); 126 | } 127 | } 128 | 129 | /// Gets the index of the item in the middle of the screen from a given 130 | /// scroll offset. 131 | int getIndexFromScrollOffset(double scrollOffset) { 132 | int widgetIndex; 133 | if (scrollOffset < 0) { 134 | widgetIndex = 0; 135 | } else { 136 | // get the distance to the closest multiple of the 137 | final distanceFromLower = scrollOffset % _spaceBetweenItems; 138 | widgetIndex = scrollOffset ~/ _spaceBetweenItems; 139 | if (distanceFromLower > _spaceBetweenItems / 2) { 140 | widgetIndex += 1; 141 | } 142 | } 143 | return widgetIndex; 144 | } 145 | 146 | Widget build(BuildContext context) { 147 | final width = MediaQuery.of(context).size.width; 148 | _edgeSpacing = width / 2 - 50; 149 | 150 | List listChildren = []; 151 | listChildren.add(SizedBox(width: _edgeSpacing)); 152 | 153 | for (int i = 0; i < widget.children.length; i++) { 154 | listChildren.add( 155 | GestureDetector( 156 | onTap: () => scrollToItem(i), 157 | behavior: HitTestBehavior.opaque, 158 | child: Container( 159 | width: widget.itemSize, 160 | child: widget.children[i], 161 | ), 162 | ), 163 | ); 164 | if (i != widget.children.length - 1) { 165 | listChildren.add(SizedBox(width: widget.spacing)); 166 | } 167 | } 168 | 169 | listChildren.add(SizedBox(width: _edgeSpacing)); 170 | 171 | return Container( 172 | height: widget.itemSize, 173 | child: ListView( 174 | controller: _controller, 175 | scrollDirection: Axis.horizontal, 176 | children: listChildren, 177 | ), 178 | ); 179 | } 180 | 181 | void dispose() { 182 | _subscription.cancel(); 183 | super.dispose(); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /lib/src/widgets/event_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import '../models/event_model.dart'; 5 | import '../mixins/tile_mixin.dart'; 6 | import '../utils.dart' show kTileBigTextStyle, kBlueGradient, getColorFromEvent; 7 | import '../widgets/action_button.dart'; 8 | 9 | class EventListTile extends StatelessWidget with Tile { 10 | final EventModel event; 11 | 12 | EventListTile({@required this.event}) : assert(event != null); 13 | 14 | Widget build(BuildContext context) { 15 | return FractionallySizedBox( 16 | alignment: Alignment.centerLeft, 17 | widthFactor: .95, 18 | child: Container( 19 | height: 85, 20 | child: Stack( 21 | overflow: Overflow.visible, 22 | children: [ 23 | Container( 24 | padding: EdgeInsets.all(8), 25 | child: Row( 26 | mainAxisSize: MainAxisSize.max, 27 | children: [ 28 | Column( 29 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 30 | crossAxisAlignment: CrossAxisAlignment.start, 31 | children: [ 32 | Text( 33 | event.name, 34 | style: kTileBigTextStyle, 35 | ), 36 | _OcurranceIdicator(ocurrance: event.when), 37 | ], 38 | ), 39 | ], 40 | ), 41 | ), 42 | getResourcesButton(context), 43 | getPriorityIndicator(), 44 | getTasksIndicator(), 45 | ], 46 | ), 47 | decoration: tileDecoration(Theme.of(context).cardColor), 48 | ), 49 | ); 50 | } 51 | 52 | Widget getResourcesButton(BuildContext context) { 53 | return Positioned( 54 | bottom: 8, 55 | right: 23, 56 | child: ActionButton( 57 | color: Colors.white, 58 | textColor: Colors.black, 59 | text: 'Resources', 60 | leadingIconData: FontAwesomeIcons.listAlt, 61 | onPressed: () => Navigator.of(context).pushNamed('event/${event.name}'), 62 | ), 63 | ); 64 | } 65 | 66 | Widget getPriorityIndicator() { 67 | final color = getColorFromEvent(event); 68 | return Row( 69 | children: [ 70 | Spacer(), 71 | Container( 72 | width: 15, 73 | decoration: tileDecoration(color), 74 | ), 75 | ], 76 | ); 77 | } 78 | 79 | Widget getTasksIndicator() { 80 | return Positioned( 81 | bottom: 75, 82 | right: -10, 83 | child: Container( 84 | child: Center( 85 | child: Text( 86 | '${event.pendingTasks}', 87 | style: TextStyle( 88 | color: Colors.white, 89 | ), 90 | ), 91 | ), 92 | width: 30, 93 | height: 20, 94 | decoration: BoxDecoration( 95 | gradient: kBlueGradient, 96 | borderRadius: BorderRadius.circular(10), 97 | ), 98 | ), 99 | ); 100 | } 101 | } 102 | 103 | class _OcurranceIdicator extends StatelessWidget { 104 | static const kDayLetters = ['M', 'T', 'W', 'Th', 'F']; 105 | // A list that visually represents when an event occurs. 106 | final List ocurrance; 107 | 108 | _OcurranceIdicator({@required this.ocurrance}) 109 | : assert(ocurrance != null), 110 | assert(ocurrance.length == 5); 111 | 112 | Widget build(BuildContext context) { 113 | List rowChildren = []; 114 | 115 | for (int i = 0; i < 5; i++) { 116 | rowChildren.add( 117 | Container( 118 | height: 25, 119 | width: 25, 120 | decoration: BoxDecoration( 121 | color: ocurrance[i] ? Colors.white : Colors.grey, 122 | borderRadius: BorderRadius.circular(3), 123 | ), 124 | child: Center( 125 | child: Text( 126 | kDayLetters[i], 127 | style: TextStyle( 128 | fontWeight: FontWeight.w500, 129 | ), 130 | ), 131 | ), 132 | ), 133 | ); 134 | if (i != 4) { 135 | rowChildren.add( 136 | SizedBox( 137 | width: 5, 138 | ), 139 | ); 140 | } 141 | } 142 | 143 | return Row( 144 | mainAxisAlignment: MainAxisAlignment.start, 145 | children: rowChildren, 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/src/widgets/fractionally_screen_sized_box.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A widget that sizes its child to a fraction of the size of the screen 4 | class FractionallyScreenSizedBox extends StatelessWidget { 5 | /// The child to be rendered inside the box. 6 | final Widget child; 7 | 8 | /// The fraction of the screen width the child will use. 9 | final double widthFactor; 10 | 11 | /// The fraction of the screen height the child will use. 12 | final double heightFactor; 13 | 14 | FractionallyScreenSizedBox({ 15 | this.child, 16 | this.widthFactor, 17 | this.heightFactor, 18 | }); 19 | 20 | Widget build(BuildContext context) { 21 | final size = MediaQuery.of(context).size; 22 | return Container( 23 | width: widthFactor != null ? widthFactor * size.width : null, 24 | height: heightFactor != null ? heightFactor * size.height : null, 25 | child: child, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/widgets/gradient_touchable_container.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils.dart'; 4 | 5 | /// A container that has the apps custom gradient as its background. 6 | /// 7 | /// The optional [onTap] gets called if provided when the onTap gesture is 8 | /// detected. 9 | class GradientTouchableContainer extends StatelessWidget { 10 | /// The border radius of the button. 11 | final double radius; 12 | 13 | /// The Widget to be contained inside the button. 14 | final Widget child; 15 | 16 | /// Height of the button. 17 | final double height; 18 | 19 | /// Width of the button. 20 | final double width; 21 | 22 | /// Function to be called when the button is pressed. 23 | final VoidCallback onTap; 24 | 25 | /// Shadow to be casted by the container. 26 | final BoxShadow shadow; 27 | 28 | /// Whether the container is expanded horizontally or not. 29 | /// 30 | /// The container will have an unbound width if set to true and it should not 31 | /// be put inside a row without constraints. 32 | final bool isExpanded; 33 | 34 | /// Whether or not the tap functionality is enabled. 35 | /// 36 | /// Changes the background to grey if set to false. 37 | final bool enabled; 38 | 39 | GradientTouchableContainer({ 40 | this.radius = 4, 41 | @required this.child, 42 | this.height, 43 | this.width, 44 | this.onTap, 45 | this.shadow, 46 | this.isExpanded = false, 47 | this.enabled = true, 48 | }); 49 | 50 | Widget build(BuildContext context) { 51 | final resultChild = Center( 52 | widthFactor: 1.0, 53 | heightFactor: 1.0, 54 | child: child, 55 | ); 56 | 57 | return ConstrainedBox( 58 | constraints: const BoxConstraints(minWidth: 88.0, minHeight: 36.0), 59 | child: GestureDetector( 60 | onTap: enabled ? onTap : null, 61 | child: Container( 62 | width: width, 63 | height: isExpanded ? null : height, 64 | padding: EdgeInsets.all(5), 65 | child: isExpanded 66 | ? Row( 67 | children: [ 68 | Expanded( 69 | child: resultChild, 70 | ) 71 | ], 72 | ) 73 | : resultChild, 74 | decoration: BoxDecoration( 75 | color: enabled ? null : Colors.grey, 76 | boxShadow: shadow == null ? null : [shadow], 77 | borderRadius: BorderRadius.circular(radius), 78 | gradient: enabled ? kBlueGradient : null, 79 | ), 80 | ), 81 | ), 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/src/widgets/home_app_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import './logo.dart'; 5 | import './avatar.dart'; 6 | 7 | //TODO: Add callback for the menu button. 8 | 9 | /// Custom app bar with avatar and menu button. 10 | /// 11 | /// Only to be used in the home scree. 12 | class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { 13 | /// Url for the user avatar. 14 | /// 15 | /// A placeholder is shown if null. 16 | final String avatarUrl; 17 | 18 | /// Text to be shown as a subtitle. 19 | final String subtitle; 20 | 21 | HomeAppBar({ 22 | this.avatarUrl, 23 | this.subtitle = '', 24 | }); 25 | 26 | Widget build(BuildContext context) { 27 | return Container( 28 | color: Theme.of(context).cardColor, 29 | child: SafeArea( 30 | top: true, 31 | child: Container( 32 | height: preferredSize.height, 33 | child: Column( 34 | crossAxisAlignment: CrossAxisAlignment.start, 35 | children: [ 36 | buildTopSection(context), 37 | SizedBox( 38 | height: 20, 39 | ), 40 | Padding( 41 | padding: EdgeInsets.only(left: 30), 42 | child: Logo(), 43 | ), 44 | Padding( 45 | padding: EdgeInsets.only(left: 30), 46 | child: Text( 47 | subtitle, 48 | style: TextStyle( 49 | color: Colors.white, 50 | fontSize: 20, 51 | fontWeight: FontWeight.w600, 52 | ), 53 | ), 54 | ), 55 | ], 56 | ), 57 | ), 58 | ), 59 | ); 60 | } 61 | 62 | Widget buildTopSection(BuildContext context) { 63 | final scaffoldState = Scaffold.of(context); 64 | 65 | return Row( 66 | children: [ 67 | SizedBox( 68 | width: 20, 69 | ), 70 | IconButton( 71 | onPressed: () => scaffoldState.openDrawer(), 72 | icon: Icon( 73 | FontAwesomeIcons.bars, 74 | size: 24, 75 | ), 76 | color: Colors.white, 77 | ), 78 | Spacer(), 79 | Avatar( 80 | imageUrl: avatarUrl, 81 | ), 82 | SizedBox( 83 | width: 20, 84 | ) 85 | ], 86 | ); 87 | } 88 | 89 | @override 90 | final preferredSize = Size.fromHeight(220.0); 91 | } 92 | -------------------------------------------------------------------------------- /lib/src/widgets/loading_indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flare_flutter/flare_actor.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | /// Loading indicator. 5 | /// 6 | /// Shows an animation of the logo. 7 | class LoadingIndicator extends StatelessWidget { 8 | final double size; 9 | 10 | LoadingIndicator({ 11 | this.size = 70, 12 | }); 13 | 14 | Widget build(BuildContext context) { 15 | return Container( 16 | width: size, 17 | height: size, 18 | child: FlareActor( 19 | 'assets/animations/loading_animation_looped.flr', 20 | animation: 'Flip', 21 | fit: BoxFit.contain, 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/widgets/logo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | class Logo extends StatelessWidget { 5 | final Color color; 6 | 7 | const Logo({this.color = Colors.white}); 8 | 9 | Widget build(BuildContext context) { 10 | return Container( 11 | width: 158, 12 | child: Stack( 13 | children: [ 14 | Text( 15 | 'DO', 16 | style: TextStyle( 17 | fontFamily: 'IBM Plex Sans', 18 | fontSize: 80.0, 19 | fontWeight: FontWeight.w600, 20 | color: color, 21 | ), 22 | ), 23 | Positioned( 24 | child: Icon( 25 | FontAwesomeIcons.angleRight, 26 | size: 90.0, 27 | color: color, 28 | ), 29 | left: 90, 30 | top: 5, 31 | ), 32 | ], 33 | ), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/widgets/new_item_dialog_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | /// A card with a label and circular button. 5 | /// 6 | /// Let's you specify the text shown and the action to perform when the circular 7 | /// button is pressed. 8 | class NewItemDialogButton extends StatelessWidget { 9 | /// Function to be called on tap gesture. 10 | final VoidCallback onTap; 11 | 12 | /// Label to be shown on top of the action button. 13 | final String label; 14 | 15 | NewItemDialogButton({ 16 | @required this.onTap, 17 | this.label = '', 18 | }) : assert(onTap != null); 19 | 20 | Widget build(BuildContext context) { 21 | return Container( 22 | padding: EdgeInsets.only( 23 | top: 30, 24 | bottom: 30, 25 | ), 26 | decoration: BoxDecoration( 27 | color: Theme.of(context).cardColor, 28 | borderRadius: BorderRadius.circular(8), 29 | ), 30 | height: 170, 31 | width: 120, 32 | child: Column( 33 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 34 | children: [ 35 | Text( 36 | label, 37 | style: TextStyle( 38 | color: Colors.white, 39 | fontSize: 24, 40 | fontWeight: FontWeight.w600, 41 | ), 42 | ), 43 | FloatingActionButton( 44 | child: Icon(FontAwesomeIcons.plus), 45 | onPressed: onTap, 46 | ), 47 | ], 48 | ), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/widgets/new_item_dialog_route.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | 4 | import './new_item_dialog_button.dart'; 5 | 6 | /// A transparent screen that lets you choose between creating a new task or 7 | /// add a new picture. 8 | class NewItemDialogRoute extends PopupRoute { 9 | @override 10 | Color get barrierColor => Color.fromRGBO(255, 255, 255, 0.5); 11 | 12 | @override 13 | bool get barrierDismissible => true; 14 | 15 | @override 16 | String get barrierLabel => null; 17 | 18 | @override 19 | Widget buildPage(BuildContext context, Animation animation, 20 | Animation secondaryAnimation) { 21 | return _builder(context); 22 | } 23 | 24 | Widget _builder(BuildContext context) { 25 | // Needs to be wrapped in a material widget so all the widgets have a 26 | // [Theme] widget as a parent. 27 | return Center( 28 | child: Material( 29 | type: MaterialType.transparency, 30 | child: Row( 31 | mainAxisSize: MainAxisSize.min, 32 | children: [ 33 | NewItemDialogButton( 34 | label: 'Task', 35 | onTap: () => 36 | Navigator.of(context).pushReplacementNamed('newTask/'), 37 | ), 38 | SizedBox( 39 | width: 20, 40 | ), 41 | NewItemDialogButton( 42 | label: 'Media', 43 | onTap: () => 44 | Navigator.of(context).pushReplacementNamed('newImage/'), 45 | ), 46 | ], 47 | ), 48 | ), 49 | ); 50 | } 51 | 52 | @override 53 | Duration get transitionDuration => Duration(milliseconds: 300); 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/widgets/ocurrance_selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A widget that lets you select the ocurrance of an event. 4 | class OcurranceSelector extends StatefulWidget { 5 | /// Function to be called when the selected ocurrance changes. 6 | final Function(List) onChange; 7 | 8 | OcurranceSelector({@required this.onChange}); 9 | _OcurranceSelectorState createState() => _OcurranceSelectorState(); 10 | } 11 | 12 | class _OcurranceSelectorState extends State { 13 | /// Strings corresponding to every day of the week. 14 | static const kDayLetters = ['M', 'T', 'W', 'Th', 'F']; 15 | 16 | /// Encoded occurance. 17 | /// 18 | /// True means the event occurs in that day. 19 | List ocurrance = List.filled(5, false); 20 | 21 | Widget build(BuildContext context) { 22 | List rowChildren = []; 23 | 24 | for (int i = 0; i < 5; i++) { 25 | rowChildren.add( 26 | GestureDetector( 27 | onTap: () => onDayTap(i), 28 | child: Container( 29 | height: 40, 30 | width: 40, 31 | decoration: BoxDecoration( 32 | color: ocurrance[i] ? Colors.white : Colors.grey, 33 | borderRadius: BorderRadius.circular(3), 34 | ), 35 | child: Center( 36 | child: Text( 37 | kDayLetters[i], 38 | style: TextStyle( 39 | fontWeight: FontWeight.w500, 40 | ), 41 | ), 42 | ), 43 | ), 44 | ), 45 | ); 46 | if (i != 4) { 47 | rowChildren.add( 48 | SizedBox( 49 | width: 10, 50 | ), 51 | ); 52 | } 53 | } 54 | 55 | return Row( 56 | mainAxisAlignment: MainAxisAlignment.start, 57 | children: rowChildren, 58 | ); 59 | } 60 | 61 | void onDayTap(int index) { 62 | setState(() { 63 | ocurrance[index] = !ocurrance[index]; 64 | }); 65 | widget.onChange(ocurrance); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/src/widgets/populated_drawer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../services/auth_service.dart'; 4 | import '../widgets/avatar.dart'; 5 | import '../widgets/gradient_touchable_container.dart'; 6 | 7 | class PopulatedDrawer extends StatelessWidget { 8 | /// The user's display name. 9 | final String userDisplayName; 10 | 11 | /// The url for the user's avatar. 12 | final String userAvatarUrl; 13 | 14 | /// The current user's email. 15 | final String userEmail; 16 | 17 | /// The selected screen. 18 | final Screen selectedScreen; 19 | 20 | PopulatedDrawer({ 21 | this.userDisplayName = '', 22 | this.userAvatarUrl, 23 | this.userEmail = '', 24 | @required this.selectedScreen, 25 | }) : assert(selectedScreen != null), 26 | assert(userDisplayName != null), 27 | assert(userEmail != null); 28 | 29 | Widget build(BuildContext context) { 30 | return Drawer( 31 | child: SafeArea( 32 | child: Column( 33 | crossAxisAlignment: CrossAxisAlignment.start, 34 | children: [ 35 | Material( 36 | elevation: 10, 37 | child: UserAccountsDrawerHeader( 38 | decoration: BoxDecoration( 39 | color: Theme.of(context).canvasColor, 40 | ), 41 | accountEmail: Text(userEmail), 42 | accountName: Text(userDisplayName), 43 | currentAccountPicture: Avatar( 44 | imageUrl: userAvatarUrl, 45 | ), 46 | ), 47 | ), 48 | SizedBox( 49 | height: 10, 50 | ), 51 | buildDrawerTile( 52 | text: 'Home', 53 | isSelected: selectedScreen == Screen.home, 54 | action: () => Navigator.of(context) 55 | .pushNamedAndRemoveUntil('home/', (_) => false), 56 | ), 57 | buildDrawerTile( 58 | text: 'Archive', 59 | isSelected: selectedScreen == Screen.archive, 60 | action: () => Navigator.of(context) 61 | .pushNamedAndRemoveUntil('archive/', (_) => false), 62 | ), 63 | buildDrawerTile( 64 | text: 'Events', 65 | isSelected: selectedScreen == Screen.events, 66 | action: () => Navigator.of(context) 67 | .pushNamedAndRemoveUntil('events/', (_) => false), 68 | ), 69 | Spacer(), 70 | Padding( 71 | padding: const EdgeInsets.only(left: 10), 72 | child: GradientTouchableContainer( 73 | onTap: () => onLogoutTap(context), 74 | child: Text( 75 | 'LOGOUT', 76 | style: TextStyle( 77 | color: Colors.white, 78 | fontSize: 15, 79 | fontWeight: FontWeight.w600, 80 | ), 81 | ), 82 | ), 83 | ), 84 | ], 85 | ), 86 | ), 87 | ); 88 | } 89 | 90 | Widget buildDrawerTile({ 91 | String text, 92 | bool isSelected = false, 93 | VoidCallback action, 94 | }) { 95 | final result = Container( 96 | color: isSelected ? Colors.white10 : null, 97 | height: 50, 98 | padding: EdgeInsets.only( 99 | left: 10, 100 | ), 101 | child: Row( 102 | children: [ 103 | Text( 104 | text, 105 | style: TextStyle( 106 | color: Colors.white, 107 | fontSize: 16, 108 | ), 109 | ), 110 | ], 111 | ), 112 | ); 113 | if (isSelected) { 114 | return result; 115 | } 116 | return GestureDetector( 117 | behavior: HitTestBehavior.opaque, 118 | onTap: action, 119 | child: result, 120 | ); 121 | } 122 | 123 | void onLogoutTap(BuildContext context) { 124 | authService.signOut(); 125 | // Push the login screen and remove all other screens from the navigator. 126 | Navigator.of(context).pushNamedAndRemoveUntil('login/', (_) => false); 127 | } 128 | } 129 | 130 | enum Screen { 131 | home, 132 | events, 133 | archive, 134 | } 135 | -------------------------------------------------------------------------------- /lib/src/widgets/priority_selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import '../models/task_model.dart'; 5 | import '../utils.dart'; 6 | 7 | /// A wdiget that lets you select the priority of a task. 8 | class PrioritySelector extends StatefulWidget { 9 | /// Width of the selector. 10 | /// 11 | /// If none is provided it expands as much as it can horizontally. 12 | final double width; 13 | 14 | /// Function to be called when the current selected priority changes. 15 | final Function(TaskPriority) onChage; 16 | 17 | PrioritySelector({ 18 | this.width, 19 | @required this.onChage, 20 | }); 21 | 22 | _PrioritySelectorState createState() => _PrioritySelectorState(); 23 | } 24 | 25 | class _PrioritySelectorState extends State { 26 | TaskPriority selectedPriority = TaskPriority.high; 27 | 28 | static const kCheckMark = Center( 29 | child: Icon( 30 | FontAwesomeIcons.checkCircle, 31 | color: Colors.white, 32 | ), 33 | ); 34 | 35 | Widget build(BuildContext context) { 36 | return Container( 37 | height: 35.0, 38 | width: widget.width, 39 | child: Row( 40 | children: [ 41 | Expanded( 42 | flex: 8, 43 | child: GestureDetector( 44 | onTap: () => updatePriority(TaskPriority.high), 45 | child: Container( 46 | decoration: BoxDecoration( 47 | color: kHighPriorityColor, 48 | borderRadius: BorderRadius.circular(8), 49 | ), 50 | child: 51 | selectedPriority == TaskPriority.high ? kCheckMark : null, 52 | ), 53 | ), 54 | ), 55 | Spacer( 56 | flex: 2, 57 | ), 58 | Expanded( 59 | flex: 8, 60 | child: GestureDetector( 61 | onTap: () => updatePriority(TaskPriority.medium), 62 | child: Container( 63 | decoration: BoxDecoration( 64 | color: kMediumPriorityColor, 65 | borderRadius: BorderRadius.circular(8), 66 | ), 67 | child: 68 | selectedPriority == TaskPriority.medium ? kCheckMark : null, 69 | ), 70 | ), 71 | ), 72 | Spacer( 73 | flex: 2, 74 | ), 75 | Expanded( 76 | child: GestureDetector( 77 | onTap: () => updatePriority(TaskPriority.low), 78 | child: Container( 79 | decoration: BoxDecoration( 80 | color: kLowPriorityColor, 81 | borderRadius: BorderRadius.circular(8), 82 | ), 83 | child: selectedPriority == TaskPriority.low ? kCheckMark : null, 84 | ), 85 | ), 86 | flex: 8, 87 | ), 88 | ], 89 | ), 90 | ); 91 | } 92 | 93 | /// Sets the selected priority and calls the onChange method with it. 94 | void updatePriority(TaskPriority priority) { 95 | widget.onChage(priority); 96 | setState(() { 97 | selectedPriority = priority; 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/src/widgets/search-box.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import './gradient_touchable_container.dart'; 5 | 6 | //TODO: Add neccessary properties to be able to inform of changes in text field. 7 | 8 | /// A search box that mathces the app mocks. 9 | class SearchBox extends StatefulWidget { 10 | /// Height of the sarch box. 11 | final double height; 12 | 13 | /// Function to be called when the text changes. 14 | final Function(String) onChanged; 15 | 16 | /// Creates a search box. 17 | /// 18 | /// the height should be equal or larger than 50. 19 | SearchBox({ 20 | @required this.height, 21 | @required this.onChanged, 22 | }) : assert(height >= 50); 23 | 24 | @override 25 | _SearchBoxState createState() => _SearchBoxState(); 26 | } 27 | 28 | class _SearchBoxState extends State { 29 | /// Controller for the [TextFiel]. 30 | final TextEditingController _controller = TextEditingController(); 31 | 32 | initState() { 33 | _controller.addListener(() => widget.onChanged(_controller.text)); 34 | super.initState(); 35 | } 36 | 37 | Widget build(BuildContext context) { 38 | final List containerRowChildren = [ 39 | SizedBox( 40 | width: 10, 41 | ), 42 | Icon( 43 | FontAwesomeIcons.sistrix, 44 | color: Colors.white, 45 | ), 46 | SizedBox( 47 | width: 8, 48 | ), 49 | Expanded( 50 | child: TextField( 51 | controller: _controller, 52 | decoration: InputDecoration( 53 | border: InputBorder.none, 54 | hintText: 'Search...', 55 | hintStyle: TextStyle( 56 | fontSize: 16, 57 | color: Colors.white, 58 | ), 59 | ), 60 | cursorColor: Colors.white, 61 | scrollPadding: EdgeInsets.zero, 62 | style: TextStyle( 63 | fontSize: 16, 64 | color: Colors.white, 65 | ), 66 | ), 67 | ), 68 | ]; 69 | 70 | if (_controller.text != '') { 71 | containerRowChildren.add( 72 | IconButton( 73 | icon: Icon( 74 | FontAwesomeIcons.timesCircle, 75 | color: Colors.white, 76 | ), 77 | onPressed: onClearButtonPressed, 78 | ), 79 | ); 80 | } 81 | 82 | return Row( 83 | children: [ 84 | Spacer(flex: 1), 85 | Expanded( 86 | flex: 8, 87 | child: GradientTouchableContainer( 88 | radius: widget.height / 2, 89 | height: widget.height, 90 | shadow: BoxShadow( 91 | color: Color(0x20FFFFFF), 92 | offset: Offset(0, 3), 93 | blurRadius: 6, 94 | spreadRadius: 1, 95 | ), 96 | child: Row( 97 | children: containerRowChildren, 98 | ), 99 | ), 100 | ), 101 | Spacer(flex: 1), 102 | ], 103 | ); 104 | } 105 | 106 | void onClearButtonPressed() { 107 | _controller.text = ''; 108 | // The controller does not notify its listeners when the text is set 109 | // explicitely. We have to do it manually. 110 | widget.onChanged(_controller.text); 111 | setState(() {}); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/src/widgets/task_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import '../mixins/tile_mixin.dart'; 5 | import '../models/task_model.dart'; 6 | import '../utils.dart'; 7 | import '../widgets/action_button.dart'; 8 | 9 | /// A card that visually represents a task. 10 | class TaskListTile extends StatelessWidget with Tile { 11 | /// Task model which this card represents. 12 | final TaskModel task; 13 | 14 | /// Function to be called when the "done" button is pressed. 15 | final VoidCallback onDone; 16 | 17 | /// Function to be called when the "edit" button is pressed. 18 | final VoidCallback onEditPressed; 19 | 20 | /// Function to be called when the "event" button is pressed. 21 | final VoidCallback onEventPressed; 22 | 23 | /// Function to be called when the "undo" button is pressed. 24 | final VoidCallback onUndo; 25 | 26 | /// Whether or not the event button should be hidden. 27 | final bool hideEventButton; 28 | 29 | /// Height of the priority badge. 30 | /// 31 | /// Also used to calculate the padding for the first section. 32 | static const _badgeHeight = 25.0; 33 | 34 | /// Creates a card that represents a task. 35 | /// 36 | /// The task property cannot be null. 37 | /// The card has 3 buttons whose callback must be provided. 38 | TaskListTile({ 39 | @required this.task, 40 | this.onDone, 41 | this.onUndo, 42 | this.onEditPressed, 43 | this.onEventPressed, 44 | this.hideEventButton = false, 45 | }) : assert(task != null); 46 | 47 | Widget build(BuildContext context) { 48 | return FractionallySizedBox( 49 | alignment: Alignment.centerLeft, 50 | widthFactor: .95, 51 | child: Container( 52 | height: 116, 53 | child: Stack( 54 | children: [ 55 | buildBadge(), 56 | Row( 57 | children: [ 58 | SizedBox( 59 | width: 9, 60 | ), 61 | buildTextSection(), 62 | buildButtonSection(), 63 | SizedBox( 64 | width: 9, 65 | ), 66 | ], 67 | ), 68 | ], 69 | ), 70 | decoration: tileDecoration(Theme.of(context).cardColor), 71 | ), 72 | ); 73 | } 74 | 75 | /// Builds the section that contains the task's event and its text. 76 | Widget buildTextSection() { 77 | return Expanded( 78 | flex: 6, 79 | child: Column( 80 | crossAxisAlignment: CrossAxisAlignment.start, 81 | children: [ 82 | SizedBox( 83 | height: _badgeHeight + 4, 84 | ), 85 | Text( 86 | task.event, 87 | style: kTileBigTextStyle, 88 | ), 89 | Text( 90 | task.text, 91 | style: kTileSubtitleStyle, 92 | ), 93 | ], 94 | ), 95 | ); 96 | } 97 | 98 | /// Builds the section that contains the 3 buttons for the tile. 99 | Widget buildButtonSection() { 100 | final columnChildren = []; 101 | if (task.done) { 102 | columnChildren.addAll( 103 | [ 104 | SizedBox( 105 | height: 25, 106 | ), 107 | ActionButton( 108 | onPressed: onUndo, 109 | text: 'Undo', 110 | trailingIconData: FontAwesomeIcons.timesCircle, 111 | color: Colors.white, 112 | textColor: Colors.black, 113 | radius: 14, 114 | width: 72, 115 | ), 116 | ], 117 | ); 118 | } else { 119 | final bottomRowChildren = []; 120 | 121 | bottomRowChildren.add( 122 | ActionButton( 123 | onPressed: onEditPressed, 124 | text: 'Edit', 125 | leadingIconData: Icons.edit, 126 | ), 127 | ); 128 | 129 | if (!hideEventButton) { 130 | bottomRowChildren.addAll([ 131 | SizedBox( 132 | width: 4, 133 | ), 134 | ActionButton( 135 | onPressed: onEventPressed, 136 | text: 'Event', 137 | leadingIconData: FontAwesomeIcons.calendar, 138 | ), 139 | ]); 140 | } 141 | columnChildren.addAll( 142 | [ 143 | ActionButton( 144 | onPressed: onDone, 145 | text: 'Done', 146 | trailingIconData: FontAwesomeIcons.checkCircle, 147 | color: Colors.white, 148 | textColor: Colors.black, 149 | radius: 14, 150 | width: 72, 151 | ), 152 | Row( 153 | mainAxisAlignment: MainAxisAlignment.end, 154 | children: bottomRowChildren, 155 | ), 156 | ], 157 | ); 158 | } 159 | 160 | return Expanded( 161 | flex: 5, 162 | child: Column( 163 | mainAxisAlignment: 164 | task.done ? MainAxisAlignment.start : MainAxisAlignment.spaceAround, 165 | crossAxisAlignment: CrossAxisAlignment.end, 166 | children: columnChildren, 167 | ), 168 | ); 169 | } 170 | 171 | /// Builds the badge that showcases the tasks priority. 172 | Widget buildBadge() { 173 | final badgeColor = getColorFromPriority(task.priority); 174 | return Container( 175 | alignment: Alignment.topLeft, 176 | width: 88, 177 | height: _badgeHeight, 178 | decoration: BoxDecoration( 179 | color: badgeColor, 180 | borderRadius: BorderRadius.only( 181 | bottomRight: Radius.circular(14), 182 | ), 183 | ), 184 | child: Center( 185 | child: Text(task.getPriorityText(), 186 | style: TextStyle( 187 | fontWeight: FontWeight.w600, 188 | fontSize: 13, 189 | color: Colors.white, 190 | )), 191 | ), 192 | ); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: do_more 2 | description: A good lookig glorified todo list. 3 | 4 | # The following defines the version and build number for your application. 5 | # A version number is three numbers separated by dots, like 1.2.43 6 | # followed by an optional build number separated by a +. 7 | # Both the version and the builder number may be overridden in flutter 8 | # build by specifying --build-name and --build-number, respectively. 9 | # Read more about versioning at semver.org. 10 | version: 1.0.0+1 11 | 12 | environment: 13 | sdk: ">=2.0.0-dev.68.0 <3.0.0" 14 | 15 | dependencies: 16 | flutter: 17 | sdk: flutter 18 | 19 | # The following adds the Cupertino Icons font to your application. 20 | # Use with the CupertinoIcons class for iOS style icons. 21 | cloud_firestore: ^0.9.0 22 | #cloud_firestore: 23 | # path: /Users/aym/Projects/open_source/plugins/packages/cloud_firestore 24 | cupertino_icons: ^0.1.2 25 | firebase_analytics: ^2.0.2 26 | firebase_auth: ^0.8.1 27 | firebase_core: ^0.3.0 28 | firebase_ml_vision: ^0.6.0 29 | firebase_storage: ^2.0.1 30 | flare_flutter: ^1.3.2 31 | font_awesome_flutter: ^8.4.0 32 | google_sign_in: ^4.0.1+1 33 | http: ^0.12.0 34 | image_picker: ^0.5.0+3 35 | mockito: ^4.0.0 36 | path_provider: ^0.5.0+1 37 | photo_view: 0.2.3 38 | rxdart: ^0.20.0 39 | sqflite: ^1.1.0 40 | uuid: ^2.0.0 41 | 42 | dev_dependencies: 43 | flutter_test: 44 | sdk: flutter 45 | 46 | 47 | # For information on the generic Dart part of this file, see the 48 | # following page: https://www.dartlang.org/tools/pub/pubspec 49 | 50 | # The following section is specific to Flutter. 51 | flutter: 52 | assets: 53 | - assets/animations/loading_logo.flr 54 | - assets/animations/loading_animation_looped.flr 55 | - assets/animations/loading_animation.flr 56 | 57 | fonts: 58 | - family: IBM Plex Sans 59 | fonts: 60 | - asset: assets/fonts/IBMPlexSans-Regular.ttf 61 | weight: 400 62 | - asset: assets/fonts/IBMPlexSans-Medium.ttf 63 | weight: 500 64 | - asset: assets/fonts/IBMPlexSans-Light.ttf 65 | weight: 300 66 | - asset: assets/fonts/IBMPlexSans-Bold.ttf 67 | weight: 700 68 | - asset: assets/fonts/IBMPlexSans-SemiBold.ttf 69 | weight: 600 70 | # The following line ensures that the Material Icons font is 71 | # included with your application, so that you can use the icons in 72 | # the material Icons class. 73 | uses-material-design: true 74 | 75 | -------------------------------------------------------------------------------- /test/src/resources/google_sign_in_provider_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:do_more/src/resources/google_sign_in_provider.dart'; 4 | import 'package:firebase_auth/firebase_auth.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | import 'package:google_sign_in/google_sign_in.dart'; 7 | import 'package:mockito/mockito.dart'; 8 | 9 | main() { 10 | group('GoogleSignInProvider', () { 11 | test('should return an instance of user', () { 12 | final googleSignIn = MockGoogleSignIn(); 13 | final auth = MockFirebaseAuth(); 14 | final googleUser = MockGoogleSignInAccount(); 15 | final googleAuth = MockGoogleSignInAuthentication(); 16 | final user = MockFirebaseUser(); 17 | final provider = GoogleSignInProvider(googleSignIn, auth); 18 | 19 | when(googleSignIn.signIn()).thenAnswer((_) => Future.value(googleUser)); 20 | when(googleUser.authentication) 21 | .thenAnswer((_) => Future.value(googleAuth)); 22 | when(auth.signInWithCredential(any)) 23 | .thenAnswer((_) => Future.value(user)); 24 | 25 | expect(provider.signIn(), completion(isInstanceOf())); 26 | }); 27 | 28 | test('should get the current user stored in cache', () { 29 | final auth = MockFirebaseAuth(); 30 | final user = MockFirebaseUser(); 31 | final provider = GoogleSignInProvider(null, auth); 32 | 33 | when(auth.currentUser()).thenAnswer((_) => Future.value(user)); 34 | 35 | expect( 36 | provider.getCurrentUser(), completion(isInstanceOf())); 37 | }); 38 | 39 | test('should sign out a user', () { 40 | final auth = MockFirebaseAuth(); 41 | final googleSignIn = MockGoogleSignIn(); 42 | final provider = GoogleSignInProvider(googleSignIn, auth); 43 | 44 | when(auth.signOut()).thenAnswer((_) => Future.value()); 45 | 46 | expect(provider.signOut(), completes); 47 | verify(googleSignIn.disconnect()); 48 | }); 49 | }); 50 | } 51 | 52 | class MockGoogleSignIn extends Mock implements GoogleSignIn {} 53 | 54 | class MockFirebaseAuth extends Mock implements FirebaseAuth {} 55 | 56 | class MockGoogleSignInAccount extends Mock implements GoogleSignInAccount {} 57 | 58 | class MockGoogleSignInAuthentication extends Mock 59 | implements GoogleSignInAuthentication {} 60 | 61 | class MockFirebaseUser extends Mock implements FirebaseUser {} 62 | -------------------------------------------------------------------------------- /test/src/utils_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:do_more/src/utils.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | 6 | main() { 7 | group('DistinctStreamTransformer', () { 8 | test('should only emit non repeated elements', () { 9 | final stream = Stream.fromIterable([1, 1, 1, 2, 2, 2, 1, 1, 1, 1]); 10 | final transformedStream = stream.transform(DistinctStreamTransformer()); 11 | 12 | expect(transformedStream.toList(), completion(equals([1, 2, 1]))); 13 | }); 14 | }); 15 | } 16 | --------------------------------------------------------------------------------