diff --git a/.circleci/config.yml b/.circleci/config.yml index 065812a5..461d2852 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,9 +3,10 @@ jobs: build: working_directory: ~/code docker: - - image: circleci/android:api-29 + - image: circleci/android:api-30 environment: - JVM_OPTS: -Xmx3200m + JVM_OPTS: -Xmx8192m + GRADLE_OPTS: -Xmx8192m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dkotlin.compiler.execution.strategy=in-process -Dkotlin.incremental=false steps: - checkout - restore_cache: diff --git a/.gitignore b/.gitignore index 603b1407..a90d57da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,13 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +.idea/* .DS_Store /build /captures .externalNativeBuild .cxx +*.apk +*.aar +*.ap_ +*.aab diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..679f5965 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +dailyrecipes \ No newline at end of file diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml new file mode 100644 index 00000000..251a4f16 --- /dev/null +++ b/.idea/checkstyle-idea.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 88ea3aa1..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..fb7f4a8a --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 9ba31e52..1a9cdf77 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -12,10 +12,11 @@ diff --git a/.idea/markdown-navigator-enh.xml b/.idea/markdown-navigator-enh.xml new file mode 100644 index 00000000..12fb99d1 --- /dev/null +++ b/.idea/markdown-navigator-enh.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/markdown-navigator.xml b/.idea/markdown-navigator.xml new file mode 100644 index 00000000..4463382e --- /dev/null +++ b/.idea/markdown-navigator.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..5c027807 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4996c9f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0](https://github.com/JimClermonts/Kotlin-Android-sample-App/releases) - In progress date +- Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..801d1b8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# How to Contribute + +We encourage you to participate in this project. We love pull requests, bug reports, ideas, (security) code reviews or any kind of positive contribution. Please feel free to post questions, recommendations, ideas, bugs by create [new issue](https://github.com/JimClermonts/Kotlin-Android-sample-App/issues/new) + +## Code reviews + +All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. + +After that create your [pull request](https://github.com/JimClermonts/Kotlin-Android-sample-App/compare) and complete the template structure adding any information that you consider relevant, to make it easier to identify these changes. + +## Code quality + +Once the pull request is created, make sure that it passes the different static analysis checks and tests. Otherwise review the errors in the build and make the necessary changes to correct it. + +## Code release process +1. Checkout `develop` branch with `feature/number_of_jira_ticket`. +2. Work on task and make small commits. +3. Try to add a couple of tests, start with unit tests and go up the pyramid. +4. When done, run the existing test cases to make sure everything still works: + +`./gradlew testDebugUnitTest` + +`./gradlew connectedDebugAndroidTest` + +5. When all tests still work, create pull request. +6. Wait for review of team member and make changes if necessary. +7. Make a test build and share it with the tester. +8. When no changes need to be made, merge the `feature` branch with the `develop` branch and delete the branch. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..a1bdf8f8 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,163 @@ +# Design + +## About + +The architecture design is based on [Google's IO App](https://github.com/google/iosched/tree/adssched) and more specifically, the [Google Android Developer Summit App](https://play.google.com/store/apps/details?id=com.google.samples.apps.adssched&hl=en_US). +This project is open-sourced by Google to demonstrate good programming paradigma's and design choices. There is never a one-fit solution for every App but this is a good fit for most business-grade Apps. + +## Chosen architecture +This architecture follows [MVVM architecture](https://developer.android.com/jetpack/docs/guide#recommended-app-arch) and (loosely) applies [Uncle Bob's clean architecture approach](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). + + + +## Layers +There are 4 layers. Each layer has a different color. Every layer should be a separate module, to enforce adhering to the [dependency inversion rule](https://en.wikipedia.org/wiki/Dependency_inversion_principle). But this causes unnecessary duplicate code. That's why the App module has these layers as directories. It is the developer's responsibilities to maintain the DIP. Let's start with the outer layer and work our way towards the center. + + + +## Blue layer + +### UI +The ordering within this directory is done using package-by-feature. So for example the home screen and all it's necessary classes (that exist in the UI layer) are within a directory called home. + +#### Activity / Fragment +In these feature directories we can find activities, fragments and their matching ViewModels. + +### Util +The util layer consists of utilities that are used within the whole application + +### Persistence +The persistence contains an implementation of a Shared preferences that is used to store preferences of the user. + +## Green layer +ViewModels communicate the result of the Use cases back to the view. In android apps, the view Models are usually a part of the UI layer and thus are placed in the same directory. + +## Red layer + +### Domain +The domain layer is a collection of use cases who follow the same directory structure as the ui layer. + +#### Use Cases +The use cases describe all the use cases of the App. E.g. logging in, refreshing the screen, viewing favorite items. So everything that the user can do. Implementation details such as using a Database for viewing the favorite items are not relevant in this layer of the design. However, when changing the Use case, then most certainly the descending layers will change. In this way, the layer adheres to the dependency inversion rule. + +### Data + +#### Repository +The repository is an abstraction of the data layer and a central point of handling the domain objects (which are use cases). This abstraction is important because there are different places and implementations where the data can be retrieved from, but the repository and it's interface should remain the same. In this project for every end point there is a separate Repository. 2 Examples of end points are: User or Item. + +#### Data source +In this App, there are 3 different data sources that serve different purposes. + +##### Bootstrap +On first startup, the App shows 2 videos that are in the resources directory. This is faster than the network data source and also works when there is no internet. + +##### Network +Parallel on displaying the first video's from the bootstrap data source, a network call is done that loads the latest items from a API. Responsibilities also include checking internet. + +###### Data downloader +The actual API call for the network call is executed in data downloader. In the case of the Items, this is a Firebase firestore implementation. But the data downloader could also do a GET request to some other API. + +##### App Database +The app database is another data source that can be a Room implementation, or another database like Realm or Sqlite. + +## Yellow layer +The yellow layer is the most inner layer of the circle. In this App, entities are a set of data classes, or POJO's. + +### Model +The rule is that these entities should be used within the whole program. That's why the ItemEntity (Room DB pojo) is not placed in the model directory, but in the db directory because it is only (and should be) used by the Room database. + +## Testing strategy +Following [Martin Fowler's practical test pyramid](https://martinfowler.com/articles/practical-test-pyramid.html), we have 3 types of tests: **unit** test, **integration** test and **ui** tests. This *does not* map to the 2 test directories `androidTest` and `test`. + +| Test files | Type of test | Found in directory | explanation | +|------------|-------------:|--------------------|-------------| +| DataSourceTest(s) | unit | `androidTest` | needs context object | +| JsonParserTest(s) | unit | `test` | +| RepositoryTest(s) | integration | `test` | +| UseCaseTest(s) | unit | `test` | +| ViewModelTest(s) | integration | `test` | +| ActivityTest(s) | integration | `androidTest` | + +### Unit tests +A unit test is a small and fast test that tests a method of a class in isolation. Unit tests can be found both in the `androidTest` and `test` directory. Only put them in the `androidTest` directory if you need android instrumentation such as the `context` object. Tests in the `test` directory don't require an Android device to run and should be pure java or kotlin classes. `androidTest` does require a phone or emulator and is thus also slower. Therefore, it is always better if possible to move tests to the `test` directory instead of `androidTest`. + +### Integration tests +Integration tests test how classes or components work together. Integration with a db or network call is common (such as repository). Mocking is a way to stub out dependencies so that 1 component can be tested in isolation. Mocking is usually required to test either 1 of the 2 components. + +### UI Tests +UI Tests are the smallest amount, the slowest and are tests that validate the user interface. Because they always need an android device (or emulator), these tests are always found in the `androidTest` directory. UI Tests are always integration tests because they always need different components to run and thus cannot be run in isolation. + +#### Running the tests automatically +The tests are run automatically in azure pipelines. For this a configuration file is added in the root of the project: `azure-pipelines.yml`. This file consists of 3 steps: + +- Step 1 + +`./gradlew assembleDebug` + +Build the debug variant of the App. + +`./gradlew testDebugUnitTest` + +Run all the tests that don't require an emulator or real Android device. + +- Step 2 + +`./gradlew connectedDebugAndroidTest` + +run integration tests on emulator + +- Step 3 + +`./gradlew connectedDebugAndroidTest` + +run on real devices using App Center. + + +| flavors | Type of test | Found in directory | explanation | +|------------|-------------:|--------------------|-------------| +| debug | `androidTest` | needs context object | +| release | `androidTest` | needs context object | + + + +## Code Quality +To enforce a minimum amount of code quality, manual and automated steps are taken. + +### Definition of done +- Run below command. Manually running tests and compiling the different build flavors can be a time consuming proces. Committing (when in correct branch) can trigger a continuous integration proces. To make sure these always pass, run below command locally before committing: + +`./gradlew lint test` + +- Always test the app on the lowest API (emulator usually) with a small screen. + +- Read the ticket 1 more time. + + +### Continuous integration + +#### CircleCI +The tests are run automatically after each commit on the master branch. This way there is always a shippable product available. + +### Code review +Static code review is done automatically using Codacy, after each commit on the master branch. + +#### Lint checker +TODO + +#### Remove unused resources +In Android Studio, go to Refactor, remove unused resources. + +#### Inspect code +In Android Studio, go to Analyze, Inspect Code. + +### Firebase crashlytics +When the App is in the field, firebase keeps monitoring the users. + +### LeakCanary +TODO! + + + + + + diff --git a/PUSH-MESSAGES.md b/PUSH-MESSAGES.md new file mode 100644 index 00000000..de3e6bbd --- /dev/null +++ b/PUSH-MESSAGES.md @@ -0,0 +1,55 @@ +## Sending Push Messages (for ACC) +These steps are for Acceptance environment, but work the same for production. +> (Skip step 1 / 4 if you want to send a previously sent push message). +1. Open an [item](https://console.firebase.google.com/u/0/project/daily-recipes-acceptance/firestore/data~2Fitems~2FpLPgvBF2X73dAl7ig7i8) on acceptance environment. +2. Copy the `imageUri`. +3. Copy or write down a title. +4. Create the body text. +> To resend a push message, open the [notifications](https://console.firebase.google.com/u/0/project/daily-recipes-acceptance/notification) tab. (Otherwise continue with step 6). And press **Duplicate notification** and replace the values: + +![alt text][push-duplicate] +[push-duplicate]: images/push-duplicate.png + +6. Open the [Cloud messaging](https://console.firebase.google.com/u/0/project/daily-recipes-acceptance/notification/compose) tab. +7. Fill in the **Notification title**, the **Notification text**, and paste the `imageUri` into **Notification image**: + +![alt text][push-notification] +[push-notification]: images/push-notification.png + +8. Target all users: + +![alt text][push-target] +[push-target]: images/push-target.png + +9. IMPORTANT: Remove `https://firebasestorage.googleapis.com/v0/b` from the `imageUri` and add to the **Custom data** so that the correct video is started when clicking the push: + +![alt text][push-custom-data] +[push-custom-data]: images/push-custom-data.png + +### Types of push messages +There are 9 categories (called `foodType` in `item`:): + +``` +meat +chicken +fish +vegetarian +vegan +pastry +fingerfood +lifehack +``` + +The App only shows the notifications of the foodType categories that the user has chosen. When the `foodType` is empty in the item on firebase, then it will never be shown. + +and 5 sub-categories: + +``` +fingerfood-vegetarian +fingerfood-vegan +fingerfood-meat +fingerfood-chicken +fingerfood-fish +``` + +This is because people who are vegetarian still want to see fingerfood items. But only vegetarian fingerfood. The App should only make a push message when the user has the category selected. When the user hasn't chosen, the App will show all notifications. diff --git a/README.md b/README.md index 97a58000..66e13eec 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,97 @@ -## About -The Quick Launch Android App combines the most common building blocks, applies all material design principles, and leverages the power of Firebase. Thanks to this, you can now launch a complete App in a day, instead of months. - -## For who is this for? -Developers that need to build a new Android App and want rapid validation of their assumptions. +# Daily Recipes Android App +[![CircleCI](https://circleci.com/gh/JimClermonts/Kotlin-Android-sample-App/tree/master.svg?style=svg&circle-token=008d64c8839b00c145d0fb248c37736373cd49ad)](https://circleci.com/gh/JimClermonts/Kotlin-Android-sample-App/tree/master) -## Mission -To create an open-source project that enables developers to launch a minimum viable product within a day. +## About +The Daily Recipes Android App is a short-form video App that targets foodies and cooking enthusiasts. -## Which functionality is in this App? +## Which functionality is already in this App? - Register, Login, alternate flows: Password forgotten etc. - Social logins: Google & Facebook. -- Terms & Conditions. -- Notifications working right out of the box! - Navigation Bar with 2 tabs: Home & Notifications -- Navigation drawer with settings & user. -- Detail screen. -- Cool animations and adhering to Material Design - Support till SDK 21, Android 5.0. +## Which functionality still needs to be developed? +- Terms and conditions screen. +- Notifications. +- Dark Mode. +- Cool animations and adhering to Material Design. +- Detail screen. +- Navigation drawer with settings & user. + ## Prerequisites - Android Studio 4.0 Canary 8 - Android SDK 29 - Kotlin 1.3.61 ## Try it out -- Checkout this Repo and try to run the App as is on your phone or an emulator. +- Checkout this Repo and try to run the App on your phone or an emulator. use `acceptanceDebug` to run against the acceptance environment. Or try `productionRelease` for the Play Store version. + + *For the `productionRelease` the production key of the App is needed to compile.* -## Getting started -There are only a couple of things that need to be setup before you can launch your App: +## Architecture +Read this page on [architecture design](https://github.com/JimClermonts/Kotlin-Android-sample-App/blob/master/DESIGN.md) to find out more about the choices and design of this App. -### Add your data set -- Fill the FakeDataSource with your own dataset. -- Change namespace. -- Create a project on Firebase. +## Current version +The current version is: 0.1.3 -### Add email login and social -- Create a keystore. -- Add your fingerprint ID for Google Sign in. -- Add your Facebook App ID for Facebook Sign in. -- Replace the google-services.json. +## Contribute +Please read and follow the steps in the [CONTRIBUTING.md](https://github.com/JimClermonts/Kotlin-Android-sample-App/blob/master/CONTRIBUTING.md) -### Add fonts, language & legal -- Add font & corporate identity. -- Language: If not English / Dutch: add own language. -- Change "terms & conditions" link. +## Roadmap +- Add favorite button and storage in Room database. +- Add User profile fragment (Tab) with the liked video's. +- Solve the "segment loop playing" using separate threads. +- Next segment should show next segment of the same Item. +- When swiping down, should always reset to first segment. +- Video should play when swiping to next segment. +- Change links to embedded links on Youtube and Instagram for offloading the firebase DB. +- Embedded links must be low quality and start playing <1.5 s. +- Add simple push notifications. +- Facebook login doesn't seem to work all the time. +- Make playstore listing with text and images. -#### Optional -- Create admin panel on Retool for your clients. +## CI / CD +There are 2 CI / CD Platforms on which the tests run. The first is: [CircleCI Dashboard](https://app.circleci.com/pipelines/github/JimClermonts) and the second is: [Microsoft Azure Pipeline](https://dev.azure.com/jimclermonts/jimclermonts/_build). CircleCI sometimes has problems, so it's good to have 2 platforms. -## Current version -The current version is: 1.0.1 +- CircleCI runs automatically on master and branches when a new commit is done. +The configuration file for CircleCI can be found in: `.circleci/config.yml`. +- Microsoft Azure also runs on master and branches when a new commit is done. +The configuration file is found in: `azure-pipelines.yml`. + + +## Generating Keys for Firebase Google Login +### production key +`keytool -list -v -keystore TheAppFactory-key.jks -alias AppFactory-Keys` + +### android debug keystore +`keytool -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore` +password = android + +### Production key found in Google Play +Go to: `Google Play Console`, `Release management`, `App signing` + +## Icons +Icons (`ic_launcher_round` and `ic_launcher`) are being generated with the use of [this](https://jgilfelt.github.io/AndroidAssetStudio/icons-launcher.html#foreground.space.trim=1&foreground.space.pad=0&foreColor=E8EAF6%2C0&crop=0&backgroundShape=square&backColor=e8eaf6%2C100&effects=none&elevate=1) website. + +## Sending Push Messages (for ACC) +Please read and follow the steps in the [PUSH-MESSAGES](https://github.com/JimClermonts/Daily-Recipes-Android-App/blob/master/PUSH-MESSAGES.md) + + +## License + +```license +Copyright 2021 www.TheAppFactory.eu + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` -## Contribute -Please read and follow the steps in the CONTRIBUTING.md diff --git a/app/.gitignore b/app/.gitignore index 65d12b95..42afabfd 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1 @@ -/build -google-services.json \ No newline at end of file +/build \ No newline at end of file diff --git a/app/acceptance/debug/output-metadata.json b/app/acceptance/debug/output-metadata.json new file mode 100644 index 00000000..947aa705 --- /dev/null +++ b/app/acceptance/debug/output-metadata.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "eu.theappfactory.dailyrecipes.acc", + "variantName": "processAcceptanceDebugResources", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "versionCode": 20, + "versionName": "1.0.6-acc-SNAPSHOT", + "outputFile": "app-acceptance-debug.apk" + } + ] +} \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 907c744f..fff4b78d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,119 +1,216 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' -apply plugin: 'kotlin-kapt' -apply plugin: 'androidx.navigation.safeargs' -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.firebase.crashlytics' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-android-extensions' + id 'kotlin-kapt' + id 'androidx.navigation.safeargs' + id 'com.google.gms.google-services' + id 'com.google.firebase.crashlytics' +} + +//apply plugin: 'jacoco' + +//jacoco { +// toolVersion = "$jacocoVersion" +//} + +//tasks.withType(Test) { +// jacoco.includeNoLocationClasses = true +//} + +//task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'createDebugCoverageReport']) { +// +// reports { +// xml.enabled = true +// html.enabled = true +// } +// +// def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] +// def debugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug", excludes: fileFilter) +// def mainSrc = "$project.projectDir/src/main/java" +// +// sourceDirectories = files([mainSrc]) +// classDirectories = files([debugTree]) +// executionData = fileTree(dir: project.buildDir, includes: [ +// 'jacoco/testDebugUnitTest.exec', 'outputs/code_coverage/debugAndroidTest/connected/**/*.ec' +// ]) +//} android { - compileSdkVersion 29 + compileSdkVersion 31 defaultConfig { - applicationId "eu.theappfactory.sampleapp" - minSdkVersion 21 - targetSdkVersion 29 - versionCode 1 - versionName "1.0" + applicationId "eu.theappfactory.dailyrecipes" + minSdkVersion 23 + targetSdkVersion 31 + versionCode 34 + versionName "1.1.0" + resConfigs "nl" + + buildConfigField "String", + "BOOTSTRAP_ITEM_DATA_FILENAME", + "\"${bootstrap_items_data_filename}\"" + + buildConfigField "String", + "PRIVACY_AND_COOKIE_STATEMENT", + "\"${privacy_and_cookie_statement}\"" + + buildConfigField "String", + "TERMS_AND_CONDITIONS", + "\"${terms_and_conditions}\"" + + buildConfigField "String", + "MIXPANEL_TOKEN", + "\"[REDACTED]\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + debug { + storeFile file("../dailyrecipes-debug.keystore") + storePassword "[REDACTED]" + keyAlias "DailyRecipes-debugkey" + keyPassword "[REDACTED]" + } + release { + storeFile file("../../TheAppFactory-key.jks") + storePassword "(469084/.cvfkjd\\'[]'" + keyAlias "AppFactory-Keys" + keyPassword "+_)(1/,`aghq\\][prio])" + } + } + + flavorDimensions "type" + productFlavors { + acceptance { + dimension="type" + applicationIdSuffix ".acc" + versionNameSuffix "-acc" + } + production { + dimension="type" + applicationIdSuffix "" + versionNameSuffix "" + } + } buildTypes { + debug { + testCoverageEnabled true + debuggable true + versionNameSuffix "-SNAPSHOT" + } + release { - minifyEnabled false + debuggable false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + dataBinding { - enabled true + enabled = true } kotlinOptions { jvmTarget = '1.8' } -} + lintOptions { + abortOnError false + } + testOptions { + unitTests.returnDefaultValues = true + + /*execution 'ANDROIDX_TEST_ORCHESTRATOR'*/ + animationsDisabled true + + unitTests { + includeAndroidResources = true + all { + maxHeapSize = "1024m" + } + } + } -ext { - dagger = '2.25.3' - support = '1.0.0' - anko = '0.10.1' - lifeCycle = '2.2.0-rc03' - fragment = '1.1.0-alpha07' - fragmentKtx = '1.1.0-rc01' - appCompat = '1.1.0' - room = '2.2.0' - threetenabp = "1.0.5" - threetenbp = "1.3.6" - timber = "4.7.1" - crashlytics = '2.10.1' - material = '1.2.0-alpha03' - navigation = '2.1.0' - pushMessage = '17.2.1' - firebase = '19.2.0' - firebaseAuth = '4.0.0' - firebasePush = '20.1.0' - archCoreTesting = '2.1.0-rc01' - coroutines = '1.3.0' - mockitoKotlin = "1.5.0" + // this is added for the test-shared module + // https://stackoverflow.com/questions/67571941/unresolved-reference-test-when-adding-module-for-android-tests + packagingOptions { + exclude "META-INF/licenses/**" + exclude "META-INF/AL2.0" + exclude "META-INF/LGPL2.1" + } + // this is also added for the test-shared module + configurations.all() { configuration -> + exclude group: "com.google.protobuf", module: "protobuf-java" + } } dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation project(':core') - // Crashes - implementation("com.crashlytics.sdk.android:crashlytics:$crashlytics@aar") { - transitive = true - } + implementation "com.google.firebase:firebase-crashlytics:$crashlytics" + + // Email & Google login + implementation "com.firebaseui:firebase-ui-auth:$firebaseUi" + implementation "com.firebaseui:firebase-ui-database:$firebaseUi" + implementation "com.google.firebase:firebase-auth:21.0.1" + implementation "com.google.android.gms:play-services-auth:19.2.0" + + // Phone number registration + implementation 'com.google.android.gms:play-services-auth-api-phone:17.5.1' - // Email, Google & Facebook login - implementation 'com.firebaseui:firebase-ui-auth:4.3.1' - implementation 'com.firebaseui:firebase-ui-database:3.1.0' - implementation "com.google.firebase:firebase-auth:$firebase" - implementation "com.google.firebase:firebase-database:$firebase" - implementation 'com.facebook.android:facebook-android-sdk:5.0.0' - implementation 'com.facebook.android:facebook-login:5.0.0' + // Facebook register & login + implementation "com.facebook.android:facebook-android-sdk:$facebook" + implementation "com.facebook.android:facebook-login:$facebook" + + // Cloud Storage + implementation "com.google.firebase:firebase-firestore-ktx:23.0.3" + implementation "com.google.firebase:firebase-database-ktx:$firebase" + implementation "com.google.firebase:firebase-storage-ktx:20.0.0" + implementation "com.firebaseui:firebase-ui-storage:4.3.2" // Push messages implementation "com.google.firebase:firebase-messaging:$firebasePush" - implementation "com.google.firebase:firebase-core:$pushMessage" + implementation "com.google.firebase:firebase-core:19.0.1" // Analytics - implementation 'com.google.firebase:firebase-analytics:17.2.2' + implementation "com.google.firebase:firebase-analytics:19.0.1" // Android view - implementation "androidx.appcompat:appcompat:$appCompat" - implementation "androidx.preference:preference:$appCompat" - implementation "androidx.core:core-ktx:$appCompat" + implementation "androidx.appcompat:appcompat:1.4.0-alpha03" + implementation "androidx.preference:preference-ktx:1.1.1" + implementation "androidx.core:core-ktx:1.6.0" implementation "com.google.android.material:material:$material" - implementation "androidx.constraintlayout:constraintlayout:1.1.3"//2.0.0-beta4" - implementation "androidx.cardview:cardview:$support" + implementation "androidx.constraintlayout:constraintlayout:2.1.0" + implementation "androidx.paging:paging-runtime-ktx:3.0.1" + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.viewpager2:viewpager2:1.0.0' // Navigation - implementation "androidx.navigation:navigation-fragment:$lifeCycle" - implementation "androidx.navigation:navigation-ui:$lifeCycle" implementation "androidx.navigation:navigation-fragment-ktx:$lifeCycle" implementation "androidx.navigation:navigation-ui-ktx:$lifeCycle" // For lifecycle and LiveData and ViewModel - implementation "androidx.lifecycle:lifecycle-runtime:$lifeCycle" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifeCycle" - implementation "androidx.lifecycle:lifecycle-extensions:$lifeCycle" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifeCycle" - implementation "androidx.lifecycle:lifecycle-common-java8:$lifeCycle" - - // Async communication - implementation "org.jetbrains.anko:anko-commons:$anko" - implementation "org.jetbrains.anko:anko-coroutines:$anko" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" + implementation "androidx.lifecycle:lifecycle-common-java8:2.3.1" // Databinding kapt "com.android.databinding:compiler:3.1.4" + // Networking + implementation "com.squareup.retrofit2:converter-moshi:$retrofit" + implementation "com.squareup.retrofit2:retrofit:$retrofit" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp" + implementation "com.squareup.okhttp3:okhttp:$okhttp" + // Dependency injection implementation "com.google.dagger:dagger:$dagger" implementation "com.google.dagger:dagger-android-support:$dagger" @@ -122,17 +219,49 @@ dependencies { // Date and time API for Java. api "com.jakewharton.threetenabp:threetenabp:$threetenabp" - testImplementation "org.threeten:threetenbp:$threetenbp" // Logging api "com.jakewharton.timber:timber:$timber" - // Test - implementation 'junit:junit:4.12' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + // Storage / caching + implementation "androidx.room:room-runtime:$room" + kapt "androidx.room:room-compiler:$room" + + // Video view library + implementation 'com.google.android.exoplayer:exoplayer:2.13.3' + implementation 'com.google.android.exoplayer:exoplayer-core:2.13.3' + implementation 'com.squareup.picasso:picasso:2.71828' + + // Animations + implementation "com.airbnb.android:lottie:3.4.1" + + // Utils & Event tracking + implementation "com.google.code.gson:gson:2.8.6" + implementation 'com.mixpanel.android:mixpanel-android:5.8.6' + + // Unit Tests + testImplementation(project(":test-shared")) + implementation "junit:junit:$junit" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2" + testImplementation "junit:junit:$junit" testImplementation "androidx.arch.core:core-testing:$archCoreTesting" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines" testImplementation "com.nhaarman:mockito-kotlin:$mockitoKotlin" + testImplementation "org.hamcrest:hamcrest-library:$hamcrest" + testImplementation "org.robolectric:robolectric:4.3.1" + + // Instrumentation tests + androidTestImplementation(project(":test-shared")) + androidTestImplementation "junit:junit:$junit" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso" + androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso" + androidTestImplementation "androidx.test.ext:junit:$testExt" + androidTestImplementation "androidx.test:rules:$rules" + androidTestImplementation "androidx.test:runner:1.4.1-alpha01" + androidTestImplementation "org.hamcrest:hamcrest:$hamcrest" + androidTestImplementation "com.microsoft.appcenter:espresso-test-extension:1.4" + androidTestImplementation "androidx.arch.core:core-testing:$archCoreTesting" + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2" + implementation "androidx.test.espresso:espresso-idling-resource:$espresso" } + +apply plugin: 'com.google.gms.google-services' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..a6ae11de 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,17 +5,12 @@ # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-renamesourcefileattribute SourceFile + +-keep class eu.theappfactory.dailyrecipes.** { *; } diff --git a/app/src/acceptance/res/mipmap-hdpi/ic_launcher.png b/app/src/acceptance/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..7776116e Binary files /dev/null and b/app/src/acceptance/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/acceptance/res/mipmap-hdpi/ic_launcher_round.png b/app/src/acceptance/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..2fe38fbf Binary files /dev/null and b/app/src/acceptance/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/acceptance/res/mipmap-mdpi/ic_launcher.png b/app/src/acceptance/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..be86155b Binary files /dev/null and b/app/src/acceptance/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/acceptance/res/mipmap-mdpi/ic_launcher_round.png b/app/src/acceptance/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..4cff3e58 Binary files /dev/null and b/app/src/acceptance/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/acceptance/res/mipmap-xhdpi/ic_launcher.png b/app/src/acceptance/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..3f199b51 Binary files /dev/null and b/app/src/acceptance/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/acceptance/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/acceptance/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..a40f94b0 Binary files /dev/null and b/app/src/acceptance/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher.png b/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..14c3fd70 Binary files /dev/null and b/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..1ba3fd5d Binary files /dev/null and b/app/src/acceptance/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..868faa65 Binary files /dev/null and b/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..ca1c4199 Binary files /dev/null and b/app/src/acceptance/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/acceptance/values/strings.xml b/app/src/acceptance/values/strings.xml new file mode 100644 index 00000000..a9cbc65e --- /dev/null +++ b/app/src/acceptance/values/strings.xml @@ -0,0 +1,11 @@ + + + DailyChef Acceptance + + + 2525268647718804 + 2525268647718804 + + fb2525268647718804 + + \ No newline at end of file diff --git a/app/src/androidTest/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSourceTest.kt b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSourceTest.kt new file mode 100644 index 00000000..7d233bb6 --- /dev/null +++ b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSourceTest.kt @@ -0,0 +1,26 @@ +package eu.theappfactory.dailyrecipes.data + +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matcher +import org.hamcrest.Matchers.* +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test + +/** + * Tests for [BootstrapItemDataSource] + */ +class BootstrapItemDataSourceTest { + + @Test + fun loadJson_resultIsNotEmpty() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val data = BootstrapItemDataSource(context).getOfflineItemData() + + // has to be at least 1 item + assertThat(data?.items, hasSize(greaterThan(0))) + // date has to be present for sorting. + assertThat(data?.items?.get(0)?.date?.seconds, greaterThan(0L) as Matcher?) + // Title can be empty string but not null + assertThat(data?.items?.get(0)?.title, `is`(notNullValue())) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/theappfactory/dailyrecipes/media/MediaPlayerTest.kt b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/media/MediaPlayerTest.kt new file mode 100644 index 00000000..7818fbc5 --- /dev/null +++ b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/media/MediaPlayerTest.kt @@ -0,0 +1,43 @@ +package eu.theappfactory.dailyrecipes.media + +import android.net.Uri +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.upstream.cache.Cache +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import eu.theappfactory.dailyrecipes.cache.VideoCacheManager +import eu.theappfactory.dailyrecipes.di.CacheModule +import org.hamcrest.Matchers.`is` +import org.hamcrest.core.IsNull.notNullValue +import org.junit.Assert +import org.junit.Test + +class MediaPlayerTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun whenInitializingWithUriGetMediaPlayer() { + val cache: Cache = SimpleCache( + context.cacheDir, + LeastRecentlyUsedCacheEvictor(CacheModule.CACHE_MAX_SIZE), + ExoDatabaseProvider(context) + ) + + val cacheDataSource = CacheDataSource.Factory().apply { + setCache(cache) + } + + val videoManager = VideoCacheManager(cacheDataSource) + + val player = MediaPlayer(context, videoManager).initializeExoplayer() + // Only for testing + player.setThrowsWhenUsingWrongThread(false) + MediaPlayer(context, videoManager).prepare(Uri.parse("asset:///vid1.m4v")) + + // Confirm that we have a exoPlayer + Assert.assertThat(player, `is`(notNullValue())) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/theappfactory/dailyrecipes/ui/home/ItemTest.kt b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/ui/home/ItemTest.kt new file mode 100644 index 00000000..2a602db8 --- /dev/null +++ b/app/src/androidTest/java/eu/theappfactory/dailyrecipes/ui/home/ItemTest.kt @@ -0,0 +1,57 @@ +//package eu.theappfactory.dailyrecipes.ui.home +// +//import androidx.arch.core.executor.testing.InstantTaskExecutorRule +//import androidx.test.espresso.IdlingResource +//import androidx.test.ext.junit.runners.AndroidJUnit4 +//import eu.theappfactory.test.MainCoroutineRule +//import kotlinx.coroutines.ExperimentalCoroutinesApi +//import kotlinx.coroutines.InternalCoroutinesApi +//import org.junit.Rule +//import org.junit.runner.RunWith +// +//@ExperimentalCoroutinesApi +//@InternalCoroutinesApi +//@RunWith(AndroidJUnit4::class) +//class ItemTest { +// +// // Executes tasks in the Architecture Components in the same thread +// @get:Rule +// var instantTaskExecutorRule = InstantTaskExecutorRule() +// +// // Overrides Dispatchers.Main used in Coroutines +// @get:Rule +// var coroutineRule = MainCoroutineRule() +// +// private val testDispatcher = coroutineRule.testDispatcher +// +// private var idlingResource: IdlingResource? = null +// +// // TODO: To fix this test, this question must be answered first: +// // https://stackoverflow.com/questions/66338416/internal-error-in-cloud-firestore-22-1-0-when-running-instrumentation-test +// +//// @Before +//// fun goToHomeScreen() { +//// // +//// getInstrumentation().waitForIdleSync() +//// +//// // Enable item idling resource +//// val activityScenario: ActivityScenario = ActivityScenario.launch(MainActivity::class.java) +//// activityScenario.onActivity { +//// idlingResource = it.getIdlingResource() +//// IdlingRegistry.getInstance().register(idlingResource) +//// } +//// Log.d("","") +//// +//// } +//// +//// @Test +//// fun scrollToNextVideo() { +//// onView(withId(R.id.navigation_home)).perform(ViewActions.click()) +//// onView(withId(R.id.recycler)).perform(swipeUp()) +//// } +//// +//// @After +//// fun tearDown() { +//// IdlingRegistry.getInstance().unregister(idlingResource) +//// } +//} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/theappfactory/sampleapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/eu/theappfactory/sampleapp/ExampleInstrumentedTest.kt deleted file mode 100644 index 4974243d..00000000 --- a/app/src/androidTest/java/eu/theappfactory/sampleapp/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eu.theappfactory.sampleapp - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("eu.theappfactory.sampleapp", appContext.packageName) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 58e4dd81..c09f9e5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,27 +1,70 @@ + package="eu.theappfactory.dailyrecipes"> + + + + + + + + + + + + + + + + + + + + + + + + + + + android:name="eu.theappfactory.dailyrecipes.ui.launch.LauncherActivity" + android:exported="true" + android:screenOrientation="portrait" + android:theme="@style/Theme.AppCompat.Light.NoActionBar"> @@ -30,16 +73,93 @@ + android:name="eu.theappfactory.dailyrecipes.ui.main.MainActivity" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustNothing" /> + + + + + + + + + + + + + + + + + android:name=".ui.invite.InviteCodeActivity" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustResize" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/arrow.json b/app/src/main/assets/arrow.json new file mode 100644 index 00000000..59b0f281 --- /dev/null +++ b/app/src/main/assets/arrow.json @@ -0,0 +1 @@ +{"v":"5.7.2","fr":30,"ip":0,"op":250,"w":1441,"h":500,"nm":"elements-02","ddd":0,"assets":[{"id":"image_0","w":316,"h":339,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATwAAAFTCAYAAACkp7CJAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAVC0lEQVR4nO3d63FbR9LG8Wfe2u+kIxAdgbgREI7A3AgERyA6AkMZ0BEYisBgBAYjEBiBwQhMRtDvhzOQIJqXc++5/H9VrPW6BKBF0a2eMz09EgAAAAAAAAAAAAAAAAAAAAAAABrBO4Axmdki/uO5pNP4zw+SdpIeQgg7j7gApCHrhGdml5IW8et9y5fdSdpI2pAAASTNzE7NbGVmextuZ2ZL798TAHznKNE9jJDontpbUy0CgC8zu7RxKrq3bM3szPv3C6BSZraeIdEdezCqPQBzsmYJu5s52R278v4eAKhAAsnuYO39vQBQMEsn2R2svb8nAApk6SW7g2vv7w2AwpjZxjuzvWLp/f0BUAgzu/LOaC2ce3+fAPSXxNEya3rfdpJOnEN5y6OksxDCg3cgALr7P+8AorXST3ZSE+PGOwgA/bgnPGsmnFx4x9HBhdGjB2TJfUlrZlvllfCkZml7HkLYewcCoD3XhBef3f3tGcMAtyGEhXcQANrzXtLmvDS8MM7cAlnxrvD2kt55xjDQvZqlLbu2QAbcKrzY05ZzspOa+HOuUoGqeC5pF46fPaYrY4YekAXPhFfKqYUTSSvvIAC8zTPhnTl+9tg+UOUB6fNMeKdv/5KsrLwDAPA6t11aMzOvz57QjzQjA+ny7sMrzco7AAAvo8IbH1UekCjPCu/O8bOnRF8ekCjPhFfq6YSlmZW2IQMUwTPh7R0/e0onosoDkkTCm8bSOwAA/+aZ8LaOnz21d8alP0ByPHdpTyX94/X5M7gLIZRyfA4ogluFF0cqlbpTK0nv4/h6AInwbjzeOn/+1Ni8ABLiPQD0XNIXzxhmQCMykAjXCi+EsFMzNbhkVHlAIryXtFL597zSiAwkIoWEt/YOYGInoi8PSIL7vbRSEZf5vOU+hHDmHQRQuxQqPEm69g5gYjQiAwlIpcIrvQlZ4uJuwF0SFV5sQv7sHcfELmhEBnwlkfCitXcAM6BFBXCUxJL2oILNC4lGZMBNShWeVMedECvvAIBapVbhnaqZk3fiHMqUHiWdxeeWAGaUVIUXk0DpJy+YiAw4SarCkyQzO5P0t3ccE6MRGXCQVIUnSfGB/q13HBOjERlwkFzCi9beAcxg5R0AUJvklrQHZvagsjcvJOmnEMLWOwigFqlWeFL552slqjxgVilXeGcqf/NCohEZmE2yFV5MAjfeccxg5R0AUItkE1609g5gBh9iNQtgYkknvBDCRuXfeSExERmYRdIJL6ph8+KKey+A6eWQ8NbeAczgRNKldxBA6ZJPeJUMB5XYvAAml3zCi2pY1nLcDJhYFgkvXth95x3HDJbeAQAlyyLhRTVUedx7AUwop4S3UTM8s3RL7wCAUmWT8OLmxdo7jhnQiAxMJJuEF9WwrJXYsQUmkezwgJeY2VbShXccE+PeC2ACuVV4Uh3LWu69ACaQXYUnVXN/LfdeACPLscKT6qjyaEQGRpZrhXemOoaDUuUBI8oy4UmSma0lffCOYwbce1GJODFnIek8/u9zdvFry6TsipjZwuqw9f5eY1pmtjSzTY+fjZ2ZMVqsg2wrPKmazQuJey+KE5PUVfwaejvfo5oe1WtamQoW/3arwdr7e43xWFPRPUzwc7I3M+YqviL3Cu9U0l7l318rUeVlz5rNtrWmb5z/PYRAH+czcm1LkfT1fO3GO46ZLL0DQH/WtBjtNM8poY/WPN/j2d4TWVd4kmRm55K+eMcxA46bZSgmnWv5dBTcSVrwM/NN1hWe9HU46K13HDPg3ovMxGS3lV/71HtJWyq9b7JPeNHaO4CZrLwDQDtx5bFXk3Q8kfSOFJHwQghr1TEc9J1x3Cx5Mdltlc5m2nvVUxS8qoiEF629A5jJ0jsAvCzBZHfws5mtvIPwlv2mxYHVc75W4rhZa9bcEXKq5rjWSx7U7KDuh7T+JJzsjlX9s1NMwpMkM9tI+tk7jhl8DiEsvYNITXxOdanmHOpC/U7hPKpJWltJm7YJMJNkJ1W+219awruU9Kd3HDOhEVnfJbkrTbNBcKemrWTzUpLIKNkd3IQQqtzxLyrhSVWdr626youPMFZqkt0ciebZ86oZJruD/4UQamna/6rEhLeS9Jt3HDOocmni3MgrNd/3qxDCOibdnfJLdpJ0L+m8tp+fknZpD2q52ay6ey/M7EpNb5vnHMQTSX9YM7ZrozyTndSsgqr6+ZEKrPCkqoaDVjERecZD97V5VFPl7b0DmUuJFZ5UT09e8Y3IcSNqrkP3tTlRZad3iqzwJMnMdvI/1jOHuxDCaz1m2YrJ/A/vOCpQzY5/qRWeVM+zvPexubYo8bEEyW4eK+8A5lJyhVfTcNDbEMJiijeOyXSh5qTCqV5fWj4qnljQt4tmdj0+81rSx66vwyBVVHnFJjypqs0LaaQf2KNG3kuNc2rlUc1u5rrNkSaWsW6q6OssPeGdqZ7ztYN+YGMD7ZWm/QviXtIqTrd5KYYahrmmqvgqr+iEJ0mxX6qWHb4fujaSxiRzrXm/R/9KfLGy3KmOUzKpKr7KK3nT4mDtHcCMWjeSmtlpfFb2RfP/hfBOsXk3VuFS8+CcZOfrsvRBocVXeFJV52tbHTeLGxGpnBI4nFGt4ThgDj6FEFbeQUylhgpPqqfKe/Pei3jW+C+lkeykJg6SXTqW3gFMqZYK70z1bF68eNyssl1r9PfLSxtLuauiwos7Tzfecczk2eNmJDt0sPQOYCpVJLyolpMXknR9OH0RNyfWItmhvYu4e1+cKpa0BxVtXgBDFdmiUlOFJ9VV5QFDfCixRaW2hLf2DgDIyNI7gLFVlfBif9pn7ziATBQ3EbmqhBexrAXaeRcHsBajuoQXxxXdeccBZKKoKq+6hBdR5QHtXBydd85eVW0px8zsQekcrwJSVkyLSq0VnsSOLdBWMVNUak54LGuBdk5USItKtUtaqbrhoMAQRdyBXHOFJ1HlAW0V0aJSdYUncb4W6GCy2/HmUnuFJ7F5AbSVfYsKFV6z+/SPdxxAJrJuUam+wuN8LdBJ1i0q1Se8aO0dAJCJrFtUql/SHrB5AbSWbYsKFd43K+8AgExk26JChRfF5xJ7cb4WaCPLFhUqvChuXmy84wAykWWLCgnve5y8ANpbeQfQFUvaJzhfC7T2KOksro6yQIX3b2vvAIBMZNeiQoX3DIaDAq1l1aJChfc8nuUB7WTVokKF94y4+/S3dxxAJrJpUaHCe0YIYS/pxjsOIBPZtKiQ8F629g4AyMjKO4A2WNK+gvO1QGtZtKhQ4b2OzQugnRNJyW9eUOG9guGgQCfJt6hQ4b2C4aBAJ+/MbOEdxGtIeG9bewcAZOTKO4DXsKRtwcx2kt57xwFk4sfY2pUcKrx22LwA2ku2yqPCa4HhoEAnybaoUOG1EP/g1t5xAJlItkWFCq8lztcCnSTZokKF11J8CHvrHQeQiSRbVEh43ay9AwAyktzmBUvajjhfC3SSVIsKFV53a+8AgIwkVeVR4XXE5gXQSVItKlR4HcXynPO1QDtJtahQ4fUQd5/+8o4DyEQyLSpUeD2EELaS7r3jADKRTIsKCa8/ztcC7SWxecGStifO1wKdubeoUOH1FHedNt5xABlxr/Ko8AYws3NJX7zjADLh3qJChTdACGEn6c47DiAT7i0qJLzh2LwA2lt5fjhL2hGY2YPYvADa+im2ds2OCm8ca+8AgIwsvT6YCm8EnK8FOnNpUaHCG0H8g7vxjgPIyNLjQ6nwRmJml5L+9I4DyMRjCOF07g+lwhtJCGEjztcCbZ2Y2XLuDyXhjWvtHQCQkdlPXrCkHVE8X/uPdxxARv4bG/hnQYU3onhkhuGgQHuzVnlUeCNjOCjQ2Q9zna+lwhtZ7CDnfC3Q3nKuDyLhTYPztUB7sy1rSXjT2KgZhQPgbbONgP/PHB/ynPgbXEg6i1/HHiTt1EwU3s25izOGEMKDmW0kffCOBcjEUtJ26g+ZddMinkZYSvq540sf1XwzNpI2qdxx+RrO1wKdTb55MUvCix3VK0nvRnrLz2oSX9Ij1s1sK+nCOw4gE7+GECZ9/j1pwovL1rXGS3RP3avZIFinWPXFRP+HdxxAJia/v3aShBdPHKwkfZzi/Z/xqCbxXaeW+BgOCnQy6XDQ0XdpY7Lbar5kJzUJ5TdJezNbxRhSQYsK0N5yyjcftcKLt3ht5V/RJFPxsXkBdDLpzWajVXgJJTspoYqP4aBAJ5PebDZKhRermJ3SSHbPca34OF8LdHIbQlhM8caDE97RM7v3g6OZnlviM7O9ptutBkozyZ0XYyxpr5VHspO+LXV3DtNW2bwA2ptkWTuowivgHod7SasQwnrqD2I4KNDJJD15vSu8+B/werxQXLyT9IeZ7aY+vMxwUKCTd3EjdFRDlrRXSneToqv3kv4ys+3EiY9lLdDecuw37LWkjdXdXuUkvKc+q1nq7sd+YzPbKZ9nnoCn0Ze1fSu8kqq753yQ9LeZXU/Qw0eVB7Qz+rK2b4VX0/nQRzVtLKux3rCy7x8wxO8hhNEmIneu8GI7R03/sZ5I+s3M9iO2sqxHeh+gdKO2p/RZ0k527CNxhx3dMTY2WNYC7Yy6rO2T8BZjfXimLtTs6K7jkbrO4mbI7ZhBAQUbrcjqlPBiZVPTcvY1H9Sc2Og7nIAqD2jHJ+FJGr0RMHPHR9U6/aHE8fT3k0QFlOV939XUU10T3igfWqB3kv6Mz/e6/KWwnigeoDSLMd6ECm9cF5K+dOjfW08cD1CKUZa1XMQ9jY9qho8uX/tFcfOC87XA2xZjvAkJbzon+tbG8lplvJ8pHiBnJ2OccyfhTe+tZe7ZzPEAuVoMfQMS3nz+tcw1sys17S0A3rYY+gadztKa2VZNxQIAswshDBpa3LXC2w75MAAYYuhzvK4Jbz/kwwBgoMWQF1PhAcjJYsiLO6+HuW4QgKPHEELvobx9dmk3fT8MAAY6GTIuqk/CW/f9MAAYwXwJL4Swk3TX9wMBYKBZKzyJWW4A/PROeL2b+Ni8AOCk98bF0Iu4AWBuJ32vT+2d8OLEXu5lAOCh17J26PCApZp7WwFgTvMnvDjAcjXkPQCgh9mf4UmSQgjXkm6Gvg8AdOCypD1Yihu4AMzHp8KTpBDCg5pLNnieB2AOvSq8QcP0nopn3L6M+Z4A8Jw+w0BHHfEej539MuZ7AsBYRr/TIoSwFkkPwMT6TD+e5BKfmPT+J57pAUjIZLeWxZMYC5H0ACRi0msa4zO9hRgnBSABk99Le5T0OHcLwNUsF3GHEB5CCAtJv8/xeQCq0Ln5eJaEdxBCuBKbGQDG0bn5eNaEJ33dzDgXz/UADLPv+oLZE57UTFkJIZxL+uTx+QCKsO/6ApeEdxBCWEn6SQweADAD14QnSSGErZolLhsaACblnvCkr7u4V6LaAzChJBLeAdUegCmNOh5qTPFg8FpcBQngGe7jocZEtQdgbMlWeMditXct6b1zKADS0Osy7mQrvGMhhC19ewCO7Pq8KIuEdxD79v4rTmkAtdv3eVFWCU9qpq9Q7QHV2/d5UXYJ7+Co2mPsFFCfbZ8XZZvwpK/V3kLSr2ICC1CTfZ8XZbFL24aZnanp27vwjQTAxHrt0EqZV3jH4gSWhaj2gNL12qGVCkp4ByGEazUNyzzbA8q07fvC4hKe9F21x3RloDzbvi8s5hneS8zsVM2zvZ+dQwEwXO/nd1KhFd6xOHrqUlR7QAm2Q15cfMI7iHdpnEn67BwKgP42Q15c/JL2OYyeArL1Qwjhoe+Lq6nwjjF6CsjSzZBkJ1Wa8CTGygMZGrSclSpOeAdUe0AWHkXCG8dRtcfoKSBNm6HLWYmE9x1GTwHJuh7jTarcpW3DzM7V7OQyVh7wdRtPTg1GhfeCJ9UeDcuAn1GqO4kKrxVGTwFu7kMIZ2O9GRVeC4yeAtysxnwzKryOqPaA2dzFx0qjocLriGoPmM3V2G9IhTcAo6eAyYy2M3uMhDcCM7tUk/hOnEMBSvFjCGE/9puypB3B0eipG+dQgBJ8miLZSVR4o4vV3rUYPQX0MWobylNUeCOL1R7DCIB+llO+OQlvAoyeAnr5FKcXTYYl7cTiTu5K0kfnUICUjd5z9xwS3kwYKw+86FHS+VQbFcdY0s4khLCND2MZPQV8bzlHspOo8Fwwegr46vf4vHsWJDxHZraS9Jt3HICTm3hn9GxIeM6o9lCpO0mLMca2d8EzPGdHg0YZRoBa3Msh2UlUeElh9BQq8Kgm2e08PpwKLyGMnkLhXJOdRIWXLKo9FMY92UkkvOQxegoFSCLZSSS8LDBoFBlLJtlJJLysUO0hM0klO4mElx2qPWTCpc/uLezSZiaOnroUo6eQrlslmOwkEl624twwBo0iNZ9DCEkmO4klbREYPYVE/BJCWHsH8RoSXiEYNApHyW1OvISEVxiGEWBmt5IuU13CPsUzvMIcDSNg0Cim9inl53XPocIrGNUeJnKvZkrx1juQrqjwCka1hwncqLl/YusdSB9UeJVgGAEGelRT1W28AxmCCq8SjJ7CADeSznJPdhIVXpWo9tBSts/qXkKFVyGqPbTwSRk/q3sJFV7lGEaAJ2414z2xcyPhQRKjp1De8vU5LGkhSYoPpM/UPKBGPR4l/RpCOCs92UlUeHhGrPauxTCCkj2q+TO+zumkxFAkPDyLYQRF+yzpqqZEd0DCw6vi6KlrcTytBJ8lrUrdkGiDZ3h4VQhhy/G07H2W9GMIodjd17ao8NAaDctZeVTzZ3Vde5I7RsJDZ7SwJK3KzYi2SHjoJW5qXEn6zTsWSGr66Fapj1j3RsLDICxz3d2oqea23oHkgISHUXCR0Kx4PtcTCQ+jMrMrNf17PN8b362kNcvW/kh4GN3R870rkfiGopobEQkPkyHxDXIjaUM1Ny4SHiZH4mvtTk01t6GamwYJD7Mh8T3rXtJGzbO55C+yzh0JDy7MbKkm8dV4Rpck54SEB1exnWUp6YNvJJO7k7QVSc4VCQ9JiMvdS5VV9d2oSXI8k0sECQ/Jiac3LtVUfjklv1s1CW7LyYc0kfCQtKPKbxG/UjnJ8ShpJxJcVkh4yEqs/haSzo++pt7xvZO0V5PgdpJ2LFHzRMJD9mIVeK7mEqKz+K8XT37Zqb4tjw/V2bGHo3+3i/9/T2IDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA9/4fsHnjghjTk/8AAAAASUVORK5CYII=","e":1}],"layers":[{"ddd":0,"ind":6,"ty":2,"nm":"elements-03.png","cl":"png","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":198,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":216,"s":[96]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":230,"s":[96]},{"t":242,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.554,"y":1},"o":{"x":0.333,"y":0},"t":198,"s":[1322.5,261,0],"to":[-3.333,-16,0],"ti":[3.333,16,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":216,"s":[1302.5,165,0],"to":[0,0,0],"ti":[0,0,0]},{"t":230,"s":[1302.5,165,0]}],"ix":2},"a":{"a":0,"k":[158,169.5,0],"ix":1},"s":{"a":0,"k":[57,57,100],"ix":6}},"ao":0,"ip":0,"op":320,"st":50,"cp":false,"bm":0},{"ddd":0,"ind":7,"ty":2,"nm":"elements-03.png","cl":"png","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":148,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":166,"s":[96]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":180,"s":[96]},{"t":192,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.548,"y":1},"o":{"x":0.333,"y":0},"t":148,"s":[1038.5,261,0],"to":[-3.833,-16,0],"ti":[3.833,16,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":166,"s":[1015.5,165,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[1015.5,165,0]}],"ix":2},"a":{"a":0,"k":[158,169.5,0],"ix":1},"s":{"a":0,"k":[57,57,100],"ix":6}},"ao":0,"ip":0,"op":320,"st":50,"cp":false,"bm":0},{"ddd":0,"ind":8,"ty":2,"nm":"elements-03.png","cl":"png","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":99,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":117,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":131,"s":[100]},{"t":143,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.516,"y":1},"o":{"x":0.333,"y":0},"t":99,"s":[757.5,260,0],"to":[-3.667,-14.5,0],"ti":[3.667,14.5,0]},{"i":{"x":0.516,"y":0.516},"o":{"x":0.333,"y":0.333},"t":117,"s":[735.5,173,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[735.5,173,0]}],"ix":2},"a":{"a":0,"k":[158,169.5,0],"ix":1},"s":{"a":0,"k":[57,57,100],"ix":6}},"ao":0,"ip":0,"op":320,"st":50,"cp":false,"bm":0},{"ddd":0,"ind":9,"ty":2,"nm":"elements-03.png","cl":"png","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":68,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":82,"s":[100]},{"t":94,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.513,"y":1},"o":{"x":0.333,"y":0},"t":50,"s":[478.5,276,0],"to":[-4.667,-17.167,0],"ti":[4.667,17.167,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":68,"s":[450.5,173,0],"to":[0,0,0],"ti":[0,0,0]},{"t":82,"s":[450.5,173,0]}],"ix":2},"a":{"a":0,"k":[158,169.5,0],"ix":1},"s":{"a":0,"k":[57,57,100],"ix":6}},"ao":0,"ip":0,"op":320,"st":50,"cp":false,"bm":0},{"ddd":0,"ind":10,"ty":2,"nm":"elements-03.png","cl":"png","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":44,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.388,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[168.5,282,0],"to":[-2.333,-19,0],"ti":[2.333,19,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":19,"s":[154.5,168,0],"to":[0,0,0],"ti":[0,0,0]},{"t":33,"s":[154.5,168,0]}],"ix":2},"a":{"a":0,"k":[158,169.5,0],"ix":1},"s":{"a":0,"k":[57,57,100],"ix":6}},"ao":0,"ip":0,"op":270,"st":0,"cp":false,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/assets/swipe_up_for_vids_animation.json b/app/src/main/assets/swipe_up_for_vids_animation.json new file mode 100644 index 00000000..141c79b2 --- /dev/null +++ b/app/src/main/assets/swipe_up_for_vids_animation.json @@ -0,0 +1 @@ +{"v":"5.5.9","fr":60,"ip":0,"op":62,"w":360,"h":412,"nm":"Frame 193","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":5,"nm":"","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180.5,326.938,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"t":{"d":{"k":[{"s":{"sz":[194.820007324219,34],"ps":[-97.4100036621094,-17],"s":24,"f":"MyriadPro-Regular","t":"","j":2,"tr":0,"lh":28.8,"ls":0,"fc":[1,1,1]},"t":0}]},"p":{},"m":{"g":1,"a":{"a":0,"k":[0,0],"ix":2}},"a":[]},"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[-20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[22]},{"t":49,"s":[-20]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.52,"y":1},"o":{"x":0.244,"y":0},"t":0,"s":[237.199,201.62,0],"to":[-0.333,-7.833,0],"ti":[0,0,0]},{"i":{"x":0.606,"y":1},"o":{"x":0.421,"y":0},"t":26,"s":[235.199,154.62,0],"to":[0,0,0],"ti":[-0.333,-7.833,0]},{"t":49,"s":[237.199,201.62,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[135,135,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.6,-2.4],[-1.9,-0.3],[0.2,-2.8],[0,0],[0,-0.1],[9.5,0],[0,0],[3.2,4.5],[0,0],[-2.5,1.7],[-1.5,-2.3],[0,0],[0,0.7],[0,0],[-3.3,-0.4],[0,-2.8],[0,0],[-1.5,0],[-0.7,-2.2],[-1.7,0]],"o":[[1.2,-1.3],[2.7,0.3],[0,0],[0,0.2],[-0.1,9.5],[0,0],[-5.6,0],[-0.1,-0.1],[-1.4,-2.2],[2.5,-1.6],[0,0],[0.4,0.6],[0,0],[0,-3.2],[2.7,0.3],[0,0],[1,-1],[2.4,0],[1.1,-1.2],[2.6,0]],"v":[[18.886,-5.88],[23.686,-7.68],[28.186,-2.08],[28.186,19.12],[28.186,19.52],[10.786,36.62],[4.486,36.62],[-9.614,29.42],[-27.514,2.42],[-25.614,-4.68],[-18.314,-3.58],[-12.014,6.02],[-10.614,5.62],[-10.614,-31.18],[-4.414,-36.58],[0.286,-30.98],[0.286,-10.28],[4.186,-11.88],[9.386,-8.08],[13.586,-10.08]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-48.494,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"▽ Group 1101","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180,180,0],"ix":2},"a":{"a":0,"k":[52,92,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"▽ Group 1102","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52,92,0],"ix":2},"a":{"a":0,"k":[52,92,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":3,"nm":"▽ Group 1103","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52,89,0],"ix":2},"a":{"a":0,"k":[49,85,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Polygon 3","parent":5,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":29,"s":[1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[100]},{"t":47,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[52.752,238.356,0],"to":[0,-24.5,0],"ti":[0,24.5,0]},{"t":46,"s":[52.752,91.356,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[-100,-100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.54,-2.667],[0,0],[-3.079,0],[0,0],[1.54,2.667]],"o":[[-1.54,-2.667],[0,0],[-1.54,2.667],[0,0],[3.079,0],[0,0]],"v":[[3.464,-19],[-3.464,-19],[-18.187,6.5],[-14.722,12.5],[14.722,12.5],[18.186,6.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.768627464771,0.768627464771,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-90,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polygon 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Polygon 2","parent":5,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"t":34,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[52.752,91.356,0],"to":[0,-16,0],"ti":[0,16,0]},{"t":44,"s":[52.752,-4.644,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[-100,-100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.54,-2.667],[0,0],[-3.079,0],[0,0],[1.54,2.667]],"o":[[-1.54,-2.667],[0,0],[-1.54,2.667],[0,0],[3.079,0],[0,0]],"v":[[3.464,-19],[-3.464,-19],[-18.187,6.5],[-14.722,12.5],[14.722,12.5],[18.186,6.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.768627464771,0.768627464771,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-90,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polygon 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Rectangle 650","sr":1,"ks":{"o":{"a":0,"k":70,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180,177,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[98,170],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":13,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 648","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Rectangle 648","parent":5,"sr":1,"ks":{"o":{"a":0,"k":70,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[49,85,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[98,170],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":13,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.729166686535,0.729166686535,0.729166686535,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 648","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":-95,"op":3505,"st":-95,"bm":0}]}],"fonts":{"list":[{"fName":"MyriadPro-Regular","fFamily":"Myriad Pro","fStyle":"Regular","ascent":70.9991455078125}]},"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 1","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180,206,0],"ix":2},"a":{"a":0,"k":[180,206,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":360,"h":412,"ip":0,"op":62,"st":0,"bm":0}],"markers":[],"chars":[{"ch":"S","size":24,"style":"Regular","w":49.3,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-6.546,0],[0,10.474],[11.481,4.431],[0,6.345],[-9.265,0],[-2.216,-1.208],[0,0],[6.949,0],[0,-10.574],[-11.078,-3.928],[0,-6.345],[8.963,0],[3.928,2.417]],"o":[[3.625,2.417],[16.013,0],[0,-9.97],[-9.366,-3.625],[0,-4.633],[6.143,0],[0,0],[-3.021,-1.712],[-13.193,0],[0,9.567],[9.164,3.525],[0,6.848],[-6.042,0],[0,0]],"v":[[4.23,-3.323],[21.552,1.007],[45.319,-18.53],[27.997,-38.47],[14.502,-51.562],[27.292,-61.734],[40.182,-58.511],[42.599,-65.662],[27.594,-68.985],[5.64,-50.555],[23.566,-31.32],[36.356,-17.825],[22.156,-6.244],[6.445,-10.675]],"c":true},"ix":2},"nm":"S","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"S","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"w","size":24,"style":"Regular","w":74.8,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-1.208,5.841],[0,0],[-1.712,-5.438],[0,0],[0,0],[0,0],[0,0],[0,0],[0.806,-4.935],[0,0],[1.813,5.841],[0,0],[0,0],[0,0],[1.108,-4.935],[0,0],[1.41,5.438],[0,0]],"o":[[0,0],[0,0],[0,0],[1.712,-5.237],[0,0],[1.208,5.74],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.611,5.74],[0,0],[-1.108,-4.935],[0,0],[0,0],[0,0],[-1.611,5.237],[0,0],[-0.906,-5.035],[0,0],[0,0]],"v":[[1.813,-48.743],[16.516,0],[24.573,0],[32.428,-23.163],[36.859,-39.276],[37.061,-39.276],[41.391,-23.264],[48.843,0],[56.9,0],[72.61,-48.743],[63.849,-48.743],[56.9,-24.271],[53.174,-8.459],[52.872,-8.459],[48.541,-24.371],[40.988,-48.743],[33.536,-48.743],[25.58,-23.868],[21.249,-8.459],[20.947,-8.459],[17.322,-23.969],[10.876,-48.743]],"c":true},"ix":2},"nm":"w","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"w","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"i","size":24,"style":"Regular","w":23.4,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.214,0],[16.214,-48.743],[7.352,-48.743],[7.352,0]],"c":true},"ix":2},"nm":"i","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[3.323,0],[0,-3.122],[-3.122,0],[0.101,3.021]],"o":[[-3.223,0],[0,3.021],[3.525,0],[0,-3.122]],"v":[[11.783,-67.978],[6.244,-62.439],[11.581,-57.001],[17.221,-62.439]],"c":true},"ix":2},"nm":"i","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"i","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"p","size":24,"style":"Regular","w":56.9,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-6.445,0],[0,17.523],[11.783,0],[3.625,-5.942],[0,0],[0,0],[0,0],[0,-6.244]],"o":[[0,0],[0,0],[0,0],[2.921,4.834],[11.481,0],[0,-14.804],[-7.956,0],[0,0],[0,0],[0,0],[0.201,4.633],[0,0]],"v":[[7.352,19.94],[16.113,19.94],[16.113,-6.546],[16.315,-6.546],[31.32,1.108],[53.476,-25.076],[32.831,-49.85],[15.509,-40.384],[15.308,-40.384],[14.905,-48.743],[6.949,-48.743],[7.352,-32.831]],"c":true},"ix":2},"nm":"p","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[-0.302,1.108],[-6.042,0],[0,-9.769],[9.366,0],[1.611,6.143],[0,1.309]],"o":[[0,-1.208],[1.712,-6.647],[9.366,0],[0,11.179],[-6.345,0],[-0.201,-1.108],[0,0]],"v":[[16.113,-28.198],[16.718,-31.824],[30.112,-42.801],[44.614,-24.673],[29.81,-5.841],[16.516,-16.214],[16.113,-19.839]],"c":true},"ix":2},"nm":"p","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"p","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"e","size":24,"style":"Regular","w":50.1,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,1.813],[15.912,0],[0,-14.703],[-15.005,0],[-3.122,1.41],[0,0],[6.345,0],[0.201,11.984]],"o":[[0.101,-0.906],[0,-8.963],[-14.2,0],[0,14.703],[7.755,0],[0,0],[-3.323,1.41],[-8.862,0],[0,0]],"v":[[46.527,-22.76],[46.829,-26.889],[26.688,-49.85],[3.827,-23.566],[27.795,1.007],[44.009,-2.014],[42.499,-8.359],[29.004,-5.841],[12.286,-22.76]],"c":true},"ix":2},"nm":"e","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[-8.963,0],[0.101,-5.64]],"o":[[0.705,-6.143],[9.97,0],[0,0]],"v":[[12.387,-29.105],[25.983,-43.506],[38.269,-29.105]],"c":true},"ix":2},"nm":"e","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"e","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":" ","size":24,"style":"Regular","w":21.2,"data":{},"fFamily":"Myriad Pro"},{"ch":"u","size":24,"style":"Regular","w":55.1,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0.504,-1.309],[5.942,0],[0,9.265],[0,0],[0,0],[0,0],[-7.654,0],[-2.316,3.928],[0,0],[0,0],[0,0],[0,5.035]],"o":[[0,0],[0,0],[0,1.611],[-1.611,3.928],[-8.057,0],[0,0],[0,0],[0,0],[0,17.12],[8.661,0],[0,0],[0,0],[0,0],[-0.302,-3.827],[0,0]],"v":[[48.138,-48.743],[39.276,-48.743],[39.276,-18.832],[38.47,-14.301],[26.788,-6.244],[15.912,-21.753],[15.912,-48.743],[7.05,-48.743],[7.05,-20.242],[23.868,1.108],[39.981,-7.956],[40.182,-7.956],[40.686,0],[48.541,0],[48.138,-13.293]],"c":true},"ix":2},"nm":"u","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"u","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"f","size":24,"style":"Regular","w":29.2,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-7.553,0],[-1.309,-0.604],[0,0],[3.122,0],[3.323,-3.223],[0,-6.848],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,-7.452],[2.518,0],[0,0],[-1.712,-0.705],[-4.129,0],[-4.129,3.928],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[17.02,0],[17.02,-41.995],[28.802,-41.995],[28.802,-48.743],[17.02,-48.743],[17.02,-51.361],[26.486,-65.46],[32.126,-64.352],[33.334,-71.201],[25.781,-72.61],[13.898,-68.079],[8.258,-51.059],[8.258,-48.743],[1.41,-48.743],[1.41,-41.995],[8.258,-41.995],[8.258,0]],"c":true},"ix":2},"nm":"f","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"f","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"o","size":24,"style":"Regular","w":54.9,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[13.998,0],[0,-16.315],[-13.193,0],[0,18.027]],"o":[[-13.495,0],[0,15.408],[11.783,0],[0,-14.905]],"v":[[27.997,-49.85],[3.827,-23.969],[27.191,1.108],[51.462,-24.774]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-10.474,0],[0,-8.258],[8.459,0],[0,10.675]],"o":[[10.474,0],[0,10.977],[-8.661,0],[0,-9.265]],"v":[[27.795,-43.204],[42.398,-24.472],[27.594,-5.539],[12.79,-24.271]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"o","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"r","size":24,"style":"Regular","w":33.1,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.201,1.208],[-6.244,0],[-0.906,-0.201],[0,0],[1.007,0],[2.216,-6.546],[0,0],[0,0],[0,0],[0,-5.74]],"o":[[0,0],[0,0],[0,-1.511],[1.208,-6.647],[1.208,0],[0,0],[-0.806,-0.201],[-5.942,0],[0,0],[0,0],[0,0],[0.302,4.532],[0,0]],"v":[[7.352,0],[16.113,0],[16.113,-25.983],[16.516,-30.112],[28.4,-41.492],[31.421,-41.19],[31.421,-49.548],[28.903,-49.85],[15.408,-39.175],[15.005,-39.175],[14.703,-48.743],[6.949,-48.743],[7.352,-33.536]],"c":true},"ix":2},"nm":"r","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"r","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"},{"ch":"m","size":24,"style":"Regular","w":83.4,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.504,1.309],[-5.539,0],[0,-7.755],[0,0],[0,0],[0,0],[-0.403,1.208],[-5.035,0],[0,-9.366],[0,0],[0,0],[0,0],[6.445,0],[2.921,-2.216],[1.511,-2.719],[0,0],[6.647,0],[2.719,-4.633],[0,0],[0,0],[0,0],[0,-5.035]],"o":[[0,0],[0,0],[0,-1.511],[1.41,-4.431],[6.747,0],[0,0],[0,0],[0,0],[0,-1.611],[1.511,-4.33],[7.15,0],[0,0],[0,0],[0,0],[0,-16.919],[-4.633,0],[-2.014,1.511],[0,0],[-2.115,-5.74],[-8.057,0],[0,0],[0,0],[0,0],[0.302,4.028],[0,0]],"v":[[7.352,0],[16.013,0],[16.013,-29.407],[16.718,-33.737],[27.695,-42.599],[37.866,-29.205],[37.866,0],[46.527,0],[46.527,-30.112],[47.232,-34.543],[57.706,-42.599],[68.28,-27.594],[68.28,0],[76.941,0],[76.941,-28.702],[60.928,-49.85],[50.153,-46.426],[44.714,-40.082],[44.513,-40.082],[30.716,-49.85],[15.408,-40.887],[15.106,-40.887],[14.703,-48.743],[6.949,-48.743],[7.352,-35.55]],"c":true},"ix":2},"nm":"m","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"m","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"Myriad Pro"}]} \ No newline at end of file diff --git a/app/src/main/assets/vid1.m4v b/app/src/main/assets/vid1.m4v new file mode 100644 index 00000000..8fede9d9 Binary files /dev/null and b/app/src/main/assets/vid1.m4v differ diff --git a/app/src/main/assets/vid2.m4v b/app/src/main/assets/vid2.m4v new file mode 100644 index 00000000..11cbbeed Binary files /dev/null and b/app/src/main/assets/vid2.m4v differ diff --git a/app/src/main/java/eu/theappfactory/sampleapp/MainApplication.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/MainApplication.kt similarity index 77% rename from app/src/main/java/eu/theappfactory/sampleapp/MainApplication.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/MainApplication.kt index f0c26cf8..5117bda8 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/MainApplication.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/MainApplication.kt @@ -1,16 +1,17 @@ -package eu.theappfactory.sampleapp +package eu.theappfactory.dailyrecipes import android.os.StrictMode -import com.crashlytics.android.Crashlytics -import com.facebook.FacebookSdk import com.jakewharton.threetenabp.AndroidThreeTen import dagger.android.AndroidInjector import dagger.android.DaggerApplication -import eu.theappfactory.sampleapp.dagger.DaggerAppComponent -import eu.theappfactory.sampleapp.util.CrashlyticsTree -import io.fabric.sdk.android.Fabric +import eu.theappfactory.dailyrecipes.di.DaggerAppComponent +import eu.theappfactory.dailyrecipes.util.CrashlyticsTree +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi import timber.log.Timber +@InternalCoroutinesApi +@ExperimentalCoroutinesApi open class MainApplication : DaggerApplication() { override fun applicationInjector(): AndroidInjector { @@ -21,11 +22,9 @@ open class MainApplication : DaggerApplication() { // ThreeTenBP for times and dates, called before super to be available for objects AndroidThreeTen.init(this) - Fabric.with(this, Crashlytics()) - // Enable strict mode before Dagger creates graph if (BuildConfig.DEBUG) { -// enableStrictMode() + enableStrictMode() } super.onCreate() diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/cache/VideoCacheManager.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/cache/VideoCacheManager.kt new file mode 100644 index 00000000..482687cb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/cache/VideoCacheManager.kt @@ -0,0 +1,61 @@ +package eu.theappfactory.dailyrecipes.cache + +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.upstream.DataSpec +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.upstream.cache.CacheWriter +import kotlinx.coroutines.* +import java.io.IOException + +class VideoCacheManager constructor(private val cachedDataSourceFactory: CacheDataSource.Factory) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun preCacheVideos(urls: List) { + scope.coroutineContext.cancelChildren() + urls.forEachIndexed { index, url -> + scope.launch { + delay(INITIAL_DELAY_TIME * (index + 1)) + preloadVideo(CACHE_SIZE_EACH_VIDEO, url) + } + } + } + + fun cancel() { + scope.coroutineContext.cancelChildren() + } + + fun getCacheMediaSource(url: String) = createCachedMediaSource(url) + + private fun createCachedMediaSource(url: String) = + ProgressiveMediaSource.Factory(cachedDataSourceFactory) + .createMediaSource(MediaItem.fromUri(url)) + + private suspend fun preloadVideo(length: Long, url: String) = + suspendCancellableCoroutine { cancellableContinuation -> + val dataSpec = DataSpec.Builder().setUri(url) + .setLength(length).build() + val cacheWriter = CacheWriter( + cachedDataSourceFactory.createDataSource(), + dataSpec, + true, + null, + null + ) + + cancellableContinuation.invokeOnCancellation { + cacheWriter.cancel() + } + try { + cacheWriter.cache() + } catch (ignored: IOException) { + } + } + + private companion object { + + private const val CACHE_SIZE_EACH_VIDEO = 3 * 1024 * 1024L + private const val INITIAL_DELAY_TIME = 1000L + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSource.kt new file mode 100644 index 00000000..2023924e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/BootstrapItemDataSource.kt @@ -0,0 +1,43 @@ +package eu.theappfactory.dailyrecipes.data + +import android.content.Context +import eu.theappfactory.dailyrecipes.BuildConfig +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.networking.Result +import javax.inject.Inject + +/** + * Downloads and parses the items. + */ +class BootstrapItemDataSource @Inject constructor(val context: Context) : + ItemDataSource { + + override fun getRemoteItemData(): ItemData? { + throw Exception("Bootstrap data source doesn't have remote data") + } + + override fun getOfflineItemData(): ItemData? { + return loadAndParseBootstrapData() + } + + override suspend fun updateLikeCount(id: String, likeCount: Long): Result { + val itemData = loadAndParseBootstrapData() + itemData?.items?.firstOrNull { itemResponse -> itemResponse.id == id}?.let { + it.likes = likeCount + return Result.Success(Any()) + } + return Result.Error(RuntimeException("Error updating likes.")) + } + + private fun loadAndParseBootstrapData(): ItemData? { + val dataStream = this.javaClass.classLoader + ?.getResource(BuildConfig.BOOTSTRAP_ITEM_DATA_FILENAME)?.openStream() + + return dataStream?.let { ItemDataJsonParser.parseItemData(it) } + } + + override suspend fun getItem(uri: String): Result { + throw Exception("Bootstrap data source doesn't have item functionality") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataDownloader.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataDownloader.kt new file mode 100644 index 00000000..eadb7115 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataDownloader.kt @@ -0,0 +1,89 @@ +package eu.theappfactory.dailyrecipes.data + +import com.google.android.gms.tasks.Tasks +import com.google.firebase.Timestamp +import com.google.firebase.firestore.FirebaseFirestore +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import eu.theappfactory.dailyrecipes.model.* +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DATE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DIRECT_LINK +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.FOOD_TYPE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.INGREDIENTS +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IS_HIDDEN +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.LIKES +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.SHOW_THUMBNAIL +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.SOCIAL_NETWORK_HANDLE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.STORE_LINK +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TITLE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TRACK_NAME +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TRACK_URL +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.URI_STR +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.USERNAME +import eu.theappfactory.dailyrecipes.util.NetworkUtils.Companion.TIMEOUT +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit + +/** + * The data source for items stored in firestore. + */ +class ItemDataDownloader(private val firestore: FirebaseFirestore) { + + companion object { + const val ITEMS_COLLECTION = "items" + } + + @Throws(ExecutionException::class) + fun fetch(): ItemData { + val items = ArrayList() + val itemSnapshot = firestore.collection(ITEMS_COLLECTION).get() + + try { + // Add 20 s timeout in case internet is too slow. + Tasks.await(itemSnapshot, TIMEOUT, TimeUnit.SECONDS).documents.forEach { + var chats: List? = null + try { + chats = Gson().fromJson(it.data?.get(ItemResponse.CHATS) as String? ?: "", ChatsResponse::class.java)?.chats + } catch (jsonException: JsonSyntaxException) { + jsonException.printStackTrace() + } + + var ingredients: String? = null + try { + ingredients = it.data?.get(INGREDIENTS) as String? ?: "" + } catch (e: Exception) { + e.printStackTrace() + } + + val item = ItemResponse( + id = it.id, + date = it.data?.get(DATE) as Timestamp? ?: Timestamp.now(), + title = it.data?.get(TITLE) as String? ?: "", + uriStr = it.data?.get(URI_STR) as String? ?: "", + imageUri = it.data?.get(IMAGE_URI) as UniqueId? ?: "", + chats = chats, + ingredients = ingredients, + directLink = it.data?.get(DIRECT_LINK) as String? ?: "", + likes = it.data?.get(LIKES) as Long? ?: 0, + socialNetworkHandle = it.data?.get(SOCIAL_NETWORK_HANDLE) as String? ?: "", + storeLink = it.data?.get(STORE_LINK) as String? ?: "", + foodType = it.data?.get(FOOD_TYPE) as String? ?: "", + isHidden = it.data?.get(IS_HIDDEN) as String? ?: "", + trackName = it.data?.get(TRACK_NAME) as String? ?: "", + trackUrl = it.data?.get(TRACK_URL) as String? ?: "", + username = it.data?.get(USERNAME) as String? ?: "", + showThumbnail = it.data?.get(SHOW_THUMBNAIL) as Boolean? ?: false, + ) + + if (item.isHidden == "false" || item.isHidden == "") { + items.add(item) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return ItemData(items) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataJsonParser.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataJsonParser.kt new file mode 100644 index 00000000..07dccd87 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataJsonParser.kt @@ -0,0 +1,23 @@ +package eu.theappfactory.dailyrecipes.data + +import com.google.gson.GsonBuilder +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException +import com.google.gson.stream.JsonReader +import eu.theappfactory.dailyrecipes.data.json.ItemDeserializer +import eu.theappfactory.dailyrecipes.model.ItemData +import java.io.InputStream + +object ItemDataJsonParser { + + @Throws(JsonIOException::class, JsonSyntaxException::class) + fun parseItemData(unprocessedSessionData: InputStream): ItemData { + val jsonReader = JsonReader(unprocessedSessionData.reader()) + + val gson = GsonBuilder() + .registerTypeAdapter(ItemData::class.java, ItemDeserializer()) + .create() + + return gson.fromJson(jsonReader, ItemData::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataSource.kt new file mode 100644 index 00000000..a5cfb9aa --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemDataSource.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.data + +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.networking.Result + +interface ItemDataSource { + fun getRemoteItemData(): ItemData? + fun getOfflineItemData(): ItemData? + suspend fun updateLikeCount(id: String, likeCount: Long) : Result + suspend fun getItem(uri: String): Result +} + +enum class UpdateSource { + NONE, + NETWORK, + CACHE, + BOOTSTRAP +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemRepository.kt new file mode 100644 index 00000000..0c4ee4fb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ItemRepository.kt @@ -0,0 +1,235 @@ +package eu.theappfactory.dailyrecipes.data + +import android.os.Build +import eu.theappfactory.dailyrecipes.data.db.AppDatabase +import eu.theappfactory.dailyrecipes.data.db.ItemEntity +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.ItemType +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.ExperimentalCoroutinesApi +import java.io.IOException +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Single point of access to items data for the presentation layer. + */ +interface ItemRepository { + + /** + * Refresh the cache with data from a remote data source. + */ + fun retrieveRemoteItemData(): ItemData + /** + * Get offline data for when no internet or first time use. + */ + fun getOfflineItemData(): ItemData + /** + * Update like count. + */ + suspend fun updateLikeCount(id: String, likeCount: Long): Result + + suspend fun getItemForImageUri(uri: String): Result + + fun updateChatItem(item: ItemResponse) + + fun getItemForId(itemId: String): ItemResponse? +} + +@ExperimentalCoroutinesApi +@Singleton +open class DefaultItemRepository @Inject constructor( + @Named("remoteItemDatasource") private val remoteDataSource: ItemDataSource, + @Named("bootstrapItemDatasource") private val bootstrapItemDataSource: ItemDataSource, + private val appDatabase: AppDatabase +) : ItemRepository { + + // In-memory cache of the conference data + private var itemDataCache: ItemData? = null + + var latestException: Exception? = null + private set + + var latestUpdateSource: UpdateSource = UpdateSource.NONE + private set + + // Prevents multiple consumers requesting data at the same time + private val loadConfDataLock = Any() + + override fun retrieveRemoteItemData(): ItemData { + if (latestUpdateSource == UpdateSource.NETWORK && itemDataCache != null) { + return itemDataCache as ItemData + } + + val itemData = try { + remoteDataSource.getRemoteItemData() + } catch (e: IOException) { + latestException = e + throw e + } + if (itemData == null) { + val e = Exception("Remote returned no conference data") + latestException = e + throw e + } + + // Network data success! + // Update cache + synchronized(loadConfDataLock) { + itemDataCache = itemData + populateDatabaseData(itemData) + } + + // Update meta + latestException = null + latestUpdateSource = UpdateSource.NETWORK + + mergeAndSortItems(itemData) + + return itemData + } + + override fun getOfflineItemData(): ItemData { + val offlineData = itemDataCache ?: getCacheOrBootstrapDataAndPopulateSearch() + itemDataCache = offlineData + return offlineData + } + + override suspend fun updateLikeCount(id: String, likeCount: Long): Result { + val result = remoteDataSource.updateLikeCount(id, likeCount) + when(result) { + is Result.Success -> itemDataCache?.items?.firstOrNull { itemResponse -> itemResponse.id == id }?. + let { it.likes = likeCount} + else -> Unit + } + return result + } + + override fun updateChatItem(item: ItemResponse) { + itemDataCache?.items?.firstOrNull { itemResponse -> itemResponse.id == item.id }?.let { it.chats = item.chats } + } + + override fun getItemForId(itemId: String): ItemResponse? { + return itemDataCache?.items?.firstOrNull { itemResponse -> itemResponse.id == itemId } + } + + private fun getCacheOrBootstrapDataAndPopulateSearch(): ItemData { + val itemData = getCacheOrBootstrapData() + populateDatabaseData(itemData) + return itemData + } + + private fun getCacheOrBootstrapData(): ItemData { + // First, try the local cache: + var itemData = remoteDataSource.getOfflineItemData() + + // Cache success! + if (itemData != null && itemData.items.isNotEmpty()) { + latestUpdateSource = UpdateSource.CACHE + return itemData + } + + // Second, use the bootstrap file: + itemData = bootstrapItemDataSource.getOfflineItemData() + latestUpdateSource = UpdateSource.BOOTSTRAP + + return itemData ?: ItemData(mutableListOf()) + } + + private fun populateDatabaseData(itemData: ItemData) { + val itemEntities = itemData.items.map { item -> + ItemEntity( + title = item.title, + isFavorite = item.isFavorite, + videoUri = item.uriStr, + imageUri = item.imageUri, + username = item.username + ) + } + appDatabase.itemFtsDao().insertAll(itemEntities) + } + + private fun mergeAndSortItems(itemData: ItemData) { + removeAlreadySeenItems(itemData) + removeFavoritedItems(itemData) + + val uniqueItems = itemData.items.distinctBy { it.id } + + itemDataCache?.items?.apply { + clear() + addAll(uniqueItems) + sortByDescending { it.date } + } + } + + private fun removeItemsIfAlreadySeen(items: List) { + val removedItems = mutableListOf().apply { + addAll(items) + } + + for (item in items) { + if (item.alReadySeen) { + // if already Seen and isFavorite, + // then this is the favorite list. + removedItems.remove(item) + } + } + + itemDataCache?.items?.apply { + clear() + addAll(removedItems) + } + } + + private fun removeAlreadySeenItems(itemData: ItemData) { + /* + * Items that have been already seen should only be + * removed for home obviously. In favorite tab all items + * should be already seen. + */ + if (itemData.itemType == ItemType.DEFAULT) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + itemData.items.removeIf { it.alReadySeen } + } else { + removeItemsIfAlreadySeen(itemData.items) + } + } + } + + private fun removeFavoritedItems(itemData: ItemData) { + /* + * Items that have been favorite'd are only shown in the favorite tab + * and should not be shown in the home tab. + */ + if (itemData.itemType == ItemType.DEFAULT) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + itemData.items.removeIf { it.isFavorite } + } else { + removeItemsIfFavorite(itemData.items) + } + } + } + + private fun removeItemsIfFavorite(items: MutableList) { + val removedItems = ArrayList().apply { + addAll(items) + } + + for (item in items) { + if (item.isFavorite) { + // if already Seen and isFavorite, + // then this is the favorite list. + removedItems.remove(item) + } + } + + itemDataCache?.items?.apply { + clear() + addAll(removedItems) + } + } + + override suspend fun getItemForImageUri(uri: String): Result = remoteDataSource.getItem(uri) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/NetworkItemDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/NetworkItemDataSource.kt new file mode 100644 index 00000000..2c0977c8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/NetworkItemDataSource.kt @@ -0,0 +1,93 @@ +package eu.theappfactory.dailyrecipes.data + +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import eu.theappfactory.dailyrecipes.data.ItemDataDownloader.Companion.ITEMS_COLLECTION +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.LIKES +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.util.NetworkUtils +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Downloads and parses the items. + */ +class NetworkItemDataSource @Inject constructor( + private val networkUtils: NetworkUtils, + private val firestore: FirebaseFirestore +) : ItemDataSource { + + var responseSource: ItemData? = null + + override fun getRemoteItemData(): ItemData? { + if (responseSource != null && responseSource?.items?.size ?: 0 > 0) { + return responseSource + } + + Timber.d("Checking Network ") + if (networkUtils.hasNetworkConnection().not()) { + Timber.d("Network not connected") + return null + } + Timber.d("Has Network ") + + Timber.d("Trying to download data from network") + responseSource = try { + ItemDataDownloader(firestore).fetch() + } catch (e: Exception) { + Timber.e(e) + throw e + } + + return responseSource + } + + /** + * Returns the cached conference data or null if there's no cache. + */ + override fun getOfflineItemData(): ItemData { + return ItemData(mutableListOf()) + } + + override suspend fun updateLikeCount(id: String, likeCount: Long): Result = + suspendCancellableCoroutine { continuation -> + val data = mapOf(LIKES to likeCount) + firestore.collection(ITEMS_COLLECTION) + .document(id) + .set(data, SetOptions.merge()) + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success(Any()) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException("Error updating likes.") + ) + ) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + + override suspend fun getItem(uri: String): Result { + val responses = getRemoteItemData() + + for (response in responses?.items ?: emptyList()) { + if (response.imageUri == uri) { + return Result.Success(response) + } + } + + return Result.Error(Exception("Couldn't find item")) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/UserRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/UserRepository.kt new file mode 100644 index 00000000..22cf2678 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/UserRepository.kt @@ -0,0 +1,149 @@ +package eu.theappfactory.dailyrecipes.data + +import android.net.Uri +import eu.theappfactory.dailyrecipes.data.register.UploadProfilePictureDataSource +import eu.theappfactory.dailyrecipes.data.userEvent.UpdateUserStatus +import eu.theappfactory.dailyrecipes.data.userEvent.UserEventDataSource +import eu.theappfactory.dailyrecipes.domain.home.UpdatedStatus +import eu.theappfactory.dailyrecipes.model.AlreadySeenItem +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import javax.inject.Inject +import javax.inject.Singleton + +interface UserRepository { + + suspend fun addToFavoritesEvent(userId: String, item: FavoriteItem): Result + fun getFavoriteItems(userId: String): List + fun removeFavoriteItem(userId: String, item: FavoriteItem) + + fun addAlreadySeenItem(userId: String, item: AlreadySeenItem) + fun getAlreadySeenItems(parameters: Pair>): ArrayList + fun eraseAlreadySeen(userId: String): Result + + suspend fun getUserFoodCategories(userId: String): Result> + suspend fun updateUserFoodCategories(userId: String, foodCategories: List) : Result + + suspend fun updateUser(user: UserResponse): Result + suspend fun getUser(userId: String): Result +} + +/** + * Single point of access to user events data associated with a user for the presentation layer. + */ +@Singleton +open class DefaultUserRepository @Inject constructor( + private val userEventDataSource: UserEventDataSource, + private val uploadProfilePictureDataSource: UploadProfilePictureDataSource +) : UserRepository { + + var alreadySeenItems = arrayListOf() + + override suspend fun addToFavoritesEvent( + userId: String, + item: FavoriteItem + ): Result { + return userEventDataSource.addFavoriteItem(userId, item) + } + + override fun getFavoriteItems( + userId: String + ): List = userEventDataSource.getFavoriteItems(userId) + + override fun removeFavoriteItem(userId: String, item: FavoriteItem) { + userEventDataSource.removeFavoriteItem(userId, item) + } + + override fun addAlreadySeenItem( + userId: String, + item: AlreadySeenItem + ) { + if (hasAlreadyBeenAdded(item).not()) { + userEventDataSource.addAlreadySeenItem(userId, item) + } + } + + private fun hasAlreadyBeenAdded(alreadySeenItem: AlreadySeenItem): Boolean { + for (item in alreadySeenItems) { + if (item.id == alreadySeenItem.id) { + return true + } + } + + return false + } + + override fun getAlreadySeenItems( + parameters: Pair> + ): ArrayList { + val result = userEventDataSource.getAlreadySeenItems(parameters.first) + return parseAlreadySeen(result, parameters.second) + } + + override fun eraseAlreadySeen(userId: String): Result { + return userEventDataSource.removeAllAlreadySeen(userId) + } + + private fun parseAlreadySeen( + list: List, + items: List + ): ArrayList { + /** + * In case the user has already seen the item, + * update the item so that it doesn't shet shown again. + */ + val newItems = ArrayList().apply { + addAll(items) + } + + for (result in list) { + for (item in items) { + if (item.imageUri == result.imageUri) { + if (result.alreadySeen) { + newItems.remove(item) + } + } + } + } + + return newItems + } + + override suspend fun getUserFoodCategories(userId: String): Result> { + return userEventDataSource.getUserFoodCategories(userId) + } + + override suspend fun updateUserFoodCategories(userId: String, foodCategories: List): Result { + return userEventDataSource.updateUserFoodCategories(userId, foodCategories) + } + + override suspend fun updateUser(user: UserResponse): Result { + if (!user.photoUri.isNullOrBlank()) { + // store photo on firestore + val result = uploadProfilePictureDataSource.uploadProfilePicture(Uri.parse(user.photoUri)) + + when (result) { + is Result.Success -> { + user.photoUri = result.data + + // update displayName and emailSubscribe preference in our own user table + return userEventDataSource.updateUser(user) + } + is Result.Error -> { + Result.Error(Exception("uploading photo went wrong")) + } + } + } else { + // update displayName and emailSubscribe preference in our own user table + return userEventDataSource.updateUser(user) + } + + return Result.Error(Exception("something went wrong.")) + } + + override suspend fun getUser(userId: String): Result { + return userEventDataSource.getUserForId(userId) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/FirestoreUpdateItemChatsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/FirestoreUpdateItemChatsDataSource.kt new file mode 100644 index 00000000..e075212a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/FirestoreUpdateItemChatsDataSource.kt @@ -0,0 +1,53 @@ +package eu.theappfactory.dailyrecipes.data.chats + +import com.google.firebase.firestore.FirebaseFirestore +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.data.ItemDataDownloader.Companion.ITEMS_COLLECTION +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FirestoreUpdateItemChatsDataSource(private val firestore: FirebaseFirestore) : UpdateItemChatsDataSource { + + override suspend fun update(id: String, instructionsUpdateChatsData: ChatItem) = + suspendCancellableCoroutine> { continuation -> + firestore.collection(ITEMS_COLLECTION) + .document(id) + .update( + CHATS_COLLECTION, + Gson().toJson( + (Chats(listOf(instructionsUpdateChatsData))) + ) + ) + .addOnCompleteListener { + if (continuation.isActive.not()) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success( + Unit + ) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: Exception("Error updating item.") + ) + ) + } + }.addOnFailureListener { + if (continuation.isActive.not()) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + + data class Chats( + val chats: List + ) + + private companion object { + + const val CHATS_COLLECTION = "chats" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/UpdateItemChatsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/UpdateItemChatsDataSource.kt new file mode 100644 index 00000000..ab3099a8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/chats/UpdateItemChatsDataSource.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.data.chats + +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.networking.Result + +interface UpdateItemChatsDataSource { + suspend fun update(id: String, instructionsUpdateChatsData: ChatItem): Result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/AppDatabase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/AppDatabase.kt new file mode 100644 index 00000000..fe55941d --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/AppDatabase.kt @@ -0,0 +1,28 @@ +package eu.theappfactory.dailyrecipes.data.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * The [Room] database for this app. + */ +@Database(entities = [ + ItemEntity::class +], + version = 2, + exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun itemFtsDao(): ItemDao + + companion object { + private const val databaseName = "daily-recipes-db" + + fun buildDatabase(context: Context): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, databaseName) + .fallbackToDestructiveMigration() + .build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemDao.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemDao.kt new file mode 100644 index 00000000..3b1b603e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemDao.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** + * The Data Access Object for the [ItemEntity] class. + */ +@Dao +interface ItemDao { + + /** + * Insert a list of items. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(items: List) + + /** + * Get all the inserted items back. + */ + @Query("SELECT * FROM items") + fun getAll(): List +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemEntity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemEntity.kt new file mode 100644 index 00000000..bbbf4d38 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/db/ItemEntity.kt @@ -0,0 +1,47 @@ +package eu.theappfactory.dailyrecipes.data.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * This class represents [Items] data. + * + * The [ColumnInfo] name is explicitly declared to allow flexibility for renaming the data class + * properties without requiring changing the column name. + */ +@Entity(tableName = "items") +data class ItemEntity( + + /** + * The URL link to the video. This URL is unique and thus also the primary key. + */ + @PrimaryKey + @ColumnInfo(name = "videoUri") + val videoUri: String, + + /** + * title. + */ + @ColumnInfo(name = "title") + val title: String, + + /** + * User favorited this item. + */ + @ColumnInfo(name = "isFavorite") + val isFavorite: Boolean, + + /** + * The jpg of the first frame shown during loading. + */ + @ColumnInfo(name = "imageUri") + val imageUri: String, + + /** + * The jpg of the first frame shown during loading. + */ + @ColumnInfo(name = "username") + val username: String + +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/AHDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/AHDataSource.kt new file mode 100644 index 00000000..5434a667 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/AHDataSource.kt @@ -0,0 +1,86 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.model.ah.AhIngredientRequest +import eu.theappfactory.dailyrecipes.model.ah.AhItem +import eu.theappfactory.dailyrecipes.networking.AHApi +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.ingredients.Supermarket +import timber.log.Timber + +class AHDataSource(private val AHApi: AHApi, + private val preferenceStorage: PreferenceStorage) : SupermarketIngredientsDataSource { + + override suspend fun login(): Result { + try { + val response = AHApi.login(preferenceStorage.lastKnownCookie!!).execute() + + if (response.isSuccessful) { + return Result.Success(response.body()!!) + } else if (response.code() == 302) { + Timber.d("User needs to login.") + return Result.Error(Exception("User needs to login.")) + } + } catch (e: Exception) { + e.printStackTrace() + return Result.Error(e) + } + return Result.Error(Exception()) + } + + override suspend fun insertIngredients(ingredients: List): Result { + try { + val items = ArrayList() + + for (ingredient in ingredients) { + val id = getIdForURL(ingredient) + items.add(AhItem(id, QUANTITY)) + } + val ingredients = AhIngredientRequest(items) + val response = AHApi.insertIngredient(preferenceStorage.lastKnownCookie!!, ingredients).execute() + + if (response.isSuccessful) { + Timber.d("Successfully inserted into basket.") + return Result.Success(response.body()!!) + } + } catch (e: Exception) { + return Result.Error(e) + } + return Result.Error(Exception()) + } + + override suspend fun getIngredient(url: String): Result { + try { + val response = AHApi.getProduct( + preferenceStorage.lastKnownCookie!!, + getIdForURL(url) + ).execute() + + if (response.isSuccessful) { + return Result.Success(response.body()?.card?.products?.first()?.let { + IngredientItemData( + it.title, it.images.firstOrNull()?.url.orEmpty(), + Supermarket.AH.supermarketName, it.link + ) + }) + } + } catch (e: Exception) { + return Result.Error(e) + } + return Result.Error(Exception()) + } + + private fun getIdForURL(url: String) = url.substringAfter(AH_ID_PREFIX, "") + .substringBefore("/").toLong() + + companion object { + + const val QUANTITY = 1L + const val AH_ID_PREFIX = "wi" + const val AH_LOGIN_URL = "https://www.ah.nl/mijn/inloggen" + const val AH_DASHBOARD = "https://www.ah.nl/mijn/dashboard" + const val AH_REDIRECT = "https://www.ah.nl/mijn/api/bol/redirect" + const val AH_API_DASHBOARD = "https://www.ah.nl/mijn/api/dashboard" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsDataSource.kt new file mode 100644 index 00000000..3836a7d9 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsDataSource.kt @@ -0,0 +1,36 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.FirebaseFirestore +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.model.toIngredientDataItem +import eu.theappfactory.dailyrecipes.model.toMap +import eu.theappfactory.dailyrecipes.util.NetworkUtils +import timber.log.Timber +import java.util.concurrent.TimeUnit + +class FirestoreIngredientsDataSource(private val firestore: FirebaseFirestore) : + IngredientsDataSource { + + override fun getIngredients(): List { + val task = firestore + .collection(INGREDIENTS_COLLECTION).get() + return Tasks.await(task, NetworkUtils.TIMEOUT, TimeUnit.SECONDS) + .map { it.toIngredientDataItem() } + } + + override fun addIngredients(item: IngredientItemData) { + firestore.collection(INGREDIENTS_COLLECTION) + .add(item.toMap()) + .addOnCompleteListener { + if (it.isSuccessful.not()) { + Timber.d("Did not added ingredient item.") + } + }.addOnFailureListener { it.printStackTrace() } + } + + private companion object { + + const val INGREDIENTS_COLLECTION = "ingredients" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsImageDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsImageDataSource.kt new file mode 100644 index 00000000..35bd7273 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreIngredientsImageDataSource.kt @@ -0,0 +1,50 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import com.google.firebase.storage.FirebaseStorage +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.InputStream +import java.net.URL +import kotlin.coroutines.resume + +class FirestoreIngredientsImageDataSource(private val firebaseStorage: FirebaseStorage) : + IngredientsImageDataSource { + + override suspend fun uploadIngredientImage(url: String) = + suspendCancellableCoroutine> { continuation -> + val reference = + firebaseStorage.getReference(STORAGE_PATH).child(url.substringAfterLast(DELIMITER)) + val uploadTask = reference.putStream(URL(url).content as InputStream) + uploadTask.continueWithTask { task -> + if (!task.isSuccessful) { + task.exception?.let { + throw it + } + } + reference.downloadUrl + }.addOnCompleteListener { task -> + if (!continuation.isActive) return@addOnCompleteListener + if (task.isSuccessful) { + continuation.resume( + Result.Success(task.result.toString()) + ) + } else { + continuation.resume( + Result.Error( + task.exception ?: RuntimeException("Error uploading image.") + ) + ) + } + } + + continuation.invokeOnCancellation { + uploadTask.cancel() + } + } + + companion object { + + const val STORAGE_PATH = "2021-ingredients" + const val DELIMITER = "/" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreUpdateItemIngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreUpdateItemIngredientsDataSource.kt new file mode 100644 index 00000000..eb24bcba --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/FirestoreUpdateItemIngredientsDataSource.kt @@ -0,0 +1,55 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import com.google.firebase.firestore.FirebaseFirestore +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.data.ItemDataDownloader.Companion.ITEMS_COLLECTION +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FirestoreUpdateItemIngredientsDataSource(private val firestore: FirebaseFirestore) : + UpdateItemIngredientsDataSource { + + override suspend fun update(id: String, response: IngredientsResponse) = + suspendCancellableCoroutine> { continuation -> + firestore.collection(ITEMS_COLLECTION) + .document(id) + .update( + INGREDIENTS_COLLECTION, + Gson().toJson( + IngredientsUpdateData( + response.supermarket.orEmpty(), + response.ingredients.orEmpty() + ) + ) + ) + .addOnCompleteListener { + if (continuation.isActive.not()) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success( + Unit + ) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: Exception("Error updating item.") + ) + ) + } + }.addOnFailureListener { + if (continuation.isActive.not()) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + + private companion object { + + const val INGREDIENTS_COLLECTION = "ingredients" + } +} + +data class IngredientsUpdateData(val supermarket: String, val ingredients: List) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsDataSource.kt new file mode 100644 index 00000000..228c88bb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsDataSource.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData + +interface IngredientsDataSource { + + fun getIngredients(): List + + fun addIngredients(item: IngredientItemData) +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageDataStore.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageDataStore.kt new file mode 100644 index 00000000..6c8a2445 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageDataStore.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.networking.Result + +interface IngredientsImageDataSource { + + suspend fun uploadIngredientImage(url: String): Result +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageRepository.kt new file mode 100644 index 00000000..e4bcac0c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsImageRepository.kt @@ -0,0 +1,15 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.networking.Result + +interface IngredientsImageRepository { + + suspend fun uploadImage(url: String): Result +} + +class DefaultIngredientsImageRepository(private val ingredientsImageDataSource: IngredientsImageDataSource) : + IngredientsImageRepository { + + override suspend fun uploadImage(url: String) = + ingredientsImageDataSource.uploadIngredientImage(url) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsRepository.kt new file mode 100644 index 00000000..0cb17cf2 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/IngredientsRepository.kt @@ -0,0 +1,21 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import javax.inject.Inject + +interface IngredientsRepository { + + fun addIngredient(item: IngredientItemData) + + fun getAllIngredients(): List +} + +class DefaultIngredientsRepository @Inject constructor( + private val ingredientsDataSource: IngredientsDataSource, +) : IngredientsRepository { + + override fun addIngredient(item: IngredientItemData) = + ingredientsDataSource.addIngredients(item) + + override fun getAllIngredients() = ingredientsDataSource.getIngredients() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/JumboDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/JumboDataSource.kt new file mode 100644 index 00000000..44ac1b00 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/JumboDataSource.kt @@ -0,0 +1,33 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.networking.JumboApi +import eu.theappfactory.dailyrecipes.networking.Result + +class JumboDataSource(private val jumboApi: JumboApi) : SupermarketIngredientsDataSource { + + override suspend fun login(): Result { + TODO("Not yet implemented") + } + + override suspend fun insertIngredients(ingredients: List): Result { + TODO("Not yet implemented") +// try { +// val ingredient = JumboIngredientRequest("247803STK", 1, "pieces") +// val response = jumboApi.insertIngredient(WebViewHandler.lastKnownCookie, ingredient).execute() +// +// if (response.isSuccessful) { +// Timber.d("Successfully inserted into basket.") +// return Result.Success(response.body()!!) +// } +// } catch (e: Exception) { +// e.printStackTrace() +// return Result.Error(Exception()) +// } +// return Result.Error(Exception()) + } + + override suspend fun getIngredient(url: String): Result { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/LocalIngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/LocalIngredientsDataSource.kt new file mode 100644 index 00000000..084d5dcf --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/LocalIngredientsDataSource.kt @@ -0,0 +1,40 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Source +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.util.NetworkUtils +import java.util.concurrent.TimeUnit + +class LocalIngredientsDataSource(private val firestore: FirebaseFirestore) : + IngredientsDataSource { + + override fun getIngredients(): List { + val task = firestore + .collection(INGREDIENTS_COLLECTION).get(Source.CACHE) + return Tasks.await( + task, + NetworkUtils.TIMEOUT, + TimeUnit.SECONDS + ).documents.map { parseIngredientItems(it) } + } + + override fun addIngredients(item: IngredientItemData) {} + + private fun parseIngredientItems(snapshot: DocumentSnapshot) = + IngredientItemData( + snapshot[KEY_TITLE] as String, snapshot[KEY_IMAGE] as String, + snapshot[KEY_SUPERMARKET] as String, snapshot[KEY_URL] as String + ) + + private companion object { + + const val INGREDIENTS_COLLECTION = "ingredients" + const val KEY_IMAGE = "imageUri" + const val KEY_TITLE = "title" + const val KEY_SUPERMARKET = "supermarket" + const val KEY_URL = "url" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SearchIngredientsRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SearchIngredientsRepository.kt new file mode 100644 index 00000000..8e4d95fd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SearchIngredientsRepository.kt @@ -0,0 +1,34 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import javax.inject.Inject +import javax.inject.Named + +interface SearchIngredientsRepository { + + fun searchIngredientsByName(name: String): List + + fun searchIngredientsByUrl(url: String): IngredientItemData? +} + +class DefaultSearchIngredientsRepository @Inject constructor( + @Named("remoteIngredientsDataSource") private val remoteIngredientsDataSource: IngredientsDataSource, + @Named("localIngredientsDataSource") private val localIngredientsDataSource: IngredientsDataSource +) : SearchIngredientsRepository { + + private var lastFetchedFromNetwork = false + + override fun searchIngredientsByName(name: String): List { + return getDataSource().filter { name.isNotBlank() && it.name.contains(name, true) } + } + + override fun searchIngredientsByUrl(url: String) = + remoteIngredientsDataSource.getIngredients().firstOrNull { it.url == url } + + private fun getDataSource() = + if (lastFetchedFromNetwork) { + localIngredientsDataSource.getIngredients() + } else { + remoteIngredientsDataSource.getIngredients().also { lastFetchedFromNetwork = true } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsDataSource.kt new file mode 100644 index 00000000..723b7322 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsDataSource.kt @@ -0,0 +1,14 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.networking.Result + +/** + * Parses a list of ingredients and inserts it into a supermarket's API. + */ +interface SupermarketIngredientsDataSource { + + suspend fun login(): Result + suspend fun insertIngredients(ingredients: List): Result + suspend fun getIngredient(url: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsRepository.kt new file mode 100644 index 00000000..49920e92 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/SupermarketIngredientsRepository.kt @@ -0,0 +1,39 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.networking.Result +import javax.inject.Inject + +/** + * Single point of access to all ingredient related operations. Insert, delete, read etc. + * + */ +interface SupermarketIngredientsRepository { + + suspend fun insertIngredientInAHApi(ingredients: List): Result + suspend fun insertIngredientInJumboApi(ingredients: List) + suspend fun login(): Result + suspend fun getIngredientFromAHApi(url: String): Result +} + +class DefaultSupermarketIngredientsRepository @Inject constructor( + private val AHDataSource: AHDataSource, + private val jumboDataSource: JumboDataSource +) : SupermarketIngredientsRepository { + + override suspend fun insertIngredientInAHApi(ingredients: List): Result { + return AHDataSource.insertIngredients(ingredients) + } + + override suspend fun insertIngredientInJumboApi(ingredients: List) { + jumboDataSource.insertIngredients(ingredients) + } + + override suspend fun login(): Result { + return AHDataSource.login() + } + + override suspend fun getIngredientFromAHApi(url: String): Result { + return AHDataSource.getIngredient(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemIngredientsDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemIngredientsDataSource.kt new file mode 100644 index 00000000..6d7794b9 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemIngredientsDataSource.kt @@ -0,0 +1,9 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse +import eu.theappfactory.dailyrecipes.networking.Result + +interface UpdateItemIngredientsDataSource { + + suspend fun update(id: String, response: IngredientsResponse): Result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemRepository.kt new file mode 100644 index 00000000..38a9c65e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/ingredients/UpdateItemRepository.kt @@ -0,0 +1,53 @@ +package eu.theappfactory.dailyrecipes.data.ingredients + +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.data.ItemDataSource +import eu.theappfactory.dailyrecipes.data.chats.UpdateItemChatsDataSource +import eu.theappfactory.dailyrecipes.domain.ingredients.IngredientsUpdateData +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse +import eu.theappfactory.dailyrecipes.networking.Result + +interface UpdateItemRepository { + + suspend fun updateIngredients(id: String, ingredientsUpdateData: IngredientsUpdateData): Result + suspend fun updateChats(id: String, instructionsUpdateChatsData: ChatItem): Result +} + +class DefaultUpdateItemRepository( + private val updateItemIngredientsDataSource: UpdateItemIngredientsDataSource, + private val updateItemChatsDataSource: UpdateItemChatsDataSource, + private val itemDataSource: ItemDataSource +) : UpdateItemRepository { + + override suspend fun updateIngredients( + id: String, + ingredientsUpdateData: IngredientsUpdateData + ): Result { + val item = itemDataSource.getRemoteItemData()?.items?.firstOrNull { it.id == id } + item?.let { + val response = Gson().fromJson( + it.ingredients, + IngredientsResponse::class.java + ) + val newResponse = response?.let { + it.copy(ingredients = ingredientsUpdateData.ingredients) + } ?: run { + IngredientsResponse( + ingredientsUpdateData.supermarket, + ingredients = ingredientsUpdateData.ingredients, + directLink = null + ) + } + return updateItemIngredientsDataSource.update(id, newResponse) + } + return Result.Error(IllegalStateException("not found")) + } + + override suspend fun updateChats( + id: String, + instructionsUpdateChatsData: ChatItem + ): Result { + return updateItemChatsDataSource.update(id, instructionsUpdateChatsData) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookDataSource.kt new file mode 100644 index 00000000..c65a7dac --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookDataSource.kt @@ -0,0 +1,144 @@ +package eu.theappfactory.dailyrecipes.data.invite + +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import com.google.firebase.firestore.FirebaseFirestore +import eu.theappfactory.dailyrecipes.data.userEvent.UpdateUserStatus +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.model.Contact.Companion.DISPLAY_NAME +import eu.theappfactory.dailyrecipes.model.Contact.Companion.PHONE_NUMBER +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +interface AddressBookDataSource { + fun getContacts(): ArrayList + suspend fun uploadContacts(contactsList: Pair>): Result +} + +@ExperimentalCoroutinesApi +class DefaultAddressBookDataSource @Inject constructor( + val context: Context, + val firestore: FirebaseFirestore +) : AddressBookDataSource { + + override fun getContacts(): ArrayList { + var contactId = "" + var displayName = "" + val contacts = arrayListOf() + + val cursor: Cursor? = context.contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + null, + null, + null, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" + ) + + if (cursor != null && cursor.count > 0) { + while (cursor.moveToNext()) { + val hasPhoneNumber = + cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + .toInt() + + if (hasPhoneNumber > 0) { + contactId = + cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts._ID)) + displayName = + cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)) + + val phoneCursor: Cursor = context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + null, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?", + arrayOf(contactId), + null + )!! + + if (phoneCursor.moveToNext()) { + val phoneNumber = phoneCursor.getString( + phoneCursor.getColumnIndex( + ContactsContract.CommonDataKinds.Phone.NUMBER + ) + ) + contacts.add(Contact(displayName, phoneNumber)) + } + phoneCursor.close() + } + } + } + cursor?.close() + + return contacts + } + + override suspend fun uploadContacts(contactsList: Pair>) = + suspendCancellableCoroutine> { continuation -> + firestore.collection(USERS_COLLECTION) + .document(contactsList.first) + .collection(USERS_CONTACTS_COLLECTION) + .get() + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + val contacts = arrayListOf() + it.result?.documents?.forEach { documentSnapshot -> + contacts.add( + Contact( + phoneNumber = documentSnapshot[PHONE_NUMBER] as String, + displayName = documentSnapshot[DISPLAY_NAME] as String + ) + ) + } + val addList = arrayListOf() + contactsList.second.forEach { list -> + if (!contacts.any { it.phoneNumber == list.phoneNumber }) { + addList.add(list) + } + } + if (addList.isNotEmpty()) { + addList.forEachIndexed { index, contact -> + val addContact = hashMapOf( + DISPLAY_NAME to contact.displayName, + PHONE_NUMBER to contact.phoneNumber + ) + firestore.collection(USERS_COLLECTION) + .document(contactsList.first) + .collection(USERS_CONTACTS_COLLECTION) + .add(addContact) + .addOnSuccessListener { + if (index == addList.size - 1) { + continuation.resume( + Result.Success(UpdateUserStatus.UPDATE_OK) + ) + } + } + .addOnFailureListener { + continuation.resume( + Result.Error(it) + ) + } + } + } else { + continuation.resume( + Result.Success(UpdateUserStatus.UPDATE_OK) + ) + } + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException("Error fetching contacts") + ) + ) + } + } + } + + companion object { + private const val USERS_COLLECTION = "users" + private const val USERS_CONTACTS_COLLECTION = "contacts" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookRepository.kt new file mode 100644 index 00000000..c1b8658c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/AddressBookRepository.kt @@ -0,0 +1,65 @@ +package eu.theappfactory.dailyrecipes.data.invite + +import android.os.Build +import eu.theappfactory.dailyrecipes.data.userEvent.UpdateUserStatus +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.networking.Result + +interface AddressBookRepository { + + fun getContacts(): ArrayList + suspend fun uploadContacts(contactsList: Pair>): Result +} + +class DefaultAddressBookRepository(val addressBookDataSource: AddressBookDataSource): AddressBookRepository { + + var toggleSwitch: Boolean = true + + override fun getContacts(): ArrayList { + val result = addressBookDataSource.getContacts() + return filterOnlyDutchCellphoneNumbers(result) as ArrayList + } + + override suspend fun uploadContacts(contactsList: Pair>): Result { + return addressBookDataSource.uploadContacts(contactsList) + } + + private fun filterOnlyDutchCellphoneNumbers(contacts: ArrayList): List { + // remove duplicates + contacts.distinctBy { it.phoneNumber } + // remove weird characters and whitespace + contacts.map { it.phoneNumber = it.phoneNumber + .replace("\\s".toRegex(), "") + .replace("(", "") + .replace(")", "") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Remove when the display name is also a number + contacts.removeIf { isLetters(it.displayName).not() } + } + + val result = contacts.filter { + // Dutch cell phone numbers start with 06 and are 10 numbers. e.g. 06 1234 5678 + (it.phoneNumber.startsWith("06") && it.phoneNumber.length == 10) || + // When calling international, they should start with country code: +31. e.g. +316 1234 5678 + (it.phoneNumber.startsWith("+316") && it.phoneNumber.length == 12) + } + + for (contact in result) { + if (toggleSwitch) { + contact.isEven = true + toggleSwitch = false + } else { + contact.isEven = false + toggleSwitch = true + } + } + + return result + } + + private fun isLetters(string: String): Boolean { + return string.all { it.isLetter() } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/FirestoreInviteCodeDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/FirestoreInviteCodeDataSource.kt new file mode 100644 index 00000000..176cdcdd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/FirestoreInviteCodeDataSource.kt @@ -0,0 +1,100 @@ +package eu.theappfactory.dailyrecipes.data.invite + +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import eu.theappfactory.dailyrecipes.model.InviteCode +import eu.theappfactory.dailyrecipes.model.InviteCode.Companion.CODE +import eu.theappfactory.dailyrecipes.model.InviteCode.Companion.HAS_BEEN_USED +import eu.theappfactory.dailyrecipes.model.InviteCode.Companion.USER +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.util.NetworkUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FirestoreInviteCodeDataSource @Inject constructor( + val firestore: FirebaseFirestore +) : InviteCodeDataSource { + + override suspend fun useCode(userCode: Long): Result { + val inviteCodes = ArrayList() + val itemSnapshot = firestore.collection(INVITE_CODES).get() + + try { + // Add 20 s timeout in case internet is too slow. + Tasks.await(itemSnapshot, NetworkUtils.TIMEOUT, TimeUnit.SECONDS).documents.forEach { + inviteCodes.add(InviteCode( + id = it.id, + code = it.data?.get(CODE) as Long? ?: 0, + hasBeenUsed = it.data?.get(HAS_BEEN_USED) as Boolean? ?: false, + user = it.data?.get(USER) as String? ?: "" + )) + } + } catch (e: Exception) { + e.printStackTrace() + } + + for (inviteCode in inviteCodes) { + if (inviteCode.code == userCode && inviteCode.hasBeenUsed.not()) { + inviteCode.hasBeenUsed = true + val result = setHasBeenUsed(inviteCode) + + when (result) { + is Result.Success<*> -> { return Result.Success("Code is ok.") } + is Result.Error -> { return Result.Error(result.exception) } + else -> { return Result.Error(Exception("Something went wrong, try again.")) } + } + } + else if (inviteCode.code == userCode && inviteCode.hasBeenUsed) { + return Result.Error(Exception("Code has already been used.")) + } + } + + return Result.Error(Exception("Code has not been found.")) + } + + suspend fun setHasBeenUsed(inviteCode: InviteCode) + : Result = withContext(Dispatchers.Main) { + // The suspendCancellableCoroutine method suspends a coroutine manually. With the + // continuation object you receive in the lambda, you can resume the coroutine + // after the work is done. + suspendCancellableCoroutine { continuation -> + + val data = mapOf( + CODE to inviteCode.code, + HAS_BEEN_USED to inviteCode.hasBeenUsed, + USER to inviteCode.id + ) + + firestore.collection(INVITE_CODES) + .document(inviteCode.id) + .set(data, SetOptions.merge()) + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success(Any()) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException("Error updating the invite code.") + ) + ) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + } + + companion object { + const val INVITE_CODES = "inviteCodes" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeDataSource.kt new file mode 100644 index 00000000..25b1b2c0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeDataSource.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.data.invite + +import eu.theappfactory.dailyrecipes.networking.Result + +interface InviteCodeDataSource { + + suspend fun useCode(userCode: Long): Result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeRepository.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeRepository.kt new file mode 100644 index 00000000..87167a92 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/invite/InviteCodeRepository.kt @@ -0,0 +1,16 @@ +package eu.theappfactory.dailyrecipes.data.invite + +import eu.theappfactory.dailyrecipes.networking.Result + +interface InviteCodeRepository { + + suspend fun checkInvitationCode(code: Long): Result +} + +class DefaultInviteCodeRepository(private val inviteCodeDataSource: FirestoreInviteCodeDataSource) : + InviteCodeRepository { + + override suspend fun checkInvitationCode(code: Long): Result { + return inviteCodeDataSource.useCode(code) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/json/ItemDeserializer.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/json/ItemDeserializer.kt new file mode 100644 index 00000000..a24d5ddb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/json/ItemDeserializer.kt @@ -0,0 +1,54 @@ +package eu.theappfactory.dailyrecipes.data.json + +import com.google.firebase.Timestamp +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.FOOD_TYPE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IS_FAVORITE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.SOCIAL_NETWORK_HANDLE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TITLE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.URI_STR +import java.lang.reflect.Type + +class ItemDeserializer : JsonDeserializer { + + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): ItemData { + val obj = json?.asJsonObject!! + val items = ArrayList() + + for (entry in obj.entrySet()) { + val jsonArray = entry.value.asJsonArray + for (jsonElement in jsonArray) { + val item = ItemResponse( + date = Timestamp(jsonElement.asJsonObject.get("dateEpoch").asLong, 0), + id = jsonElement.asJsonObject.get("id").asString, + title = jsonElement.asJsonObject.get(TITLE).asString, + uriStr = jsonElement.asJsonObject.get(URI_STR).asString, + imageUri = jsonElement.asJsonObject.get(IMAGE_URI).asString, + socialNetworkHandle = jsonElement.asJsonObject.get(SOCIAL_NETWORK_HANDLE).asString, + isFavorite = getFavorite(jsonElement.asJsonObject), + foodType = jsonElement.asJsonObject.get(FOOD_TYPE).asString + ) + items.add(item) + } + } + return ItemData(items) + } + + private fun getFavorite(jsonObject: JsonObject): Boolean { + return if (jsonObject.get(IS_FAVORITE) == null) { + false + } else { + jsonObject.get(IS_FAVORITE).asBoolean + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/register/FirestoreUploadProfilePictureDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/register/FirestoreUploadProfilePictureDataSource.kt new file mode 100644 index 00000000..a99f074c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/register/FirestoreUploadProfilePictureDataSource.kt @@ -0,0 +1,63 @@ +package eu.theappfactory.dailyrecipes.data.register + +import android.content.Context +import android.net.Uri +import com.google.firebase.storage.FirebaseStorage +import eu.theappfactory.dailyrecipes.data.ingredients.FirestoreIngredientsImageDataSource +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +interface UploadProfilePictureDataSource { + suspend fun uploadProfilePicture(photo: Uri): Result +} + +class FirestoreUploadProfilePictureDataSource( + private val firebaseStorage: FirebaseStorage, + private val context: Context +) : UploadProfilePictureDataSource { + + override suspend fun uploadProfilePicture(photo: Uri) = + suspendCancellableCoroutine> { continuation -> + + val url = photo.toString() + val reference = + firebaseStorage.getReference(STORAGE_PATH_PROFILE_PICS).child( + url.substringAfterLast( + FirestoreIngredientsImageDataSource.DELIMITER + ) + ) + + val inputStream = context.contentResolver?.openInputStream(photo) + val uploadTask = inputStream?.let { reference.putStream(it) } + uploadTask?.continueWithTask { task -> + if (!task.isSuccessful) { + task.exception?.let { + throw it + } + } + reference.downloadUrl + }?.addOnCompleteListener { task -> + if (!continuation.isActive) return@addOnCompleteListener + if (task.isSuccessful) { + continuation.resume( + Result.Success(task.result.toString()) + ) + } else { + continuation.resume( + Result.Error( + task.exception ?: RuntimeException("Error uploading image.") + ) + ) + } + } + + continuation.invokeOnCancellation { + uploadTask?.cancel() + } + } + + companion object { + const val STORAGE_PATH_PROFILE_PICS = "profilePictures" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthStateUserDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthStateUserDataSource.kt new file mode 100644 index 00000000..8e6bcd89 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthStateUserDataSource.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.data.signin + +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.flow.Flow + +/** + * Listens to an Authentication state data source that emits updates on the current user. + * + * @see FirebaseAuthStateUserDataSource + */ +interface AuthStateUserDataSource { + + /** + * Returns a flow of [AuthenticatedUserInfo]. + */ + fun getBasicUserInfo(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthenticatedUserInfo.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthenticatedUserInfo.kt new file mode 100644 index 00000000..06c06e61 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/AuthenticatedUserInfo.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.data.signin + +/** + * Basic user info. + */ +interface AuthenticatedUserInfo { + + /** + * Is user signed in. + */ + fun isSignedIn(): Boolean + + /** + * Get the user's unique id. + */ + fun getUid(): String? +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseAuthStateUserDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseAuthStateUserDataSource.kt new file mode 100644 index 00000000..8bbf5570 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseAuthStateUserDataSource.kt @@ -0,0 +1,70 @@ +package eu.theappfactory.dailyrecipes.data.signin + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.Result.Success +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import timber.log.Timber +import javax.inject.Inject + +/** + * An [AuthStateUserDataSource] that listens to changes in [FirebaseAuth]. + * + * When a [FirebaseUser] is available, it + * * Posts it to the user observable + * * Fetches the ID token + * * Stores the FCM ID Token in Firestore + * * Posts the user ID to the observable + */ +@ExperimentalCoroutinesApi +class FirebaseAuthStateUserDataSource @Inject constructor( + val firebase: FirebaseAuth +) : AuthStateUserDataSource { + + private var isListening = false + private var lastUid: String? = null + + // Channel that keeps track of User Authentication + private val channel = ConflatedBroadcastChannel>() + + // Listener that saves the [FirebaseUser], fetches the ID token + // and updates the user ID observable. + val listener: ((FirebaseAuth) -> Unit) = { auth -> + Timber.d("Received a FirebaseAuth update") + + auth.currentUser?.let { currentUser -> + Timber.d("Received a FirebaseAuth update") + } + + // Save the last UID to prevent setting too many alarms. + lastUid = auth.uid + + // Send the current user for observers + if (!channel.isClosedForSend) { + channel.offer(Success(FirebaseUserInfo(auth.currentUser))) + } else { + unregisterListener() + } + } + + // Synchronized method, multiple calls to this method at the same time isn't allowed since + // isListening is read and can be modified + @FlowPreview + @Synchronized + override fun getBasicUserInfo(): Flow> { + if (!isListening) { + firebase.addAuthStateListener(listener) + isListening = true + } + return channel.asFlow() + } + + private fun unregisterListener() { + firebase.removeAuthStateListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseUserInfo.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseUserInfo.kt new file mode 100644 index 00000000..9228569a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/signin/FirebaseUserInfo.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.data.signin + +import com.google.firebase.auth.FirebaseUser + +open class FirebaseUserInfo(private val firebaseUser: FirebaseUser?) : AuthenticatedUserInfo { + + override fun isSignedIn(): Boolean = firebaseUser != null + + override fun getUid(): String? = firebaseUser?.uid +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreAlreadySeenItemParser.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreAlreadySeenItemParser.kt new file mode 100644 index 00000000..3289740c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreAlreadySeenItemParser.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.data.userEvent + +import com.google.firebase.firestore.DocumentSnapshot +import eu.theappfactory.dailyrecipes.model.AlreadySeenItem +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.ALREADY_SEEN +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import kotlinx.coroutines.ExperimentalCoroutinesApi + +/** + * Parse the already seen items of the user, if any. + */ +@ExperimentalCoroutinesApi +fun parseAlreadySeenItems(snapshot: DocumentSnapshot): AlreadySeenItem { + return AlreadySeenItem( + imageUri = snapshot[IMAGE_URI] as? String ?: "", + alreadySeen = snapshot[ALREADY_SEEN] as? Boolean ?: false + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreFavoriteItemParser.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreFavoriteItemParser.kt new file mode 100644 index 00000000..01f17cfc --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreFavoriteItemParser.kt @@ -0,0 +1,23 @@ +package eu.theappfactory.dailyrecipes.data.userEvent + +import com.google.firebase.Timestamp +import com.google.firebase.firestore.DocumentSnapshot +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DATE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IS_FAVORITE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TITLE +import kotlinx.coroutines.ExperimentalCoroutinesApi + +/** + * Parse the favorite items of the user, if any. + */ +@ExperimentalCoroutinesApi +fun parseFavoriteItems(snapshot: DocumentSnapshot): FavoriteItem { + return FavoriteItem( + imageUri = snapshot[IMAGE_URI] as? String ?: "", + isFavorite = snapshot[IS_FAVORITE] as? Boolean ?: false, + date = snapshot[DATE] as Timestamp? ?: Timestamp.now(), + title = snapshot[TITLE] as? String ?: "" + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserDetailParser.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserDetailParser.kt new file mode 100644 index 00000000..72ee03b0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserDetailParser.kt @@ -0,0 +1,34 @@ +package eu.theappfactory.dailyrecipes.data.userEvent + +import android.net.Uri +import com.google.firebase.firestore.DocumentSnapshot +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.EMAIL_SUBSCRIBE +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FIRST_NAME +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FOLLOWERS +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FOLLOWING +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.LAST_NAME +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHONE_NUMBER +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHOTO +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHOTO_URI +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.USERNAME +import kotlinx.coroutines.ExperimentalCoroutinesApi + +/** + * Parse the favorite items of the user, if any. + */ +@ExperimentalCoroutinesApi +fun parseUserDetailsItems(snapshot: DocumentSnapshot): UserResponse { + return UserResponse( + id = snapshot.id, + username = snapshot[USERNAME] as? String ?: "", + firstName = snapshot[FIRST_NAME] as? String ?: "", + lastName = snapshot[LAST_NAME] as? String ?: "", + phoneNumber = snapshot[PHONE_NUMBER] as? String ?: "", + photoUri = snapshot[PHOTO_URI] as? String ?: "", + photo = snapshot[PHOTO] as? Uri ?: Uri.EMPTY, + emailSubscribe = snapshot[EMAIL_SUBSCRIBE] as Boolean? ?: false, + followers = snapshot[FOLLOWERS] as? ArrayList? ?: arrayListOf(), + following = snapshot[FOLLOWING] as? ArrayList? ?: arrayListOf() + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserEventDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserEventDataSource.kt new file mode 100644 index 00000000..dec634b9 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/FirestoreUserEventDataSource.kt @@ -0,0 +1,376 @@ +package eu.theappfactory.dailyrecipes.data.userEvent + +import android.net.Uri +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import eu.theappfactory.dailyrecipes.domain.home.UpdatedStatus +import eu.theappfactory.dailyrecipes.model.* +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.ALREADY_SEEN +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DATE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IS_FAVORITE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TITLE +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.EMAIL_ADDRESS +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.EMAIL_SUBSCRIBE +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FIRST_NAME +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FOLLOWERS +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.FOLLOWING +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.LAST_NAME +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHONE_NUMBER +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHOTO +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHOTO_URI +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.USERNAME +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.util.NetworkUtils.Companion.TIMEOUT +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.collections.ArrayList +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * The data source for user data stored in firestore. It observes user data and also adds + * favorites. + */ +@ExperimentalCoroutinesApi +class FirestoreUserEventDataSource @Inject constructor( + val firestore: FirebaseFirestore +) : UserEventDataSource { + + companion object { + /** + * Firestore constants. + */ + private const val USERS_COLLECTION = "users" + private const val FAVORITES_COLLECTION = "favoriteItems" + private const val ALREADY_SEEN_COLLECTION = "alreadySeenItems" + private const val CATEGORIES_COLLECTION = "categoryItems" + private const val CATEGORIES_FOOD_TYPES = "foodTypes" + + const val ERROR_USER_PREFS = "Error getting user preferences." + const val ERROR_UPDATING_USER_PREFS = "Error updating user preferences." + } + + @MainThread // Firestore limitation b/116784117 + override fun getFavoriteItems(userId: String): List { + if (userId.isEmpty()) { + return emptyList() + } + + val task = firestore + .collection(USERS_COLLECTION) + .document(userId) + .collection(FAVORITES_COLLECTION).get() + val snapshot = Tasks.await(task, TIMEOUT, TimeUnit.SECONDS) + return snapshot.documents.map { parseFavoriteItems(it) } + } + + /** + * Favorite an item. This has to be run on the Main thread because of Firestore + * limitations. + */ + override suspend fun addFavoriteItem(userId: String, item: FavoriteItem) + : Result = withContext(Dispatchers.Main) { + // The suspendCancellableCoroutine method suspends a coroutine manually. With the + // continuation object you receive in the lambda, you can resume the coroutine + // after the work is done. + suspendCancellableCoroutine { continuation -> + + val data = mapOf( + IMAGE_URI to item.imageUri, + IS_FAVORITE to item.isFavorite, + DATE to item.date, + TITLE to item.title + ) + + firestore.collection(USERS_COLLECTION) + .document(userId) + .collection(FAVORITES_COLLECTION) + .document(item.id).set(data, SetOptions.merge()) + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success( + if (item.isFavorite) UpdatedStatus.FAVORITE + else UpdatedStatus.NOT_FAVORITE + ) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException("Error updating star.") + ) + ) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + } + + override fun removeFavoriteItem(userId: String, item: FavoriteItem) { + val task = firestore + .collection(USERS_COLLECTION) + .document(userId) + .collection(FAVORITES_COLLECTION).get() + val snapshot = Tasks.await(task, TIMEOUT, TimeUnit.SECONDS) + + for (doc in snapshot.documents) { + if (doc.reference.id == item.id) { + doc.reference.delete() + } + } + } + + @MainThread // Firestore limitation b/116784117 + override fun getAlreadySeenItems(userId: String): List { + if (userId.isEmpty()) { + return emptyList() + } + + val task = firestore + .collection(USERS_COLLECTION) + .document(userId) + .collection(ALREADY_SEEN_COLLECTION).get() + val snapshot = Tasks.await(task, TIMEOUT, TimeUnit.SECONDS) + return snapshot.documents.map { parseAlreadySeenItems(it) } + } + + /** + * Add the already seen property to the user's items. This has to be run on the + * Main thread because of Firestore limitations. + */ + override fun addAlreadySeenItem(userId: String, item: AlreadySeenItem) { + + val data = mapOf( + IMAGE_URI to item.imageUri, + ALREADY_SEEN to item.alreadySeen + ) + + firestore.collection(USERS_COLLECTION) + .document(userId) + .collection(ALREADY_SEEN_COLLECTION) + .document(item.id).set(data, SetOptions.merge()) + .addOnCompleteListener { + if (!it.isSuccessful) { + Timber.i("Did not store this already seen item.") + } + } + } + + override fun removeAllAlreadySeen(userId: String): Result { + val task = firestore + .collection(USERS_COLLECTION) + .document(userId) + .collection(ALREADY_SEEN_COLLECTION).get() + val snapshot = Tasks.await(task, TIMEOUT, TimeUnit.SECONDS) + + for (doc in snapshot.documents) { + doc.reference.delete() + } + return Result.Success(true) + } + + override suspend fun getUserFoodCategories(userId: String): Result> = + suspendCancellableCoroutine { continuation -> + if (userId.isEmpty()) { + continuation.resume(Result.Success(emptyList())) + } + + val task = firestore + .collection(USERS_COLLECTION) + .document(userId) + .collection(CATEGORIES_COLLECTION).get() + + task.addOnCompleteListener { task -> + if (!continuation.isActive) return@addOnCompleteListener + if (task.isSuccessful) { + continuation.resume( + Result.Success(task.result?.documents?.map { it[CATEGORIES_FOOD_TYPES] as List } + .orEmpty().flatten()) + ) + } else { + continuation.resume( + Result.Error( + task.exception ?: RuntimeException(ERROR_USER_PREFS) + ) + ) + } + } + } + + override suspend fun updateUserFoodCategories(userId: String, foodCategories: List) = + suspendCancellableCoroutine> { continuation -> + firestore.collection(USERS_COLLECTION) + .document(userId) + .collection(CATEGORIES_COLLECTION) + .get() + .addOnCompleteListener { + if (it.result.documents.isEmpty()) { + firestore.collection(USERS_COLLECTION) + .document(userId) + .collection(CATEGORIES_COLLECTION) + .add(mapOf(CATEGORIES_FOOD_TYPES to foodCategories)) + } else { + firestore.collection(USERS_COLLECTION) + .document(userId) + .collection(CATEGORIES_COLLECTION) + .document(it.result.documents.first().id) + .update(mapOf(CATEGORIES_FOOD_TYPES to foodCategories)) + } + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success( + Any() + ) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException(ERROR_UPDATING_USER_PREFS) + ) + ) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + + } + + override suspend fun updateUser(user: UserResponse) = + suspendCancellableCoroutine> { continuation -> + val result = checkIfUsernameExists(user.username, user.id) + + when (result) { + is Result.Success -> { + val map = if (user.photoUri == null) { + mapOf( + USERNAME to user.username, + FIRST_NAME to user.firstName, + LAST_NAME to user.lastName, + EMAIL_SUBSCRIBE to user.emailSubscribe, + EMAIL_ADDRESS to user.emailAddress, + PHONE_NUMBER to user.phoneNumber, + FOLLOWERS to user.followers, + FOLLOWING to user.following + ) + } else { + mapOf( + USERNAME to user.username, + FIRST_NAME to user.firstName, + LAST_NAME to user.lastName, + EMAIL_SUBSCRIBE to user.emailSubscribe, + EMAIL_ADDRESS to user.emailAddress, + PHOTO_URI to user.photoUri, + PHONE_NUMBER to user.phoneNumber, + FOLLOWERS to user.followers, + FOLLOWING to user.following + ) + } + firestore.collection(USERS_COLLECTION) + .document(user.id) + .set(map) + .addOnCompleteListener { + if (!continuation.isActive) return@addOnCompleteListener + if (it.isSuccessful) { + continuation.resume( + Result.Success( + UpdateUserStatus.UPDATE_OK + ) + ) + } else { + continuation.resume( + Result.Error( + it.exception ?: RuntimeException(ERROR_UPDATING_USER_PREFS) + ) + ) + } + }.addOnFailureListener { + if (!continuation.isActive) return@addOnFailureListener + continuation.resumeWithException(it) + } + } + is Result.Error -> { + continuation.resume( + Result.Error(Exception("username is already taken.")) + ) + } + } + } + + private fun checkIfUsernameExists(username: String, id: String): Result { + val users = ArrayList() + val itemSnapshot = firestore.collection(USERS_COLLECTION).get() + + try { + // Add 20 s timeout in case internet is too slow. + Tasks.await(itemSnapshot, TIMEOUT, TimeUnit.SECONDS).documents.forEach { + users.add( + UserResponse( + id = it.id, + username = it.data?.get(USERNAME) as String? ?: "", + photoUri = it.data?.get(PHOTO_URI) as String? ?: "", + emailSubscribe = it.data?.get(EMAIL_SUBSCRIBE) as Boolean? ?: false, + firstName = it.data?.get(FIRST_NAME) as String? ?: "", + lastName = it.data?.get(LAST_NAME) as String? ?: "", + photo = it.data?.get(PHOTO) as Uri? ?: Uri.EMPTY, + followers = it.data?.get(FOLLOWERS) as ArrayList? ?: arrayListOf(), + following = it.data?.get(FOLLOWING) as ArrayList? ?: arrayListOf(), + phoneNumber = it.data?.get(PHONE_NUMBER) as String? ?: "", + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + + val count = users.count { + it.username.equals(username, ignoreCase = true) + } + + if (count == 0 || users.find { it.username == username }?.id == id) { + return Result.Success(UpdateUserStatus.UPDATE_OK) + } else { + return Result.Error(Exception("username is taken.")) + } + } + + @WorkerThread + override fun getUserForId(userId: String): Result { + if (userId.isEmpty()) { + return Result.Error(Exception("Empty userId")) + } + + val task = firestore + .collection(USERS_COLLECTION) + .document(userId).get() + + val snapshot = Tasks.await(task, TIMEOUT, TimeUnit.SECONDS) + val user = parseUserDetailsItems(snapshot) + + return Result.Success(user) + } +} + +enum class UpdateUserStatus { + UPDATE_OK, + USERNAME_TAKEN +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/UserEventDataSource.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/UserEventDataSource.kt new file mode 100644 index 00000000..fb5be0cd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/data/userEvent/UserEventDataSource.kt @@ -0,0 +1,32 @@ +package eu.theappfactory.dailyrecipes.data.userEvent + +import eu.theappfactory.dailyrecipes.domain.home.UpdatedStatus +import eu.theappfactory.dailyrecipes.model.AlreadySeenItem +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result + +/** + * The data source with all the methods to get the user. + */ +interface UserEventDataSource { + + suspend fun addFavoriteItem(userId: String, item: FavoriteItem): Result + fun getFavoriteItems(userId: String): List + fun removeFavoriteItem(userId: String, item: FavoriteItem) + + fun addAlreadySeenItem(userId: String, item: AlreadySeenItem) + fun getAlreadySeenItems(userId: String): List + fun removeAllAlreadySeen(userId: String): Result + + suspend fun getUserFoodCategories(userId: String): Result> + suspend fun updateUserFoodCategories(userId: String, foodCategories: List) : Result + + /* + * We update our own table of users instead of firebase's built in table. + * Because we have unique parameters and we need to compare other user's displayNames. + * There is some overlap: Firebase built in table has a displayName and photoUri but we don't want to maintain it at 2 locations. + */ + suspend fun updateUser(user: UserResponse): Result + fun getUserForId(userId: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/di/ActivitiesModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/ActivitiesModule.kt new file mode 100644 index 00000000..d707b6f6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/ActivitiesModule.kt @@ -0,0 +1,53 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.categories.di.CategoriesModule +import eu.theappfactory.dailyrecipes.ui.deeplink.di.DeeplinkModule +import eu.theappfactory.dailyrecipes.ui.di.BaseModule +import eu.theappfactory.dailyrecipes.ui.favorites.di.FavoritesModule +import eu.theappfactory.dailyrecipes.ui.home.pager.di.ItemStepModule +import eu.theappfactory.dailyrecipes.ui.ingredients.di.IngredientsModule +import eu.theappfactory.dailyrecipes.ui.instructions.di.InstructionsModule +import eu.theappfactory.dailyrecipes.ui.invite.di.InvitationCodeModule +import eu.theappfactory.dailyrecipes.ui.launch.di.LauncherModule +import eu.theappfactory.dailyrecipes.ui.launch.di.SignInModule +import eu.theappfactory.dailyrecipes.ui.main.di.MainModule +import eu.theappfactory.dailyrecipes.ui.register.di.RegisterModule +import eu.theappfactory.dailyrecipes.ui.search.di.SearchModule +import eu.theappfactory.dailyrecipes.ui.settings.di.SettingsModule +import eu.theappfactory.dailyrecipes.ui.terms.di.TermsAndConditionsModule +import eu.theappfactory.dailyrecipes.ui.webview.di.WebViewModule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +@Module( + includes = [ + LauncherModule::class, + MainModule::class, + CacheModule::class, + ItemStepModule::class, + SearchModule::class, + FavoritesModule::class, + TermsAndConditionsModule::class, + SignInModule::class, + WebViewModule::class, + SettingsModule::class, + IngredientsModule::class, + CategoriesModule::class, + DeeplinkModule::class, + InstructionsModule::class, + InvitationCodeModule::class, + RegisterModule::class + ] +) +interface ActivitiesModule { + + @ContributesAndroidInjector(modules = [BaseModule::class]) + fun bindBaseActivity(): BaseActivity +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/di/AppComponent.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/AppComponent.kt new file mode 100644 index 00000000..2de2e4f7 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/AppComponent.kt @@ -0,0 +1,50 @@ +package eu.theappfactory.dailyrecipes.di + +import android.app.Application +import android.content.Context +import dagger.BindsInstance +import dagger.Component +import dagger.android.AndroidInjectionModule +import dagger.android.AndroidInjector +import eu.theappfactory.dailyrecipes.MainApplication +import eu.theappfactory.dailyrecipes.networking.NetworkModule +import eu.theappfactory.dailyrecipes.push.di.PushModule +import eu.theappfactory.dailyrecipes.ui.signin.di.SignInViewModelDelegateModule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Singleton + +/** + * Main component of the app, created and persisted in the Application class. + * + * Whenever a new module is created, it should be added to the list of modules. + * [AndroidInjectionModule] is the module from Dagger.Android that helps with the + * generation and location of subcomponents. + */ +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +@Singleton +@Component( + modules = [ + AndroidInjectionModule::class, + ActivitiesModule::class, + AppModule::class, + CacheModule::class, + CoroutinesModule::class, + SignInViewModelDelegateModule::class, + SharedModule::class, + NetworkModule::class, + RepositoryModule::class, + PushModule::class + ] +) +interface AppComponent : AndroidInjector { + + override fun inject(instance: MainApplication) + + @Component.Factory + interface Factory { + + fun create(@BindsInstance applicationContext: Context, @BindsInstance application: Application): AppComponent + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/dagger/AppModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/AppModule.kt similarity index 62% rename from app/src/main/java/eu/theappfactory/sampleapp/dagger/AppModule.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/di/AppModule.kt index e7f9ca2b..782afd76 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/dagger/AppModule.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/AppModule.kt @@ -1,10 +1,12 @@ -package eu.theappfactory.sampleapp.dagger +package eu.theappfactory.dailyrecipes.di import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI import dagger.Module import dagger.Provides -import eu.theappfactory.sampleapp.persistence.PreferenceStorage -import eu.theappfactory.sampleapp.persistence.SharedPreferenceStorage +import eu.theappfactory.dailyrecipes.BuildConfig +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.persistence.SharedPreferenceStorage import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import javax.inject.Singleton @@ -24,4 +26,9 @@ class AppModule { @Provides fun providesPreferenceStorage(context: Context): PreferenceStorage = SharedPreferenceStorage(context) + + @Provides + fun providesMixpanelAPI(context: Context): MixpanelAPI = + MixpanelAPI.getInstance(context, BuildConfig.MIXPANEL_TOKEN) + } \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/di/CacheModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/CacheModule.kt new file mode 100644 index 00000000..518e67ee --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/CacheModule.kt @@ -0,0 +1,57 @@ +package eu.theappfactory.dailyrecipes.di + +import android.content.Context +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.Cache +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.google.android.exoplayer2.util.Util +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.cache.VideoCacheManager +import eu.theappfactory.dailyrecipes.media.ImagePreLoader +import javax.inject.Singleton + +@Module +class CacheModule { + + @Provides + @Singleton + fun providesCache(context: Context): Cache = SimpleCache( + context.cacheDir, + LeastRecentlyUsedCacheEvictor(CACHE_MAX_SIZE), + ExoDatabaseProvider(context) + ) + + @Provides + @Singleton + fun providesDefaultDataVideoSourceFactory(context: Context) = DefaultDataSourceFactory( + context, + Util.getUserAgent(context, context.getString(R.string.app_name)) + ) + + @Provides + @Singleton + fun providesCachedDataVideoSourceFactory( + cache: Cache, + defaultDataSourceFactory: DefaultDataSourceFactory + ) = CacheDataSource.Factory().apply { + setCache(cache) + setUpstreamDataSourceFactory(defaultDataSourceFactory) + } + + @Provides + fun providesVideoCacheManager(cachedDataSourceFactory: CacheDataSource.Factory): VideoCacheManager = + VideoCacheManager(cachedDataSourceFactory) + + @Provides + fun providesImagePreLoader(context: Context) = ImagePreLoader(context) + + companion object { + + const val CACHE_MAX_SIZE = 512 * 1024 * 1024L + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/dagger/CoroutinesModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/CoroutinesModule.kt similarity index 92% rename from app/src/main/java/eu/theappfactory/sampleapp/dagger/CoroutinesModule.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/di/CoroutinesModule.kt index 1e2eb389..9765c80f 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/dagger/CoroutinesModule.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/CoroutinesModule.kt @@ -1,4 +1,4 @@ -package eu.theappfactory.sampleapp.dagger +package eu.theappfactory.dailyrecipes.di import dagger.Module import dagger.Provides diff --git a/app/src/main/java/eu/theappfactory/sampleapp/dagger/DispatcherQualifiers.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/DispatcherQualifiers.kt similarity index 86% rename from app/src/main/java/eu/theappfactory/sampleapp/dagger/DispatcherQualifiers.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/di/DispatcherQualifiers.kt index 39228b58..a2cd390a 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/dagger/DispatcherQualifiers.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/DispatcherQualifiers.kt @@ -1,4 +1,4 @@ -package eu.theappfactory.sampleapp.dagger +package eu.theappfactory.dailyrecipes.di import javax.inject.Qualifier diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/di/RepositoryModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/RepositoryModule.kt new file mode 100644 index 00000000..c124990d --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/RepositoryModule.kt @@ -0,0 +1,93 @@ +package eu.theappfactory.dailyrecipes.di + +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.data.* +import eu.theappfactory.dailyrecipes.data.chats.UpdateItemChatsDataSource +import eu.theappfactory.dailyrecipes.data.db.AppDatabase +import eu.theappfactory.dailyrecipes.data.ingredients.* +import eu.theappfactory.dailyrecipes.data.invite.* +import eu.theappfactory.dailyrecipes.data.register.UploadProfilePictureDataSource +import eu.theappfactory.dailyrecipes.data.signin.AuthStateUserDataSource +import eu.theappfactory.dailyrecipes.data.userEvent.UserEventDataSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Named +import javax.inject.Singleton + +@Module +@ExperimentalCoroutinesApi +class RepositoryModule { + + @Singleton + @Provides + fun provideItemRepository( + @Named("remoteItemDatasource") remoteDataSource: ItemDataSource, + @Named("bootstrapItemDatasource") bootstrapItemDataSource: ItemDataSource, + appDatabase: AppDatabase + ): ItemRepository { + return DefaultItemRepository( + remoteDataSource, + bootstrapItemDataSource, + appDatabase + ) + } + + @Singleton + @Provides + fun provideUserRepository( + userEventDataSource: UserEventDataSource, + authStateUserDataSource: AuthStateUserDataSource, + uploadProfilePictureDataSource: UploadProfilePictureDataSource + ): UserRepository { + return DefaultUserRepository( + userEventDataSource, + uploadProfilePictureDataSource + ) + } + + @Singleton + @Provides + fun provideSearchIngredientsRepository( + @Named("remoteIngredientsDataSource") remoteIngredientsDataSource: IngredientsDataSource, + @Named("localIngredientsDataSource") localIngredientsDataSource: IngredientsDataSource + ): SearchIngredientsRepository { + return DefaultSearchIngredientsRepository( + remoteIngredientsDataSource, + localIngredientsDataSource + ) + } + + @Singleton + @Provides + fun provideUpdateItemRepository( + updateItemIngredientsDataSource: UpdateItemIngredientsDataSource, + updateItemChatsDataSource: UpdateItemChatsDataSource, + @Named("remoteItemDatasource") remoteDataSource: ItemDataSource, + ): UpdateItemRepository { + return DefaultUpdateItemRepository(updateItemIngredientsDataSource, updateItemChatsDataSource, remoteDataSource) + } + + @Singleton + @Provides + fun provideIngredientsRepository(@Named("remoteIngredientsDataSource") ingredientsDataSource: IngredientsDataSource): IngredientsRepository { + return DefaultIngredientsRepository(ingredientsDataSource) + } + + @Singleton + @Provides + fun provideIngredientsImageRepository(imageDataSource: IngredientsImageDataSource): IngredientsImageRepository { + return DefaultIngredientsImageRepository(imageDataSource) + } + + @Singleton + @Provides + fun provideInviteCodeRepository(inviteCodeDataSource: FirestoreInviteCodeDataSource): InviteCodeRepository { + return DefaultInviteCodeRepository(inviteCodeDataSource) + } + + @Singleton + @Provides + fun provideAddressBookRepository(addressBookDataSource: AddressBookDataSource): AddressBookRepository { + return DefaultAddressBookRepository(addressBookDataSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/di/SharedModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/di/SharedModule.kt new file mode 100644 index 00000000..3a74d6ae --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/di/SharedModule.kt @@ -0,0 +1,148 @@ +package eu.theappfactory.dailyrecipes.di + +import android.content.Context +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.FirebaseFirestoreSettings +import com.google.firebase.storage.FirebaseStorage +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.data.BootstrapItemDataSource +import eu.theappfactory.dailyrecipes.data.ItemDataSource +import eu.theappfactory.dailyrecipes.data.NetworkItemDataSource +import eu.theappfactory.dailyrecipes.data.chats.FirestoreUpdateItemChatsDataSource +import eu.theappfactory.dailyrecipes.data.chats.UpdateItemChatsDataSource +import eu.theappfactory.dailyrecipes.data.db.AppDatabase +import eu.theappfactory.dailyrecipes.data.ingredients.* +import eu.theappfactory.dailyrecipes.data.invite.AddressBookDataSource +import eu.theappfactory.dailyrecipes.data.invite.DefaultAddressBookDataSource +import eu.theappfactory.dailyrecipes.data.invite.FirestoreInviteCodeDataSource +import eu.theappfactory.dailyrecipes.data.invite.InviteCodeDataSource +import eu.theappfactory.dailyrecipes.data.register.FirestoreUploadProfilePictureDataSource +import eu.theappfactory.dailyrecipes.data.register.UploadProfilePictureDataSource +import eu.theappfactory.dailyrecipes.data.userEvent.FirestoreUserEventDataSource +import eu.theappfactory.dailyrecipes.data.userEvent.UserEventDataSource +import eu.theappfactory.dailyrecipes.networking.AHApi +import eu.theappfactory.dailyrecipes.networking.JumboApi +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.util.NetworkUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Named +import javax.inject.Singleton + +/** + * Module where classes created in the shared module are created. + */ +@ExperimentalCoroutinesApi +@Module +class SharedModule { + + @Singleton + @Provides + @Named("bootstrapItemDatasource") + fun provideBootstrapItemDataSource( + context: Context + ): ItemDataSource = BootstrapItemDataSource(context) + + @Singleton + @Provides + @Named("remoteItemDatasource") + fun provideNetworkItemDataSource( + networkUtils: NetworkUtils, + firestore: FirebaseFirestore + ): ItemDataSource = NetworkItemDataSource( + networkUtils, + firestore + ) + + @Singleton + @Provides + fun provideUserEventDataSource(firestore: FirebaseFirestore): UserEventDataSource = + FirestoreUserEventDataSource(firestore) + + @Singleton + @Provides + @Named("remoteIngredientsDataSource") + fun provideRemoteSearchIngredientsDataSource(firestore: FirebaseFirestore): IngredientsDataSource = + FirestoreIngredientsDataSource(firestore) + + @Singleton + @Provides + @Named("localIngredientsDataSource") + fun provideLocalSearchIngredientsDataSource(firestore: FirebaseFirestore): IngredientsDataSource = + LocalIngredientsDataSource(firestore) + + @Singleton + @Provides + fun provideUpdateItemIngredientDataSource(firestore: FirebaseFirestore): UpdateItemIngredientsDataSource = + FirestoreUpdateItemIngredientsDataSource(firestore) + + @Singleton + @Provides + fun provideUpdateItemChatsDataSource(firestore: FirebaseFirestore): UpdateItemChatsDataSource = + FirestoreUpdateItemChatsDataSource(firestore) + + @Singleton + @Provides + fun provideIngredientsImageDataSource(firebaseStorage: FirebaseStorage): IngredientsImageDataSource = + FirestoreIngredientsImageDataSource(firebaseStorage) + + @Singleton + @Provides + fun provideInviteCodeDataSource(firebaseStorage: FirebaseFirestore): InviteCodeDataSource = + FirestoreInviteCodeDataSource(firebaseStorage) + + @Singleton + @Provides + fun provideAddressBookDataSource( + context: Context, + firebaseStorage: FirebaseFirestore + ): AddressBookDataSource = + DefaultAddressBookDataSource(context, firebaseStorage) + + @Singleton + @Provides + fun provideFirebaseFireStore(): FirebaseFirestore { + val firestore = FirebaseFirestore.getInstance() + firestore.firestoreSettings = FirebaseFirestoreSettings.Builder() + // This is to enable the offline data + // https://firebase.google.com/docs/firestore/manage-data/enable-offline + .setPersistenceEnabled(true) + .build() + return firestore + } + + @Singleton + @Provides + fun provideFirestoreUploadProfilePictureDataSource( + firebaseStorage: FirebaseStorage, + context: Context + ): UploadProfilePictureDataSource { + return FirestoreUploadProfilePictureDataSource(firebaseStorage, context) + } + + @Singleton + @Provides + fun provideFirebaseStorage(): FirebaseStorage { + return FirebaseStorage.getInstance() + } + + @Singleton + @Provides + fun providesAppDatabase(context: Context): AppDatabase = AppDatabase.buildDatabase(context) + + @Singleton + @Provides + fun provideAlbertHeijnDataSource(AHApi: AHApi, preferenceStorage: PreferenceStorage): AHDataSource = AHDataSource(AHApi, preferenceStorage) + + @Singleton + @Provides + fun provideJumboDataSource(jumboApi: JumboApi): JumboDataSource = JumboDataSource(jumboApi) + + @Singleton + @Provides + fun provideSupermarketIngredientsRepository( + AHDataSource: AHDataSource, + jumboDataSource: JumboDataSource + ): SupermarketIngredientsRepository = DefaultSupermarketIngredientsRepository(AHDataSource, jumboDataSource) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/FlowUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/FlowUseCase.kt similarity index 55% rename from app/src/main/java/eu/theappfactory/sampleapp/util/FlowUseCase.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/domain/FlowUseCase.kt index 07907794..5a34df92 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/FlowUseCase.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/FlowUseCase.kt @@ -1,26 +1,11 @@ -/* - * Copyright 2018 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.domain import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn +import eu.theappfactory.dailyrecipes.networking.Result /** * Executes business logic in its execute method and keep posting updates to the result as @@ -37,4 +22,4 @@ abstract class FlowUseCase(private val coroutineDispatcher: CoroutineDi } abstract fun execute(parameters: P): Flow> -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/SuspendUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/SuspendUseCase.kt new file mode 100644 index 00000000..791a9d9f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/SuspendUseCase.kt @@ -0,0 +1,43 @@ +package eu.theappfactory.dailyrecipes.domain + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import timber.log.Timber +import eu.theappfactory.dailyrecipes.networking.Result + +/** + * Executes business logic synchronously or asynchronously using Coroutines. + * + * The [execute] method of [SuspendUseCase] is a suspend function as opposed to the + * [UseCase.execute] method of [UseCase]. + */ +abstract class SuspendUseCase(private val coroutineDispatcher: CoroutineDispatcher) { + + /** Executes the use case asynchronously and returns a [Result]. + * + * @return a [Result]. + * + * @param parameters the input parameters to run the use case with + */ + suspend operator fun invoke(parameters: P): Result { + return try { + // Moving all use case's executions to the injected dispatcher + // In production code, this is usually the Default dispatcher (background thread) + // In tests, this becomes a TestCoroutineDispatcher + withContext(coroutineDispatcher) { + execute(parameters).let { + Result.Success(it) + } + } + } catch (e: Exception) { + Timber.d(e) + Result.Error(e) + } + } + + /** + * Override this to set the code to be executed. + */ + @Throws(RuntimeException::class) + protected abstract suspend fun execute(parameters: P): R +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/auth/ObserveUserAuthStateUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/auth/ObserveUserAuthStateUseCase.kt new file mode 100644 index 00000000..ec96fe43 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/auth/ObserveUserAuthStateUseCase.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.domain.auth + +import eu.theappfactory.dailyrecipes.data.signin.AuthStateUserDataSource +import eu.theappfactory.dailyrecipes.data.signin.AuthenticatedUserInfo +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.domain.FlowUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.Result.Error +import eu.theappfactory.dailyrecipes.networking.Result.Success +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [FlowUseCase] that observes a data source to generate an [AuthenticatedUserInfo]. + * + * [AuthStateUserDataSource] provides general user information, like user IDs. + */ +@Singleton +open class ObserveUserAuthStateUseCase @Inject constructor( + private val authStateUserDataSource: AuthStateUserDataSource, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : FlowUseCase(defaultDispatcher) { + + override fun execute(parameters: Any): Flow> = + authStateUserDataSource.getBasicUserInfo().map { userResult -> + if (userResult is Success) { + Success(userResult.data) + } else { + Error(Exception("FirebaseAuth error")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/EraseAlreadySeenUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/EraseAlreadySeenUseCase.kt new file mode 100644 index 00000000..708821c1 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/EraseAlreadySeenUseCase.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * When the user uses the App too much, the situation can occur + * that he/she has seen all items. In that case, show the same + * instead of not showing anything. + */ +@ExperimentalCoroutinesApi +class EraseAlreadySeenUseCase @Inject constructor( + private val repository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase>(ioDispatcher) { + + override fun execute(parameters: String): Result { + return repository.eraseAlreadySeen(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFavoriteItemsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFavoriteItemsUseCase.kt new file mode 100644 index 00000000..143ce114 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFavoriteItemsUseCase.kt @@ -0,0 +1,23 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * Retrieve the user's favorite items. + */ +@ExperimentalCoroutinesApi +class GetFavoriteItemsUseCase @Inject constructor( + private val repository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase>(ioDispatcher) { + + override fun execute(parameters: String): List { + return repository.getFavoriteItems(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFirstTimeUseItemsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFirstTimeUseItemsUseCase.kt new file mode 100644 index 00000000..c8e085ce --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetFirstTimeUseItemsUseCase.kt @@ -0,0 +1,23 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * Fetch the offline data from the repository. + */ +@ExperimentalCoroutinesApi +open class GetFirstTimeUseItemsUseCase @Inject constructor( + private val repository: ItemRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase(ioDispatcher) { + + override fun execute(parameters: Unit): ItemData { + return repository.getOfflineItemData() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUseCase.kt new file mode 100644 index 00000000..d58ad51e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUseCase.kt @@ -0,0 +1,23 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.ItemData +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * Get new items via the ItemRepository + */ +@ExperimentalCoroutinesApi +open class GetItemsUseCase @Inject constructor( + private val repository: ItemRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase(ioDispatcher) { + + override fun execute(parameters: Unit): ItemData { + return repository.retrieveRemoteItemData() + } +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUserHasNotYetSeen.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUserHasNotYetSeen.kt new file mode 100644 index 00000000..03869d2f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/GetItemsUserHasNotYetSeen.kt @@ -0,0 +1,22 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.AlreadySeenItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +/** + * The user has a collection with already seen items on Firebase. + */ +open class GetItemsUserHasNotYetSeen @Inject constructor( + private val repository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase>, ArrayList>(ioDispatcher) { + + override fun execute(parameters: Pair>): ArrayList { + return repository.getAlreadySeenItems(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/InsertIngredientsIntoAHAppUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/InsertIngredientsIntoAHAppUseCase.kt new file mode 100644 index 00000000..823dd951 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/InsertIngredientsIntoAHAppUseCase.kt @@ -0,0 +1,24 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.ingredients.SupermarketIngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * Insert the ingredients for the selected food item + * into the AH website. + */ +@ExperimentalCoroutinesApi +open class InsertIngredientsIntoAHAppUseCase @Inject constructor( + private val repository: SupermarketIngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase, Result>(ioDispatcher) { + + override suspend fun execute(parameters: List): Result { + return repository.insertIngredientInAHApi(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/SaveFavoriteItemUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/SaveFavoriteItemUseCase.kt new file mode 100644 index 00000000..1db9f02a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/SaveFavoriteItemUseCase.kt @@ -0,0 +1,47 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +/** + * The user's favorite items are stored on Firebase. + */ +open class SaveFavoriteItemUseCase @Inject constructor( + private val repository: UserRepository, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : SuspendUseCase(defaultDispatcher) { + + override suspend fun execute(parameters: FavoriteEventParameter): UpdatedStatus { + if (!parameters.item.isFavorite) { + repository.removeFavoriteItem(parameters.userId, parameters.item) + return UpdatedStatus.NOT_FAVORITE + } else { + val result = repository.addToFavoritesEvent(parameters.userId, parameters.item) + + return when (result) { + is Result.Success -> { + result.data + } + is Result.Error -> { + throw result.exception + } + else -> throw IllegalStateException() + } + } + } +} + +data class FavoriteEventParameter( + val userId: String, + val item: FavoriteItem +) + +enum class UpdatedStatus { + FAVORITE, + NOT_FAVORITE +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateAlreadySeenUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateAlreadySeenUseCase.kt new file mode 100644 index 00000000..5d03186c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateAlreadySeenUseCase.kt @@ -0,0 +1,26 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.AlreadySeenItem +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +/** + * The user's favorite items are stored on Firebase. + */ +open class UpdateAlreadySeenUseCase @Inject constructor( + private val repository: UserRepository, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : SuspendUseCase(defaultDispatcher) { + + override suspend fun execute(parameters: AlreadySeenEventParameter): Any { + return repository.addAlreadySeenItem(parameters.userId, parameters.item) + } +} + +data class AlreadySeenEventParameter( + val userId: String, + val item: AlreadySeenItem +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateLikesUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateLikesUseCase.kt new file mode 100644 index 00000000..9ee186d4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/home/UpdateLikesUseCase.kt @@ -0,0 +1,24 @@ +package eu.theappfactory.dailyrecipes.domain.home + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +open class UpdateLikesUseCase @Inject constructor( + private val repository: ItemRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase(ioDispatcher) { + + override suspend fun execute(parameters: UpdateLikesParameter): Any { + return repository.updateLikeCount(parameters.id, parameters.likesCount) + } +} + +data class UpdateLikesParameter( + val id: String, + val likesCount: Long +) diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetIngredientFromAHUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetIngredientFromAHUseCase.kt new file mode 100644 index 00000000..7fe2a09a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetIngredientFromAHUseCase.kt @@ -0,0 +1,21 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import eu.theappfactory.dailyrecipes.data.ingredients.SupermarketIngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +open class GetIngredientFromAHUseCase @Inject constructor( + private val repository: SupermarketIngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: String): Result { + return repository.getIngredientFromAHApi(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetItemIngredientsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetItemIngredientsUseCase.kt new file mode 100644 index 00000000..7c171954 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/GetItemIngredientsUseCase.kt @@ -0,0 +1,29 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.data.ingredients.IngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +open class GetItemIngredientsUseCase @Inject constructor( + private val itemRepository: ItemRepository, + private val ingredientsRepository: IngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase?>(ioDispatcher) { + + override fun execute(parameters: String): List? { + val response = Gson().fromJson( + itemRepository.getOfflineItemData().items.firstOrNull { it.id == parameters }?.ingredients, + IngredientsResponse::class.java + ) + return ingredientsRepository.getAllIngredients() + .filter { response?.ingredients.orEmpty().contains(it.url) } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SaveIngredientsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SaveIngredientsUseCase.kt new file mode 100644 index 00000000..68c4ffd6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SaveIngredientsUseCase.kt @@ -0,0 +1,21 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import eu.theappfactory.dailyrecipes.data.ingredients.IngredientsRepository +import eu.theappfactory.dailyrecipes.data.ingredients.SearchIngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class SaveIngredientsUseCase @Inject constructor( + private val repository: IngredientsRepository, + private val searchIngredientsRepository: SearchIngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase(ioDispatcher) { + + override fun execute(parameters: IngredientItemData) = + if (searchIngredientsRepository.searchIngredientsByUrl(parameters.url) == null) { + repository.addIngredient(parameters) + } else Unit +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SearchIngredientsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SearchIngredientsUseCase.kt new file mode 100644 index 00000000..f8f4a0af --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/SearchIngredientsUseCase.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import eu.theappfactory.dailyrecipes.data.ingredients.SearchIngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.IngredientItemData +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +open class SearchIngredientsUseCase @Inject constructor( + private val repository: SearchIngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase>(ioDispatcher) { + + override fun execute(parameters: String): List { + return repository.searchIngredientsByName(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UpdateIngredientsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UpdateIngredientsUseCase.kt new file mode 100644 index 00000000..7b5b9a28 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UpdateIngredientsUseCase.kt @@ -0,0 +1,32 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.data.ingredients.UpdateItemRepository +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +open class UpdateIngredientsUseCase @Inject constructor( + private val updateItemRepository: UpdateItemRepository, + private val itemRepository: ItemRepository, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : SuspendUseCase(defaultDispatcher) { + + override suspend fun execute(parameters: UpdateIngredientsParameter) = + updateItemRepository.updateIngredients(parameters.id, parameters.ingredientsUpdateData).also { + if (it is Result.Success) { + val currentItem = + itemRepository.getOfflineItemData().items.firstOrNull { parameters.id == it.id } + currentItem?.let { it.ingredients = Gson().toJson(parameters.ingredientsUpdateData) } + } + } +} + +data class IngredientsUpdateData(val supermarket: String, val ingredients: List) +data class UpdateIngredientsParameter( + val id: String, + val ingredientsUpdateData: IngredientsUpdateData +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UploadIngredientsImageUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UploadIngredientsImageUseCase.kt new file mode 100644 index 00000000..40ace6c9 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/ingredients/UploadIngredientsImageUseCase.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.domain.ingredients + +import eu.theappfactory.dailyrecipes.data.ingredients.IngredientsImageRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.successOr +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class UploadIngredientsImageUseCase @Inject constructor( + private val repository: IngredientsImageRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase(ioDispatcher) { + + // keep current image in case of failure + override suspend fun execute(parameters: String) = + repository.uploadImage(parameters).successOr(parameters) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/GetItemUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/GetItemUseCase.kt new file mode 100644 index 00000000..c963352e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/GetItemUseCase.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.domain.instructions + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class GetItemUseCase @Inject constructor( + private val itemRepository: ItemRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase(ioDispatcher) { + + override fun execute(parameters: String): ItemResponse { + return itemRepository.getOfflineItemData().items.firstOrNull { it.id == parameters } ?: ItemResponse() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/UpdateChatsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/UpdateChatsUseCase.kt new file mode 100644 index 00000000..95c9cffb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/instructions/UpdateChatsUseCase.kt @@ -0,0 +1,34 @@ +package eu.theappfactory.dailyrecipes.domain.instructions + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.data.ingredients.UpdateItemRepository +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +open class UpdateChatsUseCase @Inject constructor( + private val updateItemRepository: UpdateItemRepository, + private val itemRepository: ItemRepository, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : SuspendUseCase(defaultDispatcher) { + + override suspend fun execute(parameters: UpdateChatsParameter) = + updateItemRepository.updateChats(parameters.id, parameters.chatsData).also { + if (it is Result.Success) { + val currentItem = + itemRepository.getOfflineItemData().items.firstOrNull { parameters.id == it.id } + currentItem?.let { + it.chats?.firstOrNull { it.user == parameters.chatsData.user } + parameters.chatsData + } + } + } +} + +data class UpdateChatsParameter( + val id: String, + val chatsData: ChatItem +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/CheckInviteCodeUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/CheckInviteCodeUseCase.kt new file mode 100644 index 00000000..b254ed7a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/CheckInviteCodeUseCase.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.domain.invite + +import eu.theappfactory.dailyrecipes.data.invite.InviteCodeRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class CheckInviteCodeUseCase @Inject constructor( + private val inviteCodeRepository: InviteCodeRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: Long): Result { + return inviteCodeRepository.checkInvitationCode(parameters) + } +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/LoadAddressBookUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/LoadAddressBookUseCase.kt new file mode 100644 index 00000000..323a9bf1 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/LoadAddressBookUseCase.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.domain.invite + +import eu.theappfactory.dailyrecipes.data.invite.AddressBookRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class LoadAddressBookUseCase @Inject constructor( + private val addressBookRepository: AddressBookRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : UseCase>(ioDispatcher) { + + override fun execute(parameters: Unit): ArrayList { + return addressBookRepository.getContacts() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/UploadAddressBookUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/UploadAddressBookUseCase.kt new file mode 100644 index 00000000..783524df --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/invite/UploadAddressBookUseCase.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.domain.invite + +import eu.theappfactory.dailyrecipes.data.invite.AddressBookRepository +import eu.theappfactory.dailyrecipes.data.userEvent.UpdateUserStatus +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class UploadAddressBookUseCase @Inject constructor( + private val addressBookRepository: AddressBookRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>, Result>(ioDispatcher) { + override suspend fun execute(parameters: Pair>): Result { + return addressBookRepository.uploadContacts(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompleteUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompleteUseCase.kt similarity index 67% rename from app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompleteUseCase.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompleteUseCase.kt index 44256980..93c0bccc 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompleteUseCase.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompleteUseCase.kt @@ -1,8 +1,8 @@ -package eu.theappfactory.sampleapp.interactors +package eu.theappfactory.dailyrecipes.domain.launch -import eu.theappfactory.sampleapp.dagger.DefaultDispatcher -import eu.theappfactory.sampleapp.persistence.PreferenceStorage -import eu.theappfactory.sampleapp.util.UseCase +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.util.UseCase import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject diff --git a/app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompletedUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompletedUseCase.kt similarity index 67% rename from app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompletedUseCase.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompletedUseCase.kt index 03fb1c2e..f0c1abfb 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/interactors/RegisterOrLoginCompletedUseCase.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/launch/RegisterOrLoginCompletedUseCase.kt @@ -1,8 +1,8 @@ -package eu.theappfactory.sampleapp.interactors +package eu.theappfactory.dailyrecipes.domain.launch -import eu.theappfactory.sampleapp.dagger.DefaultDispatcher -import eu.theappfactory.sampleapp.persistence.PreferenceStorage -import eu.theappfactory.sampleapp.util.UseCase +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.util.UseCase import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/main/CheckIfUserNeedsToLoginUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/main/CheckIfUserNeedsToLoginUseCase.kt new file mode 100644 index 00000000..34e961f4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/main/CheckIfUserNeedsToLoginUseCase.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.domain.main + +import eu.theappfactory.dailyrecipes.data.ingredients.IngredientsRepository +import eu.theappfactory.dailyrecipes.data.ingredients.SupermarketIngredientsRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +/** + * Checks whether the user is already logged in + * to the AH Website. + */ +@ExperimentalCoroutinesApi +open class CheckIfUserNeedsToLoginUseCase @Inject constructor( + private val repository: SupermarketIngredientsRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: String): Result { + return repository.login() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/FoodCategory.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/FoodCategory.kt new file mode 100644 index 00000000..76eb54a8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/FoodCategory.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.domain.preferences + +import androidx.annotation.StringRes +import eu.theappfactory.dailyrecipes.R + +private const val FOOD_TYPE_MEAT = "meat" +private const val FOOD_TYPE_CHICKEN = "chicken" +private const val FOOD_TYPE_FISH = "fish" +private const val FOOD_TYPE_VEGETARIAN = "vegetarian" +private const val FOOD_TYPE_VEGAN = "vegan" +private const val FOOD_TYPE_DESSERTS_AND_PASTRIES = "pastry" +private const val FOOD_TYPE_FINGER_FOOD = "fingerfood" +private const val FOOD_TYPE_LIFE_HACKS = "lifehack" + +enum class FoodCategory( + val foodType: String, + @StringRes val nameResId: Int, + val foodSubCategories: List = listOf() +) { + + MEAT(FOOD_TYPE_MEAT, R.string.food_type_meat), + CHICKEN(FOOD_TYPE_CHICKEN, R.string.food_type_chicken), + FISH(FOOD_TYPE_FISH, R.string.food_type_fish), + VEGETARIAN(FOOD_TYPE_VEGETARIAN, R.string.food_type_vegetarian), + VEGAN(FOOD_TYPE_VEGAN, R.string.food_type_vegan), + DESSERTS_AND_PASTRIES(FOOD_TYPE_DESSERTS_AND_PASTRIES, R.string.food_type_pastry), + FINGER_FOOD( + FOOD_TYPE_FINGER_FOOD, + R.string.food_type_fingerfood, + listOf(MEAT, CHICKEN, FISH, VEGETARIAN, VEGAN) + ), + LIFE_HACKS(FOOD_TYPE_LIFE_HACKS, R.string.food_type_lifehacks); +} + + diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/GetUserFoodCategoriesUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/GetUserFoodCategoriesUseCase.kt new file mode 100644 index 00000000..964ac8ec --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/GetUserFoodCategoriesUseCase.kt @@ -0,0 +1,18 @@ +package eu.theappfactory.dailyrecipes.domain.preferences + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class GetUserFoodCategoriesUseCase @Inject constructor( + private val userRepository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>>(ioDispatcher) { + + override suspend fun execute(parameters: String): Result> { + return userRepository.getUserFoodCategories(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UpdateUserPreferencesUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UpdateUserPreferencesUseCase.kt new file mode 100644 index 00000000..df7814ae --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UpdateUserPreferencesUseCase.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.domain.preferences + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class UpdateUserPreferencesUseCase @Inject constructor( + private val userRepository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : + SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: UpdateUserPreferencesParams): Result { + return if (parameters.foodTypes.isNotEmpty()) { + userRepository.updateUserFoodCategories(parameters.userId, parameters.foodTypes) + } else { + Result.Success(Any()) + } + } +} + +data class UpdateUserPreferencesParams(val userId: String, val foodTypes: List) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompleteUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompleteUseCase.kt new file mode 100644 index 00000000..4f799590 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompleteUseCase.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.domain.preferences + +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +open class UserFoodCategoriesCompleteUseCase @Inject constructor( + private val preferenceStorage: PreferenceStorage, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : UseCase, Unit>(defaultDispatcher) { + + override fun execute(parameters: Set) { + preferenceStorage.foodCategories = parameters + } +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompletedUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompletedUseCase.kt new file mode 100644 index 00000000..e2a15663 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/UserFoodCategoriesCompletedUseCase.kt @@ -0,0 +1,15 @@ +package eu.theappfactory.dailyrecipes.domain.preferences + +import eu.theappfactory.dailyrecipes.di.DefaultDispatcher +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.util.UseCase +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +open class UserFoodCategoriesCompletedUseCase @Inject constructor( + private val preferenceStorage: PreferenceStorage, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher +) : UseCase(defaultDispatcher) { + + override fun execute(parameters: Unit): Boolean = preferenceStorage.foodCategories.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/BaseCategoryItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/BaseCategoryItem.kt new file mode 100644 index 00000000..114bad8e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/BaseCategoryItem.kt @@ -0,0 +1,12 @@ +package eu.theappfactory.dailyrecipes.domain.preferences.items + +abstract class BaseCategoryItem { + + abstract fun getType(): Int + + companion object { + + const val ITEM_HEADER = 0 + const val ITEM_CATEGORY = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryHeaderItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryHeaderItem.kt new file mode 100644 index 00000000..e7175332 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryHeaderItem.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.domain.preferences.items + +import androidx.annotation.StringRes + +data class FoodCategoryHeaderItem(@StringRes val titleResId: Int) : BaseCategoryItem() { + + override fun getType() = ITEM_HEADER +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryItem.kt new file mode 100644 index 00000000..2d7751cc --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/preferences/items/FoodCategoryItem.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.domain.preferences.items + +import eu.theappfactory.dailyrecipes.domain.preferences.FoodCategory + +data class FoodCategoryItem(val foodCategory: FoodCategory, var isSelected : Boolean = true) : BaseCategoryItem() { + + override fun getType() = ITEM_CATEGORY +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/register/UpdateUserUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/register/UpdateUserUseCase.kt new file mode 100644 index 00000000..7c929dd0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/register/UpdateUserUseCase.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.domain.register + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.data.userEvent.UpdateUserStatus +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class UpdateUserUseCase @Inject constructor( + private val userRepository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: UserResponse): Result { + return userRepository.updateUser(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/search/SearchItemsUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/search/SearchItemsUseCase.kt new file mode 100644 index 00000000..8e532492 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/search/SearchItemsUseCase.kt @@ -0,0 +1,52 @@ +package eu.theappfactory.dailyrecipes.domain.search + +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +open class SearchItemsUseCase @Inject constructor( + private val itemRepository: ItemRepository, + private val userRepository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: SearchParams): List { + val items = + if (parameters.query.isNotBlank()) itemRepository.retrieveRemoteItemData().items.filter { item -> + item.title.contains(parameters.query, ignoreCase = true) || + item.foodType.contains(parameters.query, ignoreCase = true) || + item.ingredients?.contains(parameters.query.replace(" ", "-"), ignoreCase = true) == true || + item.username.contains(parameters.query, ignoreCase = true) + } else emptyList() + val favoriteItems = userRepository.getFavoriteItems(parameters.userId) + return makeListWithOnlyFavorites( + items, + favoriteItems + ).plus(makeListWithOnlyNotFavorites(items, favoriteItems)) + } + + private fun makeListWithOnlyFavorites( + items: List, + favorites: List + ) = items.filter { item -> favorites.any { favorite -> favorite.imageUri == item.imageUri } } + .map { + it.isFavorite = true + it + }.sortedByDescending { it.date } + + private fun makeListWithOnlyNotFavorites( + items: List, + favorites: List + ) = items.filterNot { item -> favorites.any { favorite -> favorite.imageUri == item.imageUri } } + .sortedByDescending { it.date } +} + +data class SearchParams(val userId: String, val query: String) + diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/domain/userdetails/GetUserUseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/userdetails/GetUserUseCase.kt new file mode 100644 index 00000000..4d5309fd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/domain/userdetails/GetUserUseCase.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.domain.userdetails + +import eu.theappfactory.dailyrecipes.data.UserRepository +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.domain.SuspendUseCase +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class GetUserUseCase @Inject constructor( + private val userRepository: UserRepository, + @IoDispatcher ioDispatcher: CoroutineDispatcher +) : SuspendUseCase>(ioDispatcher) { + + override suspend fun execute(parameters: String): Result { + return userRepository.getUser(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/internal/TaskScheduler.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/internal/TaskScheduler.kt similarity index 98% rename from app/src/main/java/eu/theappfactory/sampleapp/internal/TaskScheduler.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/internal/TaskScheduler.kt index 5dbe7af7..12df70d9 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/internal/TaskScheduler.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/internal/TaskScheduler.kt @@ -1,4 +1,4 @@ -package eu.theappfactory.sampleapp.internal +package eu.theappfactory.dailyrecipes.internal import android.os.Handler import android.os.Looper diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/media/ImagePreLoader.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/media/ImagePreLoader.kt new file mode 100644 index 00000000..c5850050 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/media/ImagePreLoader.kt @@ -0,0 +1,53 @@ +package eu.theappfactory.dailyrecipes.media + +import android.content.Context +import com.bumptech.glide.Glide +import eu.theappfactory.dailyrecipes.model.ItemResponse +import kotlinx.coroutines.* +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.min + +class ImagePreLoader @Inject constructor(private val context: Context) { + + private val scope = + CoroutineScope(SupervisorJob() + Dispatchers.Main + CoroutineExceptionHandler { _, throwable -> + Timber.e(throwable) + }) + + fun preloadFrames(items: List) = + items.slice( + IntRange( + 1, + min(INITIAL_PRELOAD_SIZE, items.size - 1) + ) + ).forEach { + scope.launch { + delay(INITIAL_DELAY_TIME) + preloadImages(it) + } + } + + fun loadFrameForItem(item: ItemResponse?) = scope.launch { + delay(1000) + item?.let { preloadImages(it) } + } + + fun cancel() = scope.coroutineContext.cancelChildren() + + private suspend fun preloadImages(item: ItemResponse) = + suspendCancellableCoroutine { cancellableContinuation -> + val target = Glide.with(context).load(item.imageUri).preload() + + cancellableContinuation.invokeOnCancellation { + Glide.with(context).clear(target) + } + Timber.i("Setting image for ${item.title}") + } + + private companion object { + + private const val INITIAL_PRELOAD_SIZE = 4 + private const val INITIAL_DELAY_TIME = 1000L + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/media/MediaPlayer.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/media/MediaPlayer.kt new file mode 100644 index 00000000..cca767df --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/media/MediaPlayer.kt @@ -0,0 +1,78 @@ +package eu.theappfactory.dailyrecipes.media + +import android.content.Context +import android.net.Uri +import com.google.android.exoplayer2.ExoPlayerFactory +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.source.LoopingMediaSource +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import eu.theappfactory.dailyrecipes.cache.VideoCacheManager +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaPlayer @Inject constructor( + private val context: Context, + private val videoCacheManager: VideoCacheManager +) { + + private var exoPlayer: SimpleExoPlayer? = null + + fun initializeExoplayer(): SimpleExoPlayer { + if (exoPlayer != null) { + resetPlayer() + } + val trackSelector = DefaultTrackSelector() + + trackSelector.setParameters( + DefaultTrackSelector.ParametersBuilder() + .setForceLowestBitrate(true) + .setMaxAudioBitrate(128_000) + ) + + exoPlayer = ExoPlayerFactory.newSimpleInstance(context, trackSelector) + + return exoPlayer!! + } + + fun prepare(uri: Uri) { + if (exoPlayer == null) { + return + } + val mediaSource = createMediaSource(uri) + val loopingSource = LoopingMediaSource(mediaSource) + + exoPlayer?.prepare(loopingSource) + exoPlayer?.volume = 1f + } + + fun resetPlayer() { + exoPlayer?.playWhenReady = false + exoPlayer?.stop() + exoPlayer?.release() + exoPlayer = null + } + + fun isPlaying() = exoPlayer?.playWhenReady ?: false + + fun play() { + exoPlayer?.playWhenReady = true + } + + fun pause() { + exoPlayer?.playWhenReady = false + } + + fun seekTo(position: Long) { + exoPlayer?.seekTo(position) + } + + fun getCurrentPosition() = exoPlayer?.currentPosition?.toInt() ?: 0 + + private fun createMediaSource(uri: Uri): MediaSource { + val mediaSource = videoCacheManager.getCacheMediaSource(uri.toString()) + return Objects.requireNonNull(mediaSource, "MediaSource cannot be null") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/AlreadySeenItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/AlreadySeenItem.kt new file mode 100644 index 00000000..c9e667b6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/AlreadySeenItem.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.model + +import com.squareup.moshi.Json +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.ALREADY_SEEN +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI + +data class AlreadySeenItem( + + @Json(name = "id") + var id: String = "", + + @Json(name = IMAGE_URI) + var imageUri: String = "", + + @Json(name = ALREADY_SEEN) + var alreadySeen: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ChatsResponse.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ChatsResponse.kt new file mode 100644 index 00000000..965fd09f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ChatsResponse.kt @@ -0,0 +1,31 @@ +package eu.theappfactory.dailyrecipes.model + +import android.os.Parcelable +import com.squareup.moshi.Json +import kotlinx.android.parcel.Parcelize + +data class ChatsResponse( + @Json(name = "chats") + val chats: List = emptyList(), +) + +@Parcelize +data class ChatItem( + @Json(name = "user") + val user: String = "", + + @Json(name = "date") + val date: String = "", + + @Json(name = "persons") + val persons: Int = 0, + + @Json(name = "message") + val message: String = "", + + @Json(name = "ingredients") + val ingredients: List = emptyList(), + + @Json(name = "preparations") + val preparations: List = emptyList() +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/Contact.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/Contact.kt new file mode 100644 index 00000000..0c8b7838 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/Contact.kt @@ -0,0 +1,12 @@ +package eu.theappfactory.dailyrecipes.model + +data class Contact( + var displayName: String = "", + var phoneNumber: String = "", + var isEven: Boolean = false +) { + companion object { + const val DISPLAY_NAME = "displayName" + const val PHONE_NUMBER = "phoneNumber" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/FavoriteItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/FavoriteItem.kt new file mode 100644 index 00000000..84616664 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/FavoriteItem.kt @@ -0,0 +1,26 @@ +package eu.theappfactory.dailyrecipes.model + +import com.google.firebase.Timestamp +import com.squareup.moshi.Json +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DATE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IMAGE_URI +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.IS_FAVORITE +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TITLE + +data class FavoriteItem( + + @Json(name = "id") + var id: String = "", + + @Json(name = IMAGE_URI) + var imageUri: String = "", + + @Json(name = IS_FAVORITE) + var isFavorite: Boolean = false, + + @Json(name = DATE) + var date: Timestamp, + + @Json(name = TITLE) + var title: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/IngredientItemData.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/IngredientItemData.kt new file mode 100644 index 00000000..af19d718 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/IngredientItemData.kt @@ -0,0 +1,34 @@ +package eu.theappfactory.dailyrecipes.model + +import com.google.firebase.firestore.DocumentSnapshot +import eu.theappfactory.dailyrecipes.ui.ingredients.IngredientItem + +private const val KEY_IMAGE = "imageUri" +private const val KEY_TITLE = "title" +private const val KEY_SUPERMARKET = "supermarket" +private const val KEY_URL = "url" + +data class IngredientItemData( + val name: String, + val imageUrl: String, + val supermarket: String, + val url: String, +) + +fun IngredientItemData.toAddIngredientItem(checked: Boolean, searchText: String) = + IngredientItem(name, imageUrl, supermarket, url, checked, searchText) + +fun IngredientItemData.toIngredientItem() = + IngredientItem(name, imageUrl, supermarket, url, checked = true) + +fun IngredientItemData.toMap() = mapOf( + KEY_IMAGE to imageUrl, + KEY_TITLE to name, + KEY_SUPERMARKET to supermarket, + KEY_URL to url +) + +fun DocumentSnapshot.toIngredientDataItem() = IngredientItemData( + this[KEY_TITLE] as String, this[KEY_IMAGE] as String, + this[KEY_SUPERMARKET] as String, this[KEY_URL] as String +) diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/InviteCode.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/InviteCode.kt new file mode 100644 index 00000000..d332dd71 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/InviteCode.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.model + +import com.squareup.moshi.Json + +data class InviteCode( + + @Json(name = "id") + var id: String = "", + + @Json(name = CODE) + var code: Long = 0, + + @Json(name = HAS_BEEN_USED) + var hasBeenUsed: Boolean = false, + + @Json(name = USER) + var user: String = "" + +) { + companion object { + const val CODE = "code" + const val HAS_BEEN_USED = "hasBeenUsed" + const val USER = "user" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemData.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemData.kt new file mode 100644 index 00000000..149af7ae --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemData.kt @@ -0,0 +1,41 @@ +package eu.theappfactory.dailyrecipes.model + +import androidx.annotation.Keep +import com.squareup.moshi.Json +import eu.theappfactory.dailyrecipes.domain.preferences.FoodCategory + +data class ItemData( + @Keep + @Json(name = "items") + var items: MutableList, + + var itemType: ItemType = ItemType.DEFAULT +) + +/* + Filter items by food type. + Return items that: + 1. Have empty food type + 2. No categories selected won't filter out any item + 3. If there are no subcategories selected for specific category, then all subcategories results are aggregated +*/ +fun List.filterByFoodType(foodCategories: Set) = + filter { itemResponse -> + itemResponse.foodType.isEmpty() + || foodCategories.let { userCategories -> + userCategories.isEmpty() || userCategories.contains( + itemResponse.foodType + ) || FoodCategory.values() + .firstOrNull { itemResponse.foodType.split("-").firstOrNull() == it.foodType } + ?.let { category -> + category.foodSubCategories.isNotEmpty() && + category.foodSubCategories.map { subCategory -> "${category.foodType}-${subCategory.foodType}" } + .intersect(userCategories).isEmpty() + } ?: false + } + }.toMutableList() + +enum class ItemType { + DEFAULT, + FAVORITE +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemResponse.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemResponse.kt new file mode 100644 index 00000000..cca37446 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ItemResponse.kt @@ -0,0 +1,118 @@ +package eu.theappfactory.dailyrecipes.model + +import android.net.Uri +import com.google.firebase.Timestamp +import com.squareup.moshi.Json +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse + +typealias UniqueId = String + +data class ItemResponse( + + @Json(name = "id") + var id: String = "", + + @Json(name = DATE) + var date: Timestamp, + + @Json(name = TITLE) + var title: String = "", + + @Json(name = URI_STR) + var uriStr: String = "", + + @Json(name = IMAGE_URI) + var imageUri: UniqueId = "", + + @Json(name = INGREDIENTS) + var ingredients: String? = null, + + @Transient + var chats: List? = null, + + @Json(name = DIRECT_LINK) + var directLink: String? = null, + + @Json(name = LIKES) + var likes: Long? = null, + + @Json(name = SOCIAL_NETWORK_HANDLE) + var socialNetworkHandle: String? = null, + + @Json(name = STORE_LINK) + var storeLink: String? = null, + + var ingredientsResponse: IngredientsResponse? = null, + + @Json(name = IS_FAVORITE) + var isFavorite: Boolean = false, + + @Json(name = ALREADY_SEEN) + var alReadySeen: Boolean = false, + + @Json(name = FOOD_TYPE) + var foodType: String = "", + + @Json(name = IS_HIDDEN) + var isHidden: String = "", + + @Json(name = TRACK_NAME) + var trackName: String = "", + + @Json(name = TRACK_URL) + var trackUrl: String = "", + + @Json(name = USERNAME) + var username: String = "", + + @Json(name= SHOW_THUMBNAIL) + var showThumbnail : Boolean = false + + ) { + // no-arg constructor needed for firebase + constructor() : this( + id = "", + date = Timestamp.now(), + title = "", + isFavorite = false, + alReadySeen = false, + uriStr = "", + imageUri = "", + isHidden = "" + ) + + val uri: Uri? + get() = Uri.parse(uriStr) + + companion object { + const val TITLE = "title" + const val DATE = "date" + const val IS_FAVORITE = "isFavorite" + const val ALREADY_SEEN = "alreadySeen" + const val URI_STR = "uriStr" + const val IMAGE_URI = "imageUri" + const val INGREDIENTS = "ingredients" + const val CHATS = "chats" + const val DIRECT_LINK = "directLink" + const val LIKES = "likes" + const val SOCIAL_NETWORK_HANDLE = "socialNetworkHandle" + const val STORE_LINK = "storeLink" + const val FOOD_TYPE = "foodType" + const val IS_HIDDEN = "isHidden" + const val TRACK_NAME = "trackName" + const val TRACK_URL = "trackUrl" + const val USERNAME = "username" + const val SHOW_THUMBNAIL = "showThumbnail" + + + // Marketing links + const val HELLOFRESH = "https://www.hellofresh.nl/plans/" + const val DR_OETKER = "https://www.oetker.nl/" + + // Social Media links + const val INSTAGRAM_URL = "www.instagram.com" + const val TIKTOK_URL = "www.tiktok.com" + + val FIRST_TIME_VIDEO_IDS = listOf("123456789", "987654321") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/UserResponse.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/UserResponse.kt new file mode 100644 index 00000000..dded4db6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/UserResponse.kt @@ -0,0 +1,56 @@ +package eu.theappfactory.dailyrecipes.model + +import android.net.Uri +import com.squareup.moshi.Json + +data class UserResponse( + + // Firebase user + @Json(name = "id") + var id: String = "", + + // username & id is in both databases (username is displayName in firebase db). + @Json(name = USERNAME) + var username: String = "", + + @Json(name = FIRST_NAME) + var firstName: String = "", + + @Json(name = LAST_NAME) + var lastName: String = "", + + @Json(name = PHONE_NUMBER) + var phoneNumber: String = "", + + @Json(name = PHOTO_URI) + var photoUri: String? = null, + + @Json(name = PHOTO) + var photo: Uri? = null, + + @Json(name = EMAIL_SUBSCRIBE) + var emailSubscribe: Boolean = true, + + @Json(name = EMAIL_ADDRESS) + var emailAddress: String = "", + + @Json(name = FOLLOWERS) + var followers: ArrayList = arrayListOf(), + + @Json(name = FOLLOWING) + var following: ArrayList = arrayListOf() + +) { + companion object { + const val USERNAME = "username" + const val EMAIL_SUBSCRIBE = "emailSubscribe" + const val EMAIL_ADDRESS = "emailAddress" + const val PHOTO_URI = "photoUri" + const val FOLLOWERS = "followers" + const val FOLLOWING = "following" + const val FIRST_NAME = "firstName" + const val LAST_NAME = "lastName" + const val PHONE_NUMBER = "phoneNumber" + const val PHOTO = "photo" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhIngredientRequest.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhIngredientRequest.kt new file mode 100644 index 00000000..e9c4a5b3 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhIngredientRequest.kt @@ -0,0 +1,11 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class AhIngredientRequest( + + @Expose + @SerializedName("items") + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItem.kt new file mode 100644 index 00000000..a91cec3c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItem.kt @@ -0,0 +1,12 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.SerializedName + +data class AhItem( + + @SerializedName("id") + var id: Long = 0L, + + @SerializedName("quantity") + var quantity: Long = 1L +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItemRequest.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItemRequest.kt new file mode 100644 index 00000000..b1d7d764 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhItemRequest.kt @@ -0,0 +1,11 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class AhItemRequest( + + @SerializedName("url") + @Expose + var url: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhProductResponse.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhProductResponse.kt new file mode 100644 index 00000000..f62ed144 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/AhProductResponse.kt @@ -0,0 +1,27 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.SerializedName + +data class AhProductResponse( + @SerializedName("card") + var card: Card +) + +data class Card( + @SerializedName("products") + var products: List +) + +data class Product( + @SerializedName("title") + var title: String = "", + @SerializedName("link") + var link: String = "", + @SerializedName("images") + var images: List +) + +data class Image( + @SerializedName("url") + var url: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/IngredientsResponse.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/IngredientsResponse.kt new file mode 100644 index 00000000..790399e6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/IngredientsResponse.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class IngredientsResponse( + @Expose + @SerializedName("supermarket") + var supermarket: String?, + + @Expose + @SerializedName("ingredients") + var ingredients: List?, + + var ingredientsStr: String? = null, + + @Expose + @SerializedName("directLink") + var directLink: String? +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/JumboIngredientRequest.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/JumboIngredientRequest.kt new file mode 100644 index 00000000..ed2fdc7f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/model/ah/JumboIngredientRequest.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.model.ah + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class JumboIngredientRequest ( + + @Expose + @SerializedName("sku") + val sku: String, + + @Expose + @SerializedName("quantity") + val quantity: Int, + + @Expose + @SerializedName("unit") + val unit: String +) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/networking/AHApi.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/AHApi.kt new file mode 100644 index 00000000..eafb49c5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/AHApi.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.networking + +import eu.theappfactory.dailyrecipes.model.ah.AhIngredientRequest +import eu.theappfactory.dailyrecipes.model.ah.AhProductResponse +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.http.* + +interface AHApi { + + @Headers( + "content-type: application/json; charset=utf-8", + "accept-encoding: gzip, deflate, br" + ) + @POST("common/api/basket/v2/add") + fun insertIngredient( + @Header("cookie") cookie: String, + @Body ingredients: AhIngredientRequest + ): Call + + @Headers( + "content-type: application/json; charset=utf-8", + "accept-encoding: gzip, deflate, br" + ) + @GET("mijn/dashboard") + fun login( + @Header("cookie") cookie: String + ): Call + + @GET("zoeken/api/products/product") + fun getProduct( + @Header("cookie") cookie: String, + @Query("webshopId") webshopId: Long + ): Call +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/networking/JumboApi.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/JumboApi.kt new file mode 100644 index 00000000..98557939 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/JumboApi.kt @@ -0,0 +1,22 @@ +package eu.theappfactory.dailyrecipes.networking + +import eu.theappfactory.dailyrecipes.model.ah.JumboIngredientRequest +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST + +interface JumboApi { + + @Headers( + "content-type: application/json; charset=utf-8", + "accept-encoding: gzip, deflate, br" + ) + @POST("/api/basket/current/items") + fun insertIngredient( + @Header("cookie") cookie: String, + @Body ingredients: JumboIngredientRequest + ): Call +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/networking/NetworkModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/NetworkModule.kt new file mode 100644 index 00000000..62618c6f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/NetworkModule.kt @@ -0,0 +1,77 @@ +package eu.theappfactory.dailyrecipes.networking + +import android.content.Context +import android.webkit.CookieManager +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.R +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +class NetworkModule { + + private fun getRetrofitClient(url: String): Retrofit { + val timeout = 30L + val client: OkHttpClient = OkHttpClient.Builder() + .readTimeout(timeout, TimeUnit.SECONDS) + .writeTimeout(timeout, TimeUnit.SECONDS) + .connectTimeout(timeout, TimeUnit.SECONDS) + .cookieJar(getCookieJar()) + .followRedirects(false) + .build() + + return Retrofit.Builder() + .baseUrl(url) + .addConverterFactory(MoshiConverterFactory.create().asLenient()) + .client(client) + .build() + } + + @Provides + @Singleton + fun provideAHApi(context: Context): AHApi { + return getRetrofitClient(context.getString(R.string.ah_api)).create(AHApi::class.java) + } + + @Provides + @Singleton + fun provideJumboApi(context: Context): JumboApi { + return getRetrofitClient(context.getString(R.string.jumbo_api)).create(JumboApi::class.java) + } + + private fun getCookieJar(): CookieJar { + return object : CookieJar { + override fun loadForRequest(url: HttpUrl): List { + val cookieManager = CookieManager.getInstance() + val cookies: ArrayList = ArrayList() + + if (cookieManager.getCookie(url.toString()) != null) { + val splitCookies = + cookieManager.getCookie(url.toString()).split("[,;]".toRegex()) + .dropLastWhile { it.isEmpty() }.toTypedArray() + for (i in splitCookies.indices) { + cookies.add(Cookie.parse(url, splitCookies[i].trim { it <= ' ' })!!) + Timber.e("loadForRequest :Cookie.add :: " + Cookie.parse(url, splitCookies[i].trim { it <= ' ' })) + } + } + return cookies + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val cookieManager = CookieManager.getInstance() + for (cookie in cookies) { + cookieManager.setCookie(url.toString(), cookie.toString()) + Timber.e("saveFromResponse : Cookie url : " + url.toString() + cookie.toString()) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/Result.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/Result.kt similarity index 89% rename from app/src/main/java/eu/theappfactory/sampleapp/util/Result.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/networking/Result.kt index 55c597c0..802928f1 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/Result.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/networking/Result.kt @@ -1,7 +1,7 @@ -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.networking import androidx.lifecycle.MutableLiveData -import eu.theappfactory.sampleapp.util.Result.Success +import eu.theappfactory.dailyrecipes.networking.Result.Success /** * A generic class that holds a value with its loading status. diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/persistence/PreferenceStorage.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/persistence/PreferenceStorage.kt new file mode 100644 index 00000000..18b48c5e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/persistence/PreferenceStorage.kt @@ -0,0 +1,175 @@ +package eu.theappfactory.dailyrecipes.persistence + +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.annotation.WorkerThread +import androidx.core.content.edit +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.CODE_HAS_BEEN_SUCCESSFULLY_USED +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.FIRST_TIME_VIDEOS_ARE_SHOWN +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.LAST_KNOWN_COOKIE +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREFS_NAME +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_DARK_MODE_ENABLED +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_FIRST_TIME_SAVE +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_FIRST_TIME_USE +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_FOOD_CATEGORIES +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_ONBOARDING +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.PREF_SNACKBAR_IS_STOPPED +import eu.theappfactory.dailyrecipes.persistence.BooleanPreference.Companion.USER_HAS_ADDED_FRIENDS +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Storage for app and user preferences. + */ +interface PreferenceStorage { + var firstTimeUsingApp: Boolean + var registerCompleted: Boolean + var foodCategories: Set + var selectedTheme: String? + var snackbarIsStopped: Boolean + var offlineVideosHaveBeenShown: Boolean + var lastKnownCookie: String? + var observableSelectedTheme: Flow + var codeHasBeenSuccesfullyUsed: Boolean + var userHasAddedFriends: Boolean + var firstTimeSavingItem: Boolean + fun clearLoggedInSession() +} + +/** + * [PreferenceStorage] impl backed by [android.content.SharedPreferences]. + */ +@Singleton +@ExperimentalCoroutinesApi +@FlowPreview +class SharedPreferenceStorage @Inject constructor(context: Context) : PreferenceStorage { + + private val selectedThemeChannel: ConflatedBroadcastChannel by lazy { + ConflatedBroadcastChannel().also { channel -> + channel.offer(selectedTheme) + } + } + + private val prefs: Lazy = lazy { + // Lazy to prevent IO access to main thread. + context.applicationContext.getSharedPreferences( + PREFS_NAME, Context.MODE_PRIVATE + ).apply { + registerOnSharedPreferenceChangeListener(changeListener) + } + } + + private val changeListener = OnSharedPreferenceChangeListener { _, key -> + when (key) { + PREF_DARK_MODE_ENABLED -> selectedThemeChannel.offer(selectedTheme) + } + } + + override var firstTimeUsingApp by BooleanPreference(prefs, PREF_FIRST_TIME_USE, true) + + override var registerCompleted by BooleanPreference(prefs, PREF_ONBOARDING, false) + + override var foodCategories by StringSetPreference(prefs, PREF_FOOD_CATEGORIES, setOf()) + + override var selectedTheme by StringPreference(prefs, PREF_DARK_MODE_ENABLED, null) + + override var snackbarIsStopped by BooleanPreference(prefs, PREF_SNACKBAR_IS_STOPPED, false) + + override var offlineVideosHaveBeenShown by BooleanPreference( + prefs, + FIRST_TIME_VIDEOS_ARE_SHOWN, + false + ) + + override var lastKnownCookie by StringPreference(prefs, LAST_KNOWN_COOKIE, "") + + override var observableSelectedTheme: Flow + get() = selectedThemeChannel.asFlow() + set(_) = throw IllegalAccessException("This property can't be changed") + + override var codeHasBeenSuccesfullyUsed by BooleanPreference( + prefs, + CODE_HAS_BEEN_SUCCESSFULLY_USED, + false + ) + + override var userHasAddedFriends by BooleanPreference(prefs, USER_HAS_ADDED_FRIENDS, false) + + override var firstTimeSavingItem by BooleanPreference(prefs, PREF_FIRST_TIME_SAVE, true) + + override fun clearLoggedInSession() { + prefs.value.edit()?.clear()?.apply() + } +} + +class BooleanPreference( + private val preferences: Lazy, + private val name: String, + private val defaultValue: Boolean +) : ReadWriteProperty { + + @WorkerThread + override fun getValue(thisRef: Any, property: KProperty<*>): Boolean { + return preferences.value.getBoolean(name, defaultValue) + } + + override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) { + preferences.value.edit { putBoolean(name, value) } + } + + companion object { + const val PREFS_NAME = "dailyrecipesapp" + const val PREF_FIRST_TIME_USE = "pref_first_time_use" + const val PREF_ONBOARDING = "pref_onboarding" + const val PREF_FOOD_CATEGORIES = "pref_food_categories" + const val PREF_DARK_MODE_ENABLED = "pref_dark_mode" + const val PREF_SNACKBAR_IS_STOPPED = "pref_snackbar_is_stopped" + const val FIRST_TIME_VIDEOS_ARE_SHOWN = "first_time_videos_are_shown" + const val LAST_KNOWN_COOKIE = "last_known_cookie" + const val CODE_HAS_BEEN_SUCCESSFULLY_USED = "code_has_been_successfully_used" + const val USER_HAS_ADDED_FRIENDS = "user_has_added_friends" + const val PREF_FIRST_TIME_SAVE = "pref_first_time_save" + const val PREF_USER_MOBILE_NUMBER = "pref_user_mobile_number" + const val PREF_FIRST_AND_LAST_NAME = "pref_first_and_last_name" + } +} + +class StringPreference( + private val preferences: Lazy, + private val name: String, + private val defaultValue: String? +) : ReadWriteProperty { + + @WorkerThread + override fun getValue(thisRef: Any, property: KProperty<*>): String? { + return preferences.value.getString(name, defaultValue) + } + + override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) { + preferences.value.edit { putString(name, value) } + } +} + +class StringSetPreference( + private val preferences: Lazy, + private val name: String, + private val defaultValue: Set +) : ReadWriteProperty> { + + @WorkerThread + override fun getValue(thisRef: Any, property: KProperty<*>): Set { + return preferences.value.getStringSet(name, defaultValue).orEmpty() + } + + override fun setValue(thisRef: Any, property: KProperty<*>, value: Set) { + preferences.value.edit { putStringSet(name, value) } + } +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushMessageService.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushMessageService.kt new file mode 100644 index 00000000..a191c640 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushMessageService.kt @@ -0,0 +1,43 @@ +package eu.theappfactory.dailyrecipes.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.android.AndroidInjection +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import kotlinx.coroutines.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class PushMessageService : FirebaseMessagingService() { + + @Inject + lateinit var preferenceStorage: PreferenceStorage + + @Inject + lateinit var itemRepository: ItemRepository + + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onCreate() { + AndroidInjection.inject(this) + super.onCreate() + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + scope.launch(Dispatchers.IO) { + val uri = message.notification?.imageUrl?.toString().orEmpty() + val result = itemRepository.getItemForImageUri(uri) + PushNotificationHandler(applicationContext).showNotification(message, result.data, preferenceStorage.foodCategories) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + //TODO + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushNotificationHandler.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushNotificationHandler.kt new file mode 100644 index 00000000..b40c1df5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/push/PushNotificationHandler.kt @@ -0,0 +1,108 @@ +package eu.theappfactory.dailyrecipes.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import androidx.core.app.NotificationCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.google.firebase.messaging.RemoteMessage +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.ui.launch.LauncherActivity +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.util.DeviceUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class PushNotificationHandler(val context: Context) { + + fun showNotification(message: RemoteMessage, item: ItemResponse?, categories: Set) { + if (item == null || !categories.contains(item.foodType) || item.foodType == "") { + // Only show notification when: + // user has chosen this foodCategory. + // the item has a foodType. + return + } + + val msg = message.notification?.body ?: context.getString(R.string.pn_message) + + var imageUrl = message.notification?.imageUrl?.toString().orEmpty() + // The optional Notification image has not been filled in + // Take the imageUri key instead. + if (imageUrl.isEmpty()) { + imageUrl = message.data[NOTIFICATION_EXTRA_URI].toString() + } + + // Make sure the imageUri has the domain appended + if (imageUrl.contains(MainActivity.IMAGE_URI_DOMAIN).not()) { + imageUrl = MainActivity.IMAGE_URI_DOMAIN + imageUrl + } + + val notificationImage = getNotificationImage(imageUrl) + + val intent = Intent(context, LauncherActivity::class.java) + intent.putExtra(NOTIFICATION_EXTRA_URI, imageUrl) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + + val pendingIntent = + PendingIntent.getActivity(context, REQUEST_CODE, intent, PendingIntent.FLAG_ONE_SHOT) + + val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_received) + .setContentTitle(message.notification?.title.orEmpty()) + .setContentText(msg) + .setLargeIcon(notificationImage) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + + val style = notificationImage?.let { + NotificationCompat.BigPictureStyle() + .setBigContentTitle(message.notification?.title.orEmpty()) + .bigPicture(notificationImage) + .setSummaryText(msg) + } ?: NotificationCompat.InboxStyle() + .setBigContentTitle(message.notification?.title.orEmpty()) + .setSummaryText(msg) + + notificationBuilder.setStyle(style) + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(CHANNEL_ID, context.getString(R.string.app_name), importance) + notificationManager.createNotificationChannel(channel) + } + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + notifyUser() + } + + private fun getNotificationImage(imageUrl: String): Bitmap? { + return try { + Glide.with(context).asBitmap() + .apply(RequestOptions().error(R.drawable.incoming_push_message)) + .load(imageUrl).submit().get() + } catch (e: Exception) { + null + } + } + + private fun notifyUser() { + DeviceUtils().vibrate(context) + } + + companion object { + + const val REQUEST_CODE = 12 + const val CHANNEL_ID = "channel_id_42" + const val NOTIFICATION_ID = 8 + const val NOTIFICATION_EXTRA_URI = "imageUri" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/push/di/PushModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/push/di/PushModule.kt new file mode 100644 index 00000000..66f076a2 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/push/di/PushModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.push.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.push.PushMessageService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface PushModule { + + @ContributesAndroidInjector + fun bindPushMessageService(): PushMessageService +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/sensor/ShakeDetector.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/sensor/ShakeDetector.kt new file mode 100644 index 00000000..981fa8b5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/sensor/ShakeDetector.kt @@ -0,0 +1,72 @@ +package eu.theappfactory.dailyrecipes.sensor + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import kotlin.math.sqrt + +class ShakeDetector : SensorEventListener { + + private var listener: OnShakeListener? = null + private var shakeTimestamp: Long = 0 + private var shakeCount = 0 + + fun setOnShakeListener(listener: OnShakeListener) { + this.listener = listener + } + + interface OnShakeListener { + fun onShake(count: Int) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + // ignore + } + + override fun onSensorChanged(event: SensorEvent) { + if (listener != null) { + val x = event.values[0] + val y = event.values[1] + val z = event.values[2] + val gX = x / SensorManager.GRAVITY_EARTH + val gY = y / SensorManager.GRAVITY_EARTH + val gZ = z / SensorManager.GRAVITY_EARTH + + // gForce will be close to 1 when there is no movement. + val gForce = sqrt(gX * gX + gY * gY + (gZ * gZ).toDouble()).toFloat() + if (gForce > SHAKE_THRESHOLD_GRAVITY) { + val now = System.currentTimeMillis() + + // ignore shake events too close to each other (500ms) + if (shakeTimestamp + SHAKE_SLOP_TIME_MS > now) { + return + } + + // reset the shake count after 3 seconds of no shakes + if (shakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) { + shakeCount = 0 + } + + shakeTimestamp = now + shakeCount++ + listener?.onShake(shakeCount) + } + } + } + + companion object { + + /* + * The gForce that is necessary to register as shake. + * Must be greater than 1G (one earth gravity unit). + * You can install "G-Force", by Blake La Pierre + * from the Google Play Store and run it to see how + * many G's it takes to register a shake + */ + private const val SHAKE_THRESHOLD_GRAVITY = 7.7f + + private const val SHAKE_SLOP_TIME_MS = 500 + private const val SHAKE_COUNT_RESET_TIME_MS = 3000 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseActivity.kt new file mode 100644 index 00000000..16c9dc90 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseActivity.kt @@ -0,0 +1,27 @@ +package eu.theappfactory.dailyrecipes.ui + +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import dagger.android.support.DaggerAppCompatActivity +import eu.theappfactory.dailyrecipes.R + +abstract class BaseActivity : DaggerAppCompatActivity() { + + protected fun hideToolbar() { + supportActionBar?.hide() + actionBar?.hide() + } + + protected fun showFragment(fragment: Fragment) { + supportFragmentManager + .beginTransaction() + .replace(R.id.container, fragment) + .commitAllowingStateLoss() + } + + fun setToolbarBackButton(toolbar : Toolbar){ + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + toolbar.setNavigationOnClickListener { onBackPressed() } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseFragment.kt new file mode 100644 index 00000000..234b2d41 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/BaseFragment.kt @@ -0,0 +1,13 @@ +package eu.theappfactory.dailyrecipes.ui + +import androidx.appcompat.widget.Toolbar +import dagger.android.support.DaggerFragment + +abstract class BaseFragment : DaggerFragment() { + + fun setToolbarBackButton(toolbar: Toolbar) { + if (activity is BaseActivity) { + (activity as BaseActivity).setToolbarBackButton(toolbar) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/GridItemDecoration.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/GridItemDecoration.kt new file mode 100644 index 00000000..b48eff7a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/GridItemDecoration.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.ui + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class GridItemDecoration(private val space: Int) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + outRect.set(space, space, space, space) + } +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/PopupDialog.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/PopupDialog.kt new file mode 100644 index 00000000..6451ce6f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/PopupDialog.kt @@ -0,0 +1,65 @@ +package eu.theappfactory.dailyrecipes.ui + +import android.app.Activity +import android.content.DialogInterface +import androidx.annotation.UiThread +import androidx.appcompat.app.AlertDialog +import eu.theappfactory.dailyrecipes.R + +class PopupDialog(val activity: Activity) { + + interface ButtonCallback { + fun onButtonClicked(button: Button) + } + + @UiThread + fun show(title: String, message: String, callback: ButtonCallback?) { + val builder = AlertDialog.Builder(activity, R.style.AlertDialogStyle) + builder.setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + callback?.onButtonClicked(Button.POSITIVE_BTN) + } + .setCancelable(true) + + val dialog = builder.create() + dialog.show() + } + + @UiThread + fun showWithNoYes(title: String, message: String, callback: ButtonCallback?) { + val builder = getAlertDialogBuilder(title, message, callback).setCancelable(true) + val dialog = builder.create() + dialog.show() + } + + @UiThread + fun showWithNoYesNotCancellable(title: String, message: String, callback: ButtonCallback?) { + val builder = getAlertDialogBuilder(title, message, callback).setCancelable(false) + val dialog = builder.create() + dialog.show() + } + + private fun getAlertDialogBuilder(title: String, message: String, callback: ButtonCallback?): AlertDialog.Builder { + return AlertDialog.Builder(activity, R.style.AlertDialogStyle) + .setTitle(title) + .setMessage(message) + .setPositiveButton("Ja") { dialog: DialogInterface, _: Int -> + dialog.dismiss() + callback?.onButtonClicked(Button.POSITIVE_BTN) + } + .setNegativeButton("Nee") { dialog: DialogInterface, _: Int -> + dialog.dismiss() + callback?.onButtonClicked(Button.NEGATIVE_BTN) + } + } + + companion object { + + enum class Button { + POSITIVE_BTN, + NEGATIVE_BTN + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessage.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessage.kt similarity index 91% rename from app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessage.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessage.kt index 0c2c2f4a..0d146d28 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessage.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessage.kt @@ -1,4 +1,4 @@ -package eu.theappfactory.sampleapp.ui +package eu.theappfactory.dailyrecipes.ui data class SnackbarMessage( /** Resource string ID of the message to show */ diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageFragmentExtensions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageFragmentExtensions.kt new file mode 100644 index 00000000..5eb32eb4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageFragmentExtensions.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.theappfactory.dailyrecipes.ui + +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.EventObserver +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar + +/** + * An extension for Fragments that sets up a Snackbar with a [SnackbarMessageManager]. + */ +fun Fragment.setUpSnackbar( + snackbarMessage: LiveData>, + fadingSnackbar: FadingSnackbar, + snackbarMessageManager: SnackbarMessageManager, + actionClickListener: () -> Unit = {} +) { + // Show messages generated by the ViewModel + snackbarMessage.observe(viewLifecycleOwner, EventObserver { message: SnackbarMessage -> + fadingSnackbar.show( + messageId = message.messageId, + actionId = message.actionId, + longDuration = message.longDuration, + actionClick = { + actionClickListener() + fadingSnackbar.dismiss() + } + ) + }) + + // Important reservations messages are handled with a message manager + snackbarMessageManager.observeNextMessage().observe( + viewLifecycleOwner, + EventObserver { message -> + val messageText = HtmlCompat.fromHtml( + requireContext().getString(message.messageId), + FROM_HTML_MODE_LEGACY + ) + fadingSnackbar.show( + messageText = messageText, + actionId = message.actionId, + longDuration = message.longDuration, + actionClick = { + actionClickListener() + fadingSnackbar.dismiss() + }, + // When the snackbar is dismissed, ping the snackbar message manager in case there + // are pending messages. + dismissListener = { snackbarMessageManager.loadNextMessage() } + ) + } + ) +} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessageManager.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageManager.kt similarity index 91% rename from app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessageManager.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageManager.kt index b66064ed..e36d82db 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/SnackbarMessageManager.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/SnackbarMessageManager.kt @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package eu.theappfactory.sampleapp.ui +package eu.theappfactory.dailyrecipes.ui import androidx.annotation.VisibleForTesting import androidx.lifecycle.MutableLiveData -import eu.theappfactory.sampleapp.R -import eu.theappfactory.sampleapp.persistence.PreferenceStorage -import eu.theappfactory.sampleapp.ui.SnackbarMessageManager.Companion.MAX_ITEMS -import eu.theappfactory.sampleapp.util.Event +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager.Companion.MAX_ITEMS +import eu.theappfactory.dailyrecipes.util.Event import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/BaseCategoriesViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/BaseCategoriesViewHolder.kt new file mode 100644 index 00000000..f4a82754 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/BaseCategoriesViewHolder.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.domain.preferences.items.BaseCategoryItem + +abstract class BaseCategoriesViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + abstract fun bind(item: BaseCategoryItem) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesActivity.kt new file mode 100644 index 00000000..6067433a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesActivity.kt @@ -0,0 +1,41 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.NavGraph +import androidx.navigation.fragment.NavHostFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.settings.SettingsFragment.Companion.KEY_IS_FROM_SETTINGS +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class CategoriesActivity : BaseActivity() { + + private lateinit var navController: NavController + private lateinit var navGraph: NavGraph + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_preferences) + + hideToolbar() + + val navHostFragment = + supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val graphInflater = navHostFragment.navController.navInflater + navGraph = graphInflater.inflate(R.navigation.preferences_navigation) + navController = navHostFragment.navController + val extras = intent.extras + if (extras != null) { + val bundle = Bundle().apply { + putBoolean(KEY_IS_FROM_SETTINGS, extras.getBoolean(KEY_IS_FROM_SETTINGS)) + } + navGraph.startDestination = R.id.fragment_preferences + navController.setGraph(navGraph, bundle) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesAdapter.kt new file mode 100644 index 00000000..55780257 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesAdapter.kt @@ -0,0 +1,43 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemFoodTypeBinding +import eu.theappfactory.dailyrecipes.databinding.ListItemFoodTypeHeaderBinding +import eu.theappfactory.dailyrecipes.domain.preferences.items.BaseCategoryItem + +class CategoriesAdapter(private val listener: FoodCategoryItemListener) : + RecyclerView.Adapter() { + + private val items = mutableListOf() + + fun setItems(items: List) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + BaseCategoryItem.ITEM_CATEGORY -> CategoriesViewHolder( + ListItemFoodTypeBinding.inflate(LayoutInflater.from(parent.context), parent, false), + listener + ) + BaseCategoryItem.ITEM_HEADER -> CategoriesHeaderViewHolder( + ListItemFoodTypeHeaderBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + else -> throw IllegalStateException("$viewType not supported.") + } + + override fun onBindViewHolder(holder: BaseCategoriesViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + override fun getItemViewType(position: Int) = items[position].getType() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesFragment.kt new file mode 100644 index 00000000..d260a8fa --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesFragment.kt @@ -0,0 +1,108 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentPreferencesBinding +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.ui.setUpSnackbar +import eu.theappfactory.dailyrecipes.ui.settings.SettingsFragment.Companion.KEY_IS_FROM_SETTINGS +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.hide +import eu.theappfactory.dailyrecipes.util.showWithAnimation +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class CategoriesFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + private lateinit var snackbar: FadingSnackbar + + private lateinit var binding: FragmentPreferencesBinding + + private lateinit var viewModel: CategoriesViewModel + + private lateinit var adapter: CategoriesAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentPreferencesBinding.inflate( + inflater, + container, + false + ).apply { + lifecycleOwner = viewLifecycleOwner + } + snackbar = binding.snackbar + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel = ViewModelProvider(this, viewModelFactory).get(CategoriesViewModel::class.java) + + adapter = CategoriesAdapter(viewModel) + binding.recyclerView.adapter = adapter + + binding.viewModel = viewModel + viewModel.isFromSettings = arguments?.getBoolean(KEY_IS_FROM_SETTINGS, false) ?: false + // Snackbar configuration + setUpSnackbar(viewModel.errorLiveData, snackbar, snackbarMessageManager) + + viewModel.loadingLiveData.observe(viewLifecycleOwner, { + if (it) { + showLoading() + } else { + hideLoading() + } + }) + + viewModel.typesUpdateLiveData.observe(viewLifecycleOwner, { + if (viewModel.isFromSettings) { + requireActivity().finish() + } else { + MainActivity.start(requireContext()) + requireActivity().finish() + } + }) + + viewModel.itemsLiveData.observe(viewLifecycleOwner, { + it.getContentIfNotHandled()?.let { + adapter.setItems(it) + } + }) + + viewModel.loadData() + } + + private fun showLoading() { + binding.loadingLayout.loadingHolder.setBackgroundColor( + ContextCompat.getColor( + requireContext(), + R.color.colorSecondary + ) + ) + binding.loadingLayout.showWithAnimation() + } + + private fun hideLoading() = binding.loadingLayout.hide() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesHeaderViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesHeaderViewHolder.kt new file mode 100644 index 00000000..6fd25e3b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesHeaderViewHolder.kt @@ -0,0 +1,13 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import eu.theappfactory.dailyrecipes.databinding.ListItemFoodTypeHeaderBinding +import eu.theappfactory.dailyrecipes.domain.preferences.items.BaseCategoryItem +import eu.theappfactory.dailyrecipes.domain.preferences.items.FoodCategoryHeaderItem + +class CategoriesHeaderViewHolder(private val binding: ListItemFoodTypeHeaderBinding) : + BaseCategoriesViewHolder(binding.root) { + + override fun bind(item: BaseCategoryItem) { + binding.item = item as FoodCategoryHeaderItem + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewHolder.kt new file mode 100644 index 00000000..284372a1 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewHolder.kt @@ -0,0 +1,16 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import eu.theappfactory.dailyrecipes.databinding.ListItemFoodTypeBinding +import eu.theappfactory.dailyrecipes.domain.preferences.items.BaseCategoryItem +import eu.theappfactory.dailyrecipes.domain.preferences.items.FoodCategoryItem + +class CategoriesViewHolder( + private val binding: ListItemFoodTypeBinding, + private val listener: FoodCategoryItemListener +) : BaseCategoriesViewHolder(binding.root) { + + override fun bind(item: BaseCategoryItem) { + binding.item = item as FoodCategoryItem + binding.listener = listener + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewModel.kt new file mode 100644 index 00000000..3577deee --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/CategoriesViewModel.kt @@ -0,0 +1,214 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.domain.preferences.* +import eu.theappfactory.dailyrecipes.domain.preferences.items.BaseCategoryItem +import eu.theappfactory.dailyrecipes.domain.preferences.items.FoodCategoryHeaderItem +import eu.theappfactory.dailyrecipes.domain.preferences.items.FoodCategoryItem +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.PreferencesErrorConverter +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +class CategoriesViewModel @Inject constructor( + private val getUserFoodCategoriesUseCase: GetUserFoodCategoriesUseCase, + private val updateUserPreferencesUseCase: UpdateUserPreferencesUseCase, + private val userFoodCategoriesCompleteUseCase: UserFoodCategoriesCompleteUseCase, + private val signInViewModelDelegate: SignInViewModelDelegate +) : ViewModel(), FoodCategoryItemListener, + SignInViewModelDelegate by signInViewModelDelegate { + + private val foodCategories by lazy(LazyThreadSafetyMode.NONE) { + FoodCategory.values().map { it.foodType }.toMutableList() + } + private val _errorLiveData = MutableLiveData>() + val errorLiveData: LiveData> = _errorLiveData + + private val _loadingLiveData = MutableLiveData() + val loadingLiveData: LiveData = _loadingLiveData + + private val _typesUpdateLiveData = MutableLiveData>() + val typesUpdateLiveData: LiveData> = _typesUpdateLiveData + + private val _itemsLiveData = MutableLiveData>>() + val itemsLiveData: LiveData>> = _itemsLiveData + + var isFromSettings = false + + fun loadData() { + // to handle reinstall and logging back to the app! + viewModelScope.launch { + _loadingLiveData.postValue(true) + currentFirebaseUser.collect { + getUserId()?.let { + val result = getUserFoodCategoriesUseCase(it) + when (result.data) { + is Result.Success -> { + val currentCategories = result.data?.data.orEmpty() + userFoodCategoriesCompleteUseCase(currentCategories.toSet()) + if (currentCategories.isNotEmpty() && !isFromSettings) { + _typesUpdateLiveData.postValue(Event(Unit)) + } else { + _loadingLiveData.postValue(false) + if (!isFromSettings) { + _itemsLiveData.postValue(Event(getItems())) + } else { + updateFoodCategoriesList(currentCategories) + _itemsLiveData.postValue( + Event( + getFilteredItems( + currentCategories + ) + ) + ) + } + } + } + is Result.Error -> convertErrorMessage(result.data as Result.Error) + else -> Unit + } + } ?: run { + _loadingLiveData.postValue(false) + } + } + } + } + + private fun updateFoodCategoriesList(currentCategories: List) { + val newList = ArrayList() + currentCategories.map { receivedCategories -> + foodCategories.map { ourCategories -> + if (receivedCategories.contains(ourCategories)) { + if (!newList.contains(ourCategories)) + newList.add(ourCategories) + } + } + } + foodCategories.clear() + foodCategories.addAll(newList) + } + + private fun getFilteredItems(currentCategories: List): List { + return listOf(FoodCategoryHeaderItem(R.string.preferences_header_main)).plus( + listOf( + FoodCategory.MEAT, + FoodCategory.CHICKEN, + FoodCategory.FISH, + FoodCategory.VEGETARIAN, + FoodCategory.VEGAN + ).map { foodCat -> + val isAvailable = currentCategories.find { + it.contains(foodCat.foodType) + } + FoodCategoryItem(foodCat, isAvailable != null) + }) + .plusElement(FoodCategoryHeaderItem(R.string.preferences_header_other)) + .plus(listOf( + FoodCategory.DESSERTS_AND_PASTRIES, + FoodCategory.FINGER_FOOD, + FoodCategory.LIFE_HACKS + ) + .map { subCat -> + val isAvailable = currentCategories.find { + it.contains(subCat.foodType) + } + FoodCategoryItem(subCat, isAvailable != null) + }) + } + + fun updateFoodTypes() { + viewModelScope.launch { + _loadingLiveData.postValue(true) + currentFirebaseUser.collect { + getUserId()?.let { + val foodCategories = generateFoodCategories() + val result = updateUserPreferencesUseCase( + UpdateUserPreferencesParams( + it, + foodCategories + ) + ) + userFoodCategoriesCompleteUseCase(foodCategories.toSet()) + when (result.data) { + is Result.Success -> _typesUpdateLiveData.postValue(Event(Unit)) + is Result.Error -> { + // If preference is not stored, + // continue with the App and next time + // the screen will be shown again. + _typesUpdateLiveData.postValue(Event(Unit)) + } + else -> Unit + } + } ?: run { + _loadingLiveData.postValue(false) + } + } + } + } + + private fun convertErrorMessage(result: Result.Error) { + _loadingLiveData.postValue(false) + + val errorMessage = result.exception.message ?: "" + val message = SnackbarMessage( + messageId = PreferencesErrorConverter.convert(errorMessage), + actionId = R.string.dont_show, + requestChangeId = UUID.randomUUID().toString(), + longDuration = true + ) + _errorLiveData.postValue(Event(message)) + } + + override fun onItemChecked(isChecked: Boolean, category: String) { + if (isChecked) { + foodCategories.add(category) + } else { + foodCategories.remove(category) + } + } + + private fun getItems(): List { + return listOf(FoodCategoryHeaderItem(R.string.preferences_header_main)).plus( + listOf( + FoodCategory.MEAT, + FoodCategory.CHICKEN, + FoodCategory.FISH, + FoodCategory.VEGETARIAN, + FoodCategory.VEGAN + ).map { FoodCategoryItem(it) }) + .plusElement(FoodCategoryHeaderItem(R.string.preferences_header_other)) + .plus(listOf( + FoodCategory.DESSERTS_AND_PASTRIES, + FoodCategory.FINGER_FOOD, + FoodCategory.LIFE_HACKS + ) + .map { FoodCategoryItem(it) }) + } + + private fun generateFoodCategories(): List { + val subcategories = foodCategories.filter { foodType -> + FoodCategory.values().first { it.foodType == foodType }.foodSubCategories.isNotEmpty() + }.map { foodType -> + FoodCategory.values() + .first { it.foodType == foodType }.foodSubCategories.filter { foodCategory -> + foodCategories.any { foodCategory.foodType == it } + } + .map { "${foodType}-${it.foodType}" } + }.flatten() + return subcategories.plus(foodCategories.filter { foodType -> + subcategories.intersect(FoodCategory.values() + .first { it.foodType == foodType }.foodSubCategories.map { "${foodType}-${it.foodType}" }) + .isEmpty() + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/FoodCategoryItemListener.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/FoodCategoryItemListener.kt new file mode 100644 index 00000000..3e9e2cb8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/FoodCategoryItemListener.kt @@ -0,0 +1,6 @@ +package eu.theappfactory.dailyrecipes.ui.categories + +interface FoodCategoryItemListener { + + fun onItemChecked(isChecked: Boolean, category: String) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/di/CategoriesModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/di/CategoriesModule.kt new file mode 100644 index 00000000..e6d14c09 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/categories/di/CategoriesModule.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.ui.categories.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesActivity +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface CategoriesModule { + + @ContributesAndroidInjector + fun bindCategoriesActivity(): CategoriesActivity + + @ContributesAndroidInjector + fun bindCategoriesFragment(): CategoriesFragment +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/DeeplinkActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/DeeplinkActivity.kt new file mode 100644 index 00000000..55e9f343 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/DeeplinkActivity.kt @@ -0,0 +1,24 @@ +package eu.theappfactory.dailyrecipes.ui.deeplink + +import android.os.Bundle +import dagger.android.support.DaggerAppCompatActivity +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class DeeplinkActivity: DaggerAppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val token = intent.data?.getQueryParameter("token") ?: "" + MainActivity.start(this, token) + finish() + } + + companion object { + const val TOKEN = "token" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/di/DeeplinkModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/di/DeeplinkModule.kt new file mode 100644 index 00000000..6e40af70 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/deeplink/di/DeeplinkModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.deeplink.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.deeplink.DeeplinkActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface DeeplinkModule { + + @ContributesAndroidInjector + fun bindDeeplinkActivity(): DeeplinkActivity +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/di/BaseModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/di/BaseModule.kt new file mode 100644 index 00000000..ed7f037d --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/di/BaseModule.kt @@ -0,0 +1,6 @@ +package eu.theappfactory.dailyrecipes.ui.di + +import dagger.Module + +@Module +interface BaseModule \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesAdapter.kt new file mode 100644 index 00000000..1de64cd5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesAdapter.kt @@ -0,0 +1,48 @@ +package eu.theappfactory.dailyrecipes.ui.favorites + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemsFavoritesBinding +import eu.theappfactory.dailyrecipes.model.FavoriteItem + +class FavoritesAdapter(private val favoriteEventListener: FavoritesEventActions) : + RecyclerView.Adapter() { + + var items = listOf() + set(newItems) { + val oldItems = listOf(items).flatten() + field = newItems + notifyChanges(oldItems, newItems) + } + + override fun getItemCount(): Int = items.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoritesHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ListItemsFavoritesBinding.inflate(inflater, parent, false) + return FavoritesHolder(binding, favoriteEventListener) + } + + override fun onBindViewHolder(holder: FavoritesHolder, position: Int) { + holder.bind(items[position]) + } + + private fun notifyChanges( + oldList: List, + newList: List + ) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].imageUri == newList[newItemPosition].imageUri + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + }) + diff.dispatchUpdatesTo(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesEventActions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesEventActions.kt new file mode 100644 index 00000000..dd7a7b41 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesEventActions.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.ui.favorites + +import android.graphics.drawable.Drawable +import java.io.File + +interface FavoritesEventActions { + fun onFavoriteClicked(imageUri: String) + + fun onItemImageRetrieved(file: File, resource: Drawable) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesFragment.kt new file mode 100644 index 00000000..f1df726e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesFragment.kt @@ -0,0 +1,205 @@ +package eu.theappfactory.dailyrecipes.ui.favorites + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.GridLayoutManager +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentFavoritesBinding +import eu.theappfactory.dailyrecipes.domain.preferences.FoodCategory +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.ui.GridItemDecoration +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.home.pager.ListItemType +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.ui.setUpSnackbar +import eu.theappfactory.dailyrecipes.ui.settings.SettingsActivity +import eu.theappfactory.dailyrecipes.util.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import timber.log.Timber +import java.util.* +import javax.inject.Inject +import kotlin.collections.ArrayList + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class FavoritesFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var viewModel: FavoritesViewModel + + private lateinit var binding: FragmentFavoritesBinding + + private var adapter: FavoritesAdapter? = null + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFavoritesBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(this, viewModelFactory).get(FavoritesViewModel::class.java) + binding.viewModel = viewModel + binding.toolbar.toolbarTitle.text = "" + val manager = activity?.let { GridLayoutManager(it, SPAN_COUNT) } + binding.viewPager2.addItemDecoration(GridItemDecoration(10)) + binding.viewPager2.layoutManager = manager + + adapter = context?.let { FavoritesAdapter(viewModel) } + binding.viewPager2.adapter = adapter + setUpSnackbar(viewModel.errorLiveData, binding.snackbar, snackbarMessageManager) + setupBackDispatcher() + + if (activity is MainActivity) { + (activity as MainActivity).disableKeepScreenOn(activity as MainActivity) + } + } + + override fun onResume() { + super.onResume() + viewModel.getUserFoodCategories() + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + viewModel.favoriteItems.observe(viewLifecycleOwner, { list -> + binding.favoriteCount.text = list?.size?.toString() ?: "0" + binding.favoriteCount.addFadeAnimation() + updateView(list) + }) + + viewModel.savedStateHandle.getLiveData(SAVED_STATE_LAYOUT_MANAGER_KEY) + .observe(viewLifecycleOwner, { + binding.viewPager2.layoutManager?.onRestoreInstanceState(it) + }) + + viewModel.onFavItemClicked.observe(viewLifecycleOwner, { event -> + event.getContentIfNotHandled()?.let { imageUri -> + Timber.i("Setting Clicked") + val action = FavoritesFragmentDirections + .actionNavigationFavoritesToNavigationFavorite( + imageUri, + "", + ListItemType.FAVORITE + ) + NavHostFragment.findNavController(this@FavoritesFragment) + .navigate(action) + } + }) + + viewModel.onUserLoaded.observe(viewLifecycleOwner, { user -> + initializeUserFields(user) + }) + + viewModel.userCategories.observe(viewLifecycleOwner, { + val newList = ArrayList() + it.map { receivedCategories -> + FoodCategory.values().map { ourCategories -> + if (receivedCategories.contains(ourCategories.foodType) && receivedCategories != FoodCategory.LIFE_HACKS.foodType) { + if (!newList.contains(getString(ourCategories.nameResId).lowercase())) + newList.add(getString(ourCategories.nameResId).lowercase()) + } + } + } + binding.categoriesText.text = + getString(R.string.favourite_like_categories, newList.joinToString()) + binding.categoriesText.addFadeAnimation() + }) + + binding.settingsButton.setOnClickListener { + SettingsActivity.start(requireActivity()) + } + + viewModel.getItems() + } + + private fun initializeUserFields(user: UserResponse) { + val listener = object : ImageListener { + override fun onImageRetrievedFromRemote(drawable: Drawable, url: String) { + viewModel.onItemImageRetrieved( + FileUtils().getFileFromUrl( + requireContext(), + url + ), drawable + ) + } + } + + if (user.photoUri?.isNotEmpty() == true) { + user.photoUri?.let { uri -> + binding.userProfilePicture.loadFromCacheOrFallbackToUrl( + FileUtils().getFileFromUrl(requireContext(), uri), + uri, listener, doCircleCrop = true) + } + } + + binding.userName.text = getString(R.string.favorites_username_text, user.username) + binding.userName.addFadeAnimation() + + if (user.firstName.isEmpty() || user.lastName.isEmpty()) { + binding.toolbar.toolbarTitle.text = user.username + } else { + binding.toolbar.toolbarTitle.text = user.firstName.plus(" ").plus(user.lastName) + } + + binding.followersCount.text = user.followers.size.toString() + binding.followersCount.addFadeAnimation() + binding.followingCount.text = user.following.size.toString() + binding.followingCount.addFadeAnimation() + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.savedStateHandle.set( + SAVED_STATE_LAYOUT_MANAGER_KEY, + binding.viewPager2.layoutManager?.onSaveInstanceState() + ) + } + + private fun updateView(list: List) { + if (list.isEmpty()) { + binding.noFavsAvailableText.visibility = View.VISIBLE + binding.swirlGraphic.visibility = View.VISIBLE + } else { + adapter?.items = list + } + } + + private fun setupBackDispatcher() { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + requireActivity().finishAffinity() + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) + } + + private companion object { + const val SAVED_STATE_LAYOUT_MANAGER_KEY = "SAVED_STATE_LAYOUT_MANAGER" + const val SPAN_COUNT = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesHolder.kt new file mode 100644 index 00000000..0ce730bf --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesHolder.kt @@ -0,0 +1,29 @@ +package eu.theappfactory.dailyrecipes.ui.favorites + +import android.graphics.drawable.Drawable +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemsFavoritesBinding +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.util.* + +class FavoritesHolder( + val binding: ListItemsFavoritesBinding, + private val favoriteEventListener: FavoritesEventActions +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: FavoriteItem) { + binding.eventListener = favoriteEventListener + binding.item = item + binding.favoriteImage.addFadeAnimation() + val listener = object : ImageListener { + override fun onImageRetrievedFromRemote(drawable: Drawable, url: String) { + favoriteEventListener.onItemImageRetrieved(FileUtils().getFileFromUrl(itemView.context, url), drawable) + } + } + binding.favoriteImage.loadFromCacheOrFallbackToUrl( + FileUtils().getFileFromUrl(itemView.context, item.imageUri), + item.imageUri, listener + ) + HtmlUtils().getHtmlText(binding.title,item.title) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesViewModel.kt new file mode 100644 index 00000000..0546563e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/FavoritesViewModel.kt @@ -0,0 +1,130 @@ +package eu.theappfactory.dailyrecipes.ui.favorites + +import android.graphics.drawable.Drawable +import android.view.View +import androidx.databinding.ObservableField +import androidx.lifecycle.* +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.domain.home.GetFavoriteItemsUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.GetUserFoodCategoriesUseCase +import eu.theappfactory.dailyrecipes.domain.userdetails.GetUserUseCase +import eu.theappfactory.dailyrecipes.model.FavoriteItem +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.networking.successOr +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.FileUtils +import eu.theappfactory.dailyrecipes.util.PreferencesErrorConverter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.io.File +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class FavoritesViewModel @Inject constructor( + signInViewModelDelegate: SignInViewModelDelegate, + private val getFavoriteItemsUseCase: GetFavoriteItemsUseCase, + private val getUserUseCase: GetUserUseCase, + private val getUserFoodCategoriesUseCase: GetUserFoodCategoriesUseCase, +) : ViewModel(), FavoritesEventActions, + SignInViewModelDelegate by signInViewModelDelegate { + + var savedStateHandle = SavedStateHandle() + var textVisibility = ObservableField(View.GONE) + + private var _favoriteItems = MutableLiveData>() + var favoriteItems: LiveData> = _favoriteItems + + private var _onFavItemClicked = MutableLiveData>() + var onFavItemClicked: LiveData> = _onFavItemClicked + + private var _onUserLoaded = MutableLiveData() + var onUserLoaded: LiveData = _onUserLoaded + + private val _errorLiveData = MutableLiveData>() + val errorLiveData: LiveData> = _errorLiveData + + private val _userCategories = MutableLiveData>() + val userCategories: LiveData> = _userCategories + + fun getItems() { + getUser() + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + val favorites = getFavoriteItemsUseCase(it) + .successOr(emptyList()) + .sortedByDescending { it.date } + + if (favorites.isNotEmpty()) { + textVisibility.set(View.GONE) + } + + _favoriteItems.postValue(favorites) + } + } + } + } + + fun getUserFoodCategories() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + val result = getUserFoodCategoriesUseCase(it) + when (result.data) { + is Result.Success -> { + val currentCategories = result.data?.data.orEmpty() + _userCategories.postValue(currentCategories) + } + is Result.Error -> convertErrorMessage(result.data as Result.Error) + else -> Unit + } + } + } + } + } + + private fun convertErrorMessage(result: Result.Error) { + val errorMessage = result.exception.message ?: "" + val message = SnackbarMessage( + messageId = PreferencesErrorConverter.convert(errorMessage), + actionId = R.string.dont_show, + requestChangeId = UUID.randomUUID().toString(), + longDuration = true + ) + + _errorLiveData.postValue(Event(message)) + } + + private fun getUser() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + val result = getUserUseCase(it) + + when (result) { + is Result.Success -> { _onUserLoaded.postValue(result.data.data) } + is Result.Error -> { _onUserLoaded.postValue(UserResponse()) } + } + } + } + } + } + + override fun onFavoriteClicked(imageUri: String) { + _onFavItemClicked.postValue(Event(imageUri)) + } + + override fun onItemImageRetrieved(file: File, resource: Drawable) { + viewModelScope.launch(Dispatchers.IO) { + FileUtils().saveImageToFile(file, resource) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/di/FavoritesModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/di/FavoritesModule.kt new file mode 100644 index 00000000..0ca45cbb --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/favorites/di/FavoritesModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.favorites.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.favorites.FavoritesFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface FavoritesModule { + + @ContributesAndroidInjector + fun bindFavoritesFragment(): FavoritesFragment +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ErrorViewEventActions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ErrorViewEventActions.kt new file mode 100644 index 00000000..f5b26182 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ErrorViewEventActions.kt @@ -0,0 +1,7 @@ +package eu.theappfactory.dailyrecipes.ui.home + +interface ErrorViewEventActions { + + fun onTryAgainClicked() +} + diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ItemEventActions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ItemEventActions.kt new file mode 100644 index 00000000..6084cbc3 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ItemEventActions.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.ui.home + +import android.graphics.drawable.Drawable +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import java.io.File + +/** + * Actions that can be performed on events. + */ +interface ItemEventActions { + + fun onFavoriteClicked(item: ItemResponse) + fun onAlreadySeen(item: ItemResponse) + fun onSocialNetworkButtonClicked(socialNetworkHandle: String) + fun onChatButtonClicked(chats: List) + fun onChatButtonLongClicked(id: String) + fun onStoreButtonClicked(storeLink: String) + fun onSpotifyButtonClicked(songId: String) + fun onIngredientsButtonClicked(id: String, directLink: String) + fun onAddIngredientsButtonClicked(id: String) + fun onShareButtonClicked(uri: String) + fun onPlayButtonClicked(isPlaying: Boolean) + fun onBackwardButtonLongClicked() + fun onBackwardButtonClicked() + fun onBackwardButtonDoubleClicked() + fun onForwardButtonClicked() + fun onForwardButtonLongClicked() + fun onVideoStarted(position : Int) + fun onNextVideo() + fun onPlayFirstVideo() + fun onFirstTimeSwipeUp() + fun resetAllSeenItems() + fun onItemImageRetrieved(file: File, resource: Drawable) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ProgressBarsIndicatorHandler.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ProgressBarsIndicatorHandler.kt new file mode 100644 index 00000000..620455fd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/ProgressBarsIndicatorHandler.kt @@ -0,0 +1,131 @@ +package eu.theappfactory.dailyrecipes.ui.home + +import android.animation.ValueAnimator +import android.view.animation.LinearInterpolator +import android.widget.ProgressBar +import eu.theappfactory.dailyrecipes.media.MediaPlayer +import eu.theappfactory.dailyrecipes.ui.home.pager.VideoControls +import timber.log.Timber +import kotlin.math.roundToInt + +class ProgressBarsIndicatorHandler(var mediaPlayer: MediaPlayer, var progressBars: List) { + + private var progressValueAnimator: ValueAnimator? = null + + private var partDuration = 0 + private var max = 0 + private var duration = 0 + var progressBarIndex = -1 + + + fun updateProgressBars(duration: Int) { + this.duration = duration + + max = duration / progressBars.size + + + progressBars.forEach { + it.progress = 0 + it.max = MAX_PROGRESS + } + partDuration = duration / progressBars.size + + val startProgress = { init: Int -> + updateProgress(init) + + cancel() + + progressValueAnimator = ValueAnimator.ofInt(init, duration) + ?.apply { + interpolator = LinearInterpolator() + + if (duration >= 0) { + this.duration = (duration - init).toLong() + addUpdateListener { animation -> + addAnimationOnUpdate(animation) + } + start() + } + } + } + + startProgress(mediaPlayer.getCurrentPosition()) + } + + fun cancel() { + progressValueAnimator?.cancel() + } + + private fun addAnimationOnUpdate(animation: ValueAnimator) { + val animatedValue = animation.animatedValue as Int + val index = (animatedValue / partDuration) + + if (progressBarIndex != index) { + // update when it changes. + progressBarIndex = index + Timber.i("progressBarIndex = $index") + } + + if (index >= progressBars.size) { + // When the video starts playing from start again + return + } + + val partProgress = + (((animatedValue - (partDuration * index)) * 100).toDouble() / max.toDouble()).roundToInt() + if (progressBars[index].progress != partProgress) { + progressBars[index].progress = partProgress + progressBars[index].invalidate() + } + } + + private fun updateProgress(init: Int) { + progressValueAnimator?.cancel() + + val lastProgressIndex = (init / partDuration) - 1 + if (lastProgressIndex >= 0) + for (i in progressBars.indices) { + progressBars[i].progress = if (lastProgressIndex >= i) progressBars[i].max else 0 + } + } + + fun updateIndicator(controls: VideoControls) { + when (controls) { + VideoControls.INCREMENTED -> progressBarIndex++ + VideoControls.DECREMENTED -> progressBarIndex-- + VideoControls.RESTARTED -> Unit + } + + updateProgressBarsAndSeek((duration / progressBars.size * progressBarIndex).toLong()) + } + + fun updateIndicatorOnSeek(controls: VideoControls) { + when (controls) { + VideoControls.INCREMENTED -> updateProgressBarsAndSeek(mediaPlayer.getCurrentPosition() + SEEK_TIME_IN_MILLIS) + VideoControls.DECREMENTED -> updateProgressBarsAndSeek(mediaPlayer.getCurrentPosition() - SEEK_TIME_IN_MILLIS) + VideoControls.RESTARTED -> Unit + } + } + + private fun updateProgressBarsAndSeek(seekTo: Long) { + cancel() + + mediaPlayer.seekTo(seekTo) + + updateProgress(progressBarIndex) + + progressValueAnimator = ValueAnimator.ofInt(progressBarIndex, duration) + ?.apply { + interpolator = LinearInterpolator() + this.duration = (duration - progressBarIndex) + start() + } + + mediaPlayer.play() + } + + private companion object { + const val MAX_PROGRESS = 100 + const val SEEK_TIME_IN_MILLIS = 3000L + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemAdapter.kt new file mode 100644 index 00000000..970614f8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemAdapter.kt @@ -0,0 +1,65 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemItemsBinding +import eu.theappfactory.dailyrecipes.media.MediaPlayer +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.FIRST_TIME_VIDEO_IDS +import eu.theappfactory.dailyrecipes.ui.home.ItemEventActions + +class ItemAdapter( + private val mediaPlayer: MediaPlayer, + private val itemEventListener: ItemEventActions, + private val onVideoPlayed: OnVideoPlayed +) : RecyclerView.Adapter() { + + var items = mutableListOf() + var listItemType : ListItemType? = null + var currentHolder: ItemHolder? = null + + override fun onViewAttachedToWindow(holder: ItemHolder) { + super.onViewAttachedToWindow(holder) + holder.initializeLoadingFrame() + } + + override fun onViewDetachedFromWindow(holder: ItemHolder) { + super.onViewDetachedFromWindow(holder) + holder.uninitialize() + holder.onViewDetached() + itemEventListener.onAlreadySeen(holder.item) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder { + val binding = ListItemItemsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ItemHolder(binding, mediaPlayer, itemEventListener, onVideoPlayed) + } + + override fun onBindViewHolder(holder: ItemHolder, position: Int) { + when (position) { + 0 -> itemEventListener.onPlayFirstVideo() + 2 -> itemEventListener.onFirstTimeSwipeUp() // first time swipe up, position = 2 + } + + if (position == (items.size - 1) && FIRST_TIME_VIDEO_IDS.contains(items[0].id).not() + && listItemType == ListItemType.HOME) { + // do not reload for the first time items. + itemEventListener.resetAllSeenItems() + } + currentHolder = holder + holder.bind(items[position]) + } + + fun updateCurrentItem(item: ItemResponse) { + currentHolder?.bind(item) + } + + override fun getItemCount(): Int = items.size + + fun onPageChanged(holder: ItemHolder) { + holder.initializePanAnimation() + holder.initializeVideo() + holder.showVideoWhenDoneLoading() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsDialogFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsDialogFragment.kt new file mode 100644 index 00000000..39621186 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsDialogFragment.kt @@ -0,0 +1,120 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +import android.content.Context +import android.content.DialogInterface +import android.content.res.Resources +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.BottomSheetFragmentChatsBinding +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.util.convertHyphenToBullet + +class ItemChatsDialogFragment : BottomSheetDialogFragment() { + + private var listener: ItemChatsFragmentListener? = null + private lateinit var binding: BottomSheetFragmentChatsBinding + + override fun onAttach(context: Context) { + super.onAttach(context) + if (parentFragment is ItemChatsFragmentListener) { + listener = parentFragment as ItemChatsFragmentListener + } else throw IllegalStateException("$context or $parentFragment must implement ItemChatsFragmentListener") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.ChatBottomSheetDialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = BottomSheetFragmentChatsBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val items = requireArguments().getParcelableArrayList(EXTRA_CHATS_KEY).orEmpty() + binding.closeBtn.setOnClickListener { + dismiss() + } + items.firstOrNull()?.let { chatItem -> + if (chatItem.ingredients.any { it.contains("-") }) { + binding.ingredientsText.text = + chatItem.ingredients.joinToString("\n", transform = { it.convertHyphenToBullet() }) + } else { + binding.ingredientsText.text = + chatItem.ingredients.joinToString("\n", transform = { "\u2022 $it" }) + } + + chatItem.persons.let { + binding.ingredientsPersonsText.text = resources.getQuantityString( + R.plurals.ingredients_person_count, + it, it + ) + } + binding.ingredientsWarning.isVisible = chatItem.preparations.isNotEmpty() + binding.ingredientsMessageText.isVisible = chatItem.preparations.isNotEmpty() + if (chatItem.preparations.any { it.contains("-") }) { + binding.ingredientsMessageText.text = + chatItem.preparations.joinToString("\n", transform = { it.convertHyphenToBullet() }) + } else { + binding.ingredientsMessageText.text = + chatItem.preparations.joinToString("\n", transform = { "\u2022 $it" }) + } + + chatItem.message.let { + if (it.isEmpty()) { + binding.variationTipTitleText.visibility = View.GONE + binding.variationTipText.visibility = View.GONE + } else { + binding.variationTipText.text = it + binding.variationTipTitleText.visibility = View.VISIBLE + binding.variationTipText.visibility = View.VISIBLE + } + } + } + } + + override fun onStart() { + super.onStart() + setMaxHeight() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + listener?.onChatDialogDismissed() + } + + override fun onDetach() { + super.onDetach() + listener = null + } + + private fun setMaxHeight() { + dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.apply { + layoutParams.height = 2* Resources.getSystem().displayMetrics.heightPixels / 3 + } + } + + companion object { + + private const val EXTRA_CHATS_KEY = "EXTRA_CHATS" + + fun newInstance(chats: List) = ItemChatsDialogFragment().apply { + val args = Bundle() + args.putParcelableArrayList(EXTRA_CHATS_KEY, arrayListOf(chats.first())) + arguments = args + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsFragmentListener.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsFragmentListener.kt new file mode 100644 index 00000000..d8d43450 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemChatsFragmentListener.kt @@ -0,0 +1,6 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +interface ItemChatsFragmentListener { + + fun onChatDialogDismissed() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemFragment.kt new file mode 100644 index 00000000..a7676b0d --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemFragment.kt @@ -0,0 +1,476 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +import android.app.Activity +import android.content.Context.SENSOR_SERVICE +import android.content.Intent +import android.hardware.Sensor +import android.hardware.Sensor.TYPE_ACCELEROMETER +import android.hardware.SensorManager +import android.hardware.SensorManager.SENSOR_DELAY_UI +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.navArgs +import androidx.viewpager2.widget.ViewPager2 +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.BuildConfig +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.databinding.FragmentItemBinding +import eu.theappfactory.dailyrecipes.media.MediaPlayer +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.sensor.ShakeDetector +import eu.theappfactory.dailyrecipes.ui.PopupDialog +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.home.ProgressBarsIndicatorHandler +import eu.theappfactory.dailyrecipes.ui.ingredients.AddIngredientsActivity +import eu.theappfactory.dailyrecipes.ui.ingredients.IngredientsActivity +import eu.theappfactory.dailyrecipes.ui.instructions.InstructionsActivity +import eu.theappfactory.dailyrecipes.ui.instructions.InstructionsActivity.Companion.ITEM_ID +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.ui.setUpSnackbar +import eu.theappfactory.dailyrecipes.ui.settings.DebugActivity +import eu.theappfactory.dailyrecipes.ui.webview.WebViewActivity +import eu.theappfactory.dailyrecipes.util.* +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_FAVORITE_VIDEO +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar +import kotlinx.android.synthetic.main.first_time_swipe_up_layout.* +import kotlinx.android.synthetic.main.fragment_item.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class ItemFragment : DaggerFragment(), ItemChatsFragmentListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var mixpanel: MixpanelAPI + + @Inject + lateinit var mediaPlayer: MediaPlayer + + @Inject + lateinit var itemRepository: ItemRepository + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + lateinit var viewModel: ItemViewModel + + private lateinit var binding: FragmentItemBinding + + var adapter: ItemAdapter? = null + + private val args: ItemFragmentArgs by navArgs() + + private var sensorManager: SensorManager? = null + + private var accelerometer: Sensor? = null + + private var shakeDetector: ShakeDetector? = null + + private lateinit var progressBarsIndicatorHandler: ProgressBarsIndicatorHandler + + private lateinit var snackbar: FadingSnackbar + + private var itemChatsDialogFragment: ItemChatsDialogFragment? = null + + val startForResult = + registerForActivityResult(StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val itemId = result.data?.getStringExtra(ITEM_ID) ?: "" + if (itemId.isNotEmpty()) { + val item = itemRepository.getItemForId(itemId) + adapter?.items = itemRepository.retrieveRemoteItemData().items + if (item != null) { + adapter?.updateCurrentItem(item) + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentItemBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + + if (BuildConfig.DEBUG) { + initializeShakeDetector() + } + + snackbar = binding.snackbar + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(this, viewModelFactory).get(ItemViewModel::class.java) + + binding.errorLayout.viewModel = viewModel + + if (activity is MainActivity) { + (activity as MainActivity).keepScreenOn(activity as MainActivity) + } + + viewModel.showLoading.observe(viewLifecycleOwner, { + showLoading(it) + }) + + viewModel.onPlayButtonClicked.observe(viewLifecycleOwner, { + if (activity is MainActivity) { + (activity as MainActivity).onPlayButtonClicked(it, args.imageUri.isNotEmpty()) + } + }) + + progressBarsIndicatorHandler = ProgressBarsIndicatorHandler( + mediaPlayer, listOf( + binding.progressIndicatorLayout.bar1, + binding.progressIndicatorLayout.bar2, + binding.progressIndicatorLayout.bar3, + binding.progressIndicatorLayout.bar4, + binding.progressIndicatorLayout.bar5 + ) + ) + + viewModel.onForwardBackButtonLongClicked.observe(viewLifecycleOwner, { + progressBarsIndicatorHandler.updateIndicator(it) + }) + + viewModel.onForwardBackButtonClicked.observe(viewLifecycleOwner, { + progressBarsIndicatorHandler.updateIndicatorOnSeek(it) + }) + + viewModel.onFirstTimeUseApp.observe(viewLifecycleOwner, { + swipeUpAnimation.visibility = View.VISIBLE + swipeUpOverlay.visibility = View.VISIBLE + }) + + viewModel.onFirstTimeSwipedUp.observe(viewLifecycleOwner, { + onFirstTimeSwipeUp() + }) + + viewModel.onChatButtonClicked.observe(viewLifecycleOwner, { + handleChatButtonClicked(it) + }) + + viewModel.onChatButtonLongClicked.observe(viewLifecycleOwner, { + val intent = Intent(activity, InstructionsActivity::class.java) + intent.putExtra(InstructionsActivity.EXTRA_ID, it) + startForResult.launch(intent) + }) + + viewModel.onIngredientsButtonClicked.observe(viewLifecycleOwner, { + handleIngredientsButtonClicked(it.first, it.second) + }) + + viewModel.onAddIngredientsButtonClicked.observe(viewLifecycleOwner, { + handleAddIngredientsButtonClicked(it) + }) + + viewModel.onShareButtonClicked.observe(viewLifecycleOwner, { + ShareUtils().openShareSheet(requireActivity(), it) + }) + + viewModel.onSpotifyButtonClicked.observe(viewLifecycleOwner, { songId -> + PopupDialog(requireActivity()).showWithNoYes( + getString(R.string.spotify_popup_title), + getString(R.string.spotify_popup_text), + object : PopupDialog.ButtonCallback { + override fun onButtonClicked(button: PopupDialog.Companion.Button) { + when (button) { + PopupDialog.Companion.Button.POSITIVE_BTN -> { + ShareUtils().openUrl( + requireContext(), + "https://open.spotify.com/embed/track/$songId" + ) + } + } + } + }) + }) + + viewModel.progressBarVisibility.observe(viewLifecycleOwner, { + updateProgressBarsVisibility(it) + }) + + viewModel.timestamp.observe(viewLifecycleOwner, { + DebugActivity.start(requireActivity(), it) + }) + + viewModel.onTryAgainButtonClicked.observe(viewLifecycleOwner, { + handleArguments() + }) + + viewModel.onImageUriFound.observe(viewLifecycleOwner, { imageUri -> + showItemFromNotificationAndMakeList(imageUri) + }) + + viewModel.onSocialNetworkButtonClicked.observe(viewLifecycleOwner, { followUrl -> + if (followUrl.contains("instagram")) { + PopupDialog(requireActivity()).showWithNoYes( + getString(R.string.instagram_popup_title), + getString(R.string.instagram_popup_text), + object : PopupDialog.ButtonCallback { + override fun onButtonClicked(button: PopupDialog.Companion.Button) { + when (button) { + PopupDialog.Companion.Button.POSITIVE_BTN -> { + ShareUtils().openUrl(requireContext(), followUrl) + } + } + } + }) + } else if (followUrl.contains("tiktok")) { + PopupDialog(requireActivity()).showWithNoYes( + getString(R.string.tiktok_popup_title), + getString(R.string.tiktok_popup_text), + object : PopupDialog.ButtonCallback { + override fun onButtonClicked(button: PopupDialog.Companion.Button) { + when (button) { + PopupDialog.Companion.Button.POSITIVE_BTN -> { + ShareUtils().openUrl(requireContext(), followUrl) + } + } + } + }) + } + }) + + viewModel.onStoreButtonClicked.observe(viewLifecycleOwner, { followUrl -> + ShareUtils().openUrl(requireContext(), followUrl) + }) + + setUpSnackbar(viewModel.onFirstSaveSnackbar, snackbar, snackbarMessageManager) + + handleArguments() + initializeViewPager() + } + + override fun onChatDialogDismissed() { + mediaPlayer.play() + } + + private fun updateProgressBarsVisibility(viewId: Int) { + binding.progressIndicatorLayout.bar1.visibility = viewId + binding.progressIndicatorLayout.bar2.visibility = viewId + binding.progressIndicatorLayout.bar3.visibility = viewId + binding.progressIndicatorLayout.bar4.visibility = viewId + binding.progressIndicatorLayout.bar5.visibility = viewId + } + + private fun handleIngredientsButtonClicked(id: String, directLink: String) { + if (directLink.isNotBlank()) { + // Advertisement + WebViewActivity.start(requireActivity(), directLink) + } else { + // Get ingredients + IngredientsActivity.start(requireActivity(), id) + } + } + + private fun handleAddIngredientsButtonClicked(id: String) { + AddIngredientsActivity.start(requireActivity(), id) + } + + private fun handleChatButtonClicked(chats: List) { + mediaPlayer.pause() + itemChatsDialogFragment = ItemChatsDialogFragment.newInstance(chats) + itemChatsDialogFragment?.show( + childFragmentManager, + ItemChatsDialogFragment::class.java.simpleName + ) + } + + private fun initializeViewPager() { + adapter = ItemAdapter(mediaPlayer, viewModel, object : OnVideoPlayed { + override fun onViewStarted(duration: Int) { + progressBarsIndicatorHandler.updateProgressBars(duration) + } + + override fun cancel() { + progressBarsIndicatorHandler.cancel() + } + }) + viewPager2.offscreenPageLimit = OFF_SCREEN_LIMIT + viewPager2.adapter = adapter + viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + val recyclerView = viewPager2.getRecyclerView() + recyclerView?.post { + (recyclerView.findViewHolderForAdapterPosition(position) as? ItemHolder) + ?.let { adapter?.onPageChanged(it) } + } + viewModel.onPageChanged() + } + }) + } + + private fun onFirstTimeSwipeUp() { + swipeUpAnimation.visibility = View.GONE + swipeUpOverlay.visibility = View.GONE + } + + override fun onResume() { + super.onResume() + + if (BuildConfig.DEBUG) { + sensorManager?.registerListener(shakeDetector, accelerometer, SENSOR_DELAY_UI) + } + + if (itemChatsDialogFragment?.isVisible == false || itemChatsDialogFragment == null) { + mediaPlayer.play() + } + } + + override fun onPause() { + super.onPause() + + if (BuildConfig.DEBUG) { + sensorManager?.unregisterListener(shakeDetector) + } + + mediaPlayer.pause() + } + + override fun onStop() { + super.onStop() + mediaPlayer.pause() + } + + fun loadNextVideo() { + viewPager2.setCurrentItem(viewPager2.currentItem + 1, true) + } + + private fun handleArguments() { + // need to reset player here as we continue to play in onResume and we may have interleaving of videos + mediaPlayer.resetPlayer() + when { + args.itemType == ListItemType.FAVORITE -> { + viewModel.apply { + items.removeObservers(viewLifecycleOwner) + items.observe(viewLifecycleOwner, { list -> + initializeViewWithItemsAndScroll(list) + }) + getFavoriteItems() + } + mixpanel.track(CLICKED_FAVORITE_VIDEO) + } + args.itemType == ListItemType.SEARCH -> { + viewModel.apply { + items.removeObservers(viewLifecycleOwner) + items.observe(viewLifecycleOwner, { list -> + initializeViewWithItemsAndScroll(list) + }) + getSearchItems(args.query) + } + } + + args.imageUri.isNotBlank() -> { + showItemFromNotificationAndMakeList(args.imageUri) + } + + args.token.isNotBlank() -> { + viewModel.getImageUriForToken(args.token) + } + + viewModel.items.value == null -> { + setupBackDispatcherHome() + viewModel.apply { + items.removeObservers(viewLifecycleOwner) + items.observe(viewLifecycleOwner, { list -> + updateViewWithNewItems(list) + }) + getItems() + } + } + } + } + + private fun showItemFromNotificationAndMakeList(uri: String) { + setupBackDispatcherHome() + viewModel.apply { + items.removeObservers(viewLifecycleOwner) + items.observe(viewLifecycleOwner, { list -> + updateViewWithNewItems(list) + }) + getItemsForNotification(uri) + } + } + + private fun initializeViewWithItemsAndScroll(list: List) { + adapter?.apply { + items = list.toMutableList() + listItemType = args.itemType + notifyDataSetChanged() + + items.indexOfFirst { it.imageUri == args.imageUri }.let { + viewPager2.setCurrentItem(it, false) + } + } + } + + private fun updateViewWithNewItems(list: List) { + adapter?.apply { + items = list.toMutableList() + listItemType = args.itemType + + notifyDataSetChanged() + } + } + + private fun initializeShakeDetector() { + sensorManager = context?.getSystemService(SENSOR_SERVICE) as SensorManager + accelerometer = sensorManager?.getDefaultSensor(TYPE_ACCELEROMETER) + + shakeDetector = ShakeDetector() + shakeDetector?.setOnShakeListener(object : ShakeDetector.OnShakeListener { + override fun onShake(count: Int) { + val item = adapter?.items?.getOrNull(viewPager2.currentItem) ?: return + viewModel.handleShake(item) + } + }) + } + + private fun showLoading(loading: Boolean) { + if (loading) { + binding.loadingLayout.showWithAnimation() + } else { + binding.loadingLayout.hide() + } + } + + private fun setupBackDispatcherHome() { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + requireActivity().finishAffinity() + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) + } + + private companion object { + const val OFF_SCREEN_LIMIT = 1 + } +} + +interface OnVideoPlayed { + + fun onViewStarted(duration: Int) + fun cancel() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemHolder.kt new file mode 100644 index 00000000..348182a3 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemHolder.kt @@ -0,0 +1,522 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +import android.graphics.drawable.Drawable +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION +import com.google.android.exoplayer2.Player.EventListener +import com.google.gson.Gson +import eu.theappfactory.dailyrecipes.BuildConfig +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ListItemItemsBinding +import eu.theappfactory.dailyrecipes.media.MediaPlayer +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.DR_OETKER +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.HELLOFRESH +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.INSTAGRAM_URL +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.TIKTOK_URL +import eu.theappfactory.dailyrecipes.model.ah.IngredientsResponse +import eu.theappfactory.dailyrecipes.ui.home.ItemEventActions +import eu.theappfactory.dailyrecipes.util.* +import kotlinx.android.synthetic.main.list_item_items.view.* +import kotlinx.android.synthetic.main.video_controls_layout.view.* +import timber.log.Timber + +class ItemHolder( + val binding: ListItemItemsBinding, + private val mediaPlayer: MediaPlayer, + private val itemEventListener: ItemEventActions, + private val onVideoPlayed: OnVideoPlayed +) : RecyclerView.ViewHolder(binding.root) { + + private val backwardButtonGestureDetector by lazy { + GestureDetector( + binding.root.context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent?): Boolean { + binding.videoControlsLayout.backButtonHolder.isPressed = true + return true + } + + override fun onSingleTapUp(e: MotionEvent?): Boolean { + binding.videoControlsLayout.backButtonHolder.isPressed = true + return true + } + + override fun onLongPress(e: MotionEvent?) { + super.onLongPress(e) + binding.videoControlsLayout.backButtonHolder.isPressed = true + itemEventListener.onBackwardButtonLongClicked() + } + + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + binding.videoControlsLayout.backButtonHolder.isPressed = false + itemEventListener.onBackwardButtonClicked() + return true + } + + override fun onDoubleTap(e: MotionEvent?): Boolean { + binding.videoControlsLayout.backButtonHolder.isPressed = false + itemEventListener.onBackwardButtonDoubleClicked() + return true + } + + override fun onDoubleTapEvent(e: MotionEvent?): Boolean { + binding.videoControlsLayout.backButtonHolder.isPressed = false + return true + } + }) + } + + var response: IngredientsResponse? = null + lateinit var item: ItemResponse + + fun bind(item: ItemResponse) { + this.item = item + + initializeStoreButton(item) + initializeSocialNetworkButton(item) + initializeIngredientsButton(item) + initializeAddIngredientsButton(item) + initializeChatButton(item) + initializeSpotifyButton(item) + initializeShareButton(item) + + initializeFavoriteButton(item) + initializeTitle(item) + initializeUserName(item) + } + + private fun initializeStoreButton(item: ItemResponse) { + if (item.storeLink?.isNotEmpty() == true) { + binding.storeButton.visibility = View.VISIBLE + + binding.storeButton.setOnClickListener { + itemEventListener.onStoreButtonClicked(item.storeLink!!) + } + } else { + binding.storeButton.visibility = View.GONE + } + } + + private fun initializeShareButton(item: ItemResponse) { + binding.shareButton.addFadeAnimation() + binding.shareButton.setOnClickListener { + itemEventListener.onShareButtonClicked(item.imageUri) + } + } + + private fun initializeSocialNetworkButton(item: ItemResponse) { + if (item.socialNetworkHandle == "") { + binding.socialNetworkButton.visibility = View.GONE + } else { + binding.socialNetworkButton.visibility = View.VISIBLE + binding.socialNetworkButton.addFadeAnimation() + + if (item.socialNetworkHandle?.contains(INSTAGRAM_URL)!!) { + val drawable = + ContextCompat.getDrawable( + binding.layout.context, + R.drawable.ic_instagram_button + ) + binding.socialNetworkButton.setImageDrawable(drawable) + } else if (item.socialNetworkHandle?.contains(TIKTOK_URL)!!) { + val drawable = + ContextCompat.getDrawable(binding.layout.context, R.drawable.ic_tiktok) + binding.socialNetworkButton.setImageDrawable(drawable) + } + + binding.socialNetworkButton.setOnClickListener { + item.socialNetworkHandle?.let { it1 -> + itemEventListener.onSocialNetworkButtonClicked( + it1 + ) + } + } + } + } + + private fun initializeSpotifyButton(item: ItemResponse) { + if (item.trackName.isNotEmpty() && item.trackUrl.isNotEmpty()) { + binding.spotifyButton.visibility = View.VISIBLE + binding.spotifyButton.addFadeAnimation() + binding.trackName.visibility = View.VISIBLE + binding.trackName.text = item.trackName + binding.trackName.addFadeAnimation() + binding.spotifyButton.setOnClickListener { + itemEventListener.onSpotifyButtonClicked(item.trackUrl) + } + } + } + + private fun initializeChatButton(item: ItemResponse) { + if (item.chats.isNullOrEmpty()) { + binding.chatButton.visibility = View.GONE + } else { + binding.chatButton.visibility = View.VISIBLE + binding.chatButton.addFadeAnimation() + binding.chatButton.setOnClickListener { + item.chats?.let { chats -> + itemEventListener.onChatButtonClicked(chats) + } + } + + if (isAddIngredientsEnabled()) { + binding.chatButton.setOnLongClickListener { + onChatButtonLongClicked() + true + } + } + } + } + + fun initializeLoadingFrame() { + /** + * The first frame is shown during loading. + * The first frame is a separate bitmap + * in the ItemResponse because it is only + * available in the ExoPlayer after the player + * is done loading. And we only need it + * during the loading. + * + * The first frame should already be + * available because the app preloads this. + */ + binding.loadingFrame.visibility = View.VISIBLE + + if (item.imageUri.isNotEmpty()) { + binding.loadingFrame.loadFromCacheOrFallbackToUrl(FileUtils().getFileFromUrl( + itemView.context, + item.imageUri + ), item.imageUri, object : ImageListener { + override fun onImageRetrievedFromRemote(drawable: Drawable, url: String) { + itemEventListener.onItemImageRetrieved( + FileUtils().getFileFromUrl( + itemView.context, + url + ), drawable + ) + } + }) + Timber.i("Setting frame using Glide: ${item.title}") + } + } + + fun initializePanAnimation() { + if (item.showThumbnail) { + binding.videoControlsLayout.playButton.visibility = View.GONE + binding.videoControlsLayout.backButton.visibility = View.GONE + binding.videoControlsLayout.forwardButton.visibility = View.GONE + binding.loadingFrame.addPanOutAnimation() + } + } + + private fun initializeIngredientsButton(item: ItemResponse) { + item.ingredients?.let { + setIngredientsButton( + it, + item.directLink + ) + } + + if (item.ingredients.isNullOrBlank() && item.directLink.isNullOrBlank()) { + binding.ingredientsButton.visibility = View.GONE + } else { + binding.ingredientsButton.visibility = View.VISIBLE + binding.ingredientsButton.addFadeAnimation() + + binding.ingredientsButton.setOnClickListener { + onIngredientsButtonClicked() + } + + if (isAddIngredientsEnabled()) binding.ingredientsButton.setOnLongClickListener { + onIngredientsButtonLongClicked() + true + } + } + } + + private fun onIngredientsButtonClicked() { + itemEventListener.onIngredientsButtonClicked( + item.id, + item.ingredientsResponse?.directLink.orEmpty() + ) + + pausePlaying() + } + + private fun onIngredientsButtonLongClicked() { + itemEventListener.onAddIngredientsButtonClicked(item.id) + binding.videoControlsLayout.playButton.visibility = View.VISIBLE + binding.videoControlsLayout.backButton.visibility = View.VISIBLE + binding.videoControlsLayout.forwardButton.visibility = View.VISIBLE + + binding.videoView.player?.playWhenReady = false + } + + private fun onChatButtonLongClicked() { + itemEventListener.onChatButtonLongClicked(item.id) + pausePlaying() + } + + private fun pausePlaying() { + binding.layout.foreground = ContextCompat.getDrawable( + binding.layout.context, + R.color.half_transparant_black + ) + binding.videoControlsLayout.playButton.visibility = View.VISIBLE + binding.videoControlsLayout.backButton.visibility = View.VISIBLE + binding.videoControlsLayout.forwardButton.visibility = View.VISIBLE + + binding.videoView.player?.playWhenReady = false + } + + private fun initializeAddIngredientsButton(item: ItemResponse) { + if (!isAddIngredientsEnabled() || item.ingredients?.isNotBlank() == true || item.directLink?.isNotBlank() == true) { + binding.addIngredientsButton.visibility = View.GONE + } else { + binding.addIngredientsButton.visibility = View.VISIBLE + binding.addIngredientsButton.addFadeAnimation() + + binding.addIngredientsButton.setOnClickListener { + itemEventListener.onAddIngredientsButtonClicked(item.id) + + binding.layout.foreground = ContextCompat.getDrawable( + binding.layout.context, + R.color.half_transparant_black + ) + + binding.videoControlsLayout.playButton.visibility = View.VISIBLE + binding.videoControlsLayout.backButton.visibility = View.VISIBLE + binding.videoControlsLayout.forwardButton.visibility = View.VISIBLE + + binding.videoView.player?.playWhenReady = false + } + } + } + + private fun initializeFavoriteButton(item: ItemResponse) { + binding.favoriteButton.visibility = View.VISIBLE + binding.favoriteButton.setOnClickListener { + binding.favoriteButton.isActivated = item.isFavorite.not() + item.isFavorite = item.isFavorite.not() + setFavoriteButtonResource(item.isFavorite) + itemEventListener.onFavoriteClicked(item) + } + binding.favoriteButton.addFadeAnimation() + setFavoriteButtonResource(item.isFavorite) + } + + private fun setFavoriteButtonResource(isFavorite: Boolean) { + if (isFavorite) { + binding.favoriteButton.setImageResource(R.drawable.ic_bookmark_liked) + } else { + binding.favoriteButton.setImageResource(R.drawable.ic_bookmark_button) + } + } + + private fun initializeTitle(item: ItemResponse) { + binding.title.addFadeAnimation() + HtmlUtils().getHtmlText(binding.title, item.title) + } + + private fun initializeUserName(item: ItemResponse) { + if (item.username.isEmpty()) { + binding.username.visibility = View.GONE + } else { + binding.username.addFadeAnimation() + binding.username.visibility = View.VISIBLE + HtmlUtils().getHtmlText( + binding.username, + itemView.context.getString(R.string.favourite_username_text, item.username) + ) + } + } + + private fun setIngredientsButton( + ingredients: String, + directLink: String?, + ) { + try { + if (ingredients.isNotEmpty()) { + response = Gson().fromJson(ingredients, IngredientsResponse::class.java) + binding.ingredientsButton.setImageDrawable( + ContextCompat.getDrawable( + itemView.context, + R.drawable.ic_shopping_cart_button + ) + ) + } else { + directLink?.let { setWithoutIngredients(ingredients, it, null) } + } + } catch (e: Exception) { + directLink?.let { setWithoutIngredients(ingredients, it, null) } + e.printStackTrace() + } + } + + private fun setWithoutIngredients( + ingredients: String, + directLink: String, + supermarket: String? + ) { + response = IngredientsResponse(supermarket, null, ingredients, directLink) + + if (directLink.contains(DR_OETKER)) { + binding.ingredientsButton.setImageDrawable( + ContextCompat.getDrawable( + itemView.context, + R.drawable.dr_oetker_logo + ) + ) + } else if (directLink.contains(HELLOFRESH)) { + binding.ingredientsButton.setImageDrawable( + ContextCompat.getDrawable( + itemView.context, + R.drawable.hellofresh_logo + ) + ) + } else { + binding.ingredientsButton.setImageDrawable( + ContextCompat.getDrawable( + itemView.context, + R.drawable.ic_more_button + ) + ) + } + } + + private fun togglePlayState() { + binding.layout.foreground = null + + if (binding.videoControlsLayout.playButton.visibility == View.VISIBLE) { + binding.videoControlsLayout.playButton.visibility = View.GONE + } else { + binding.videoControlsLayout.playButton.visibility = View.VISIBLE + } + + if (binding.videoControlsLayout.backButton.visibility == View.VISIBLE) { + binding.videoControlsLayout.backButton.visibility = View.GONE + } else { + binding.videoControlsLayout.backButton.visibility = View.VISIBLE + } + + if (binding.videoControlsLayout.forwardButton.visibility == View.VISIBLE) { + binding.videoControlsLayout.forwardButton.visibility = View.GONE + } else { + binding.videoControlsLayout.forwardButton.visibility = View.VISIBLE + } + + binding.videoView.player?.playWhenReady = + binding.videoView.player?.playWhenReady?.not() ?: false + } + + fun initializeVideo() { + /* + * Only initialize Video when + * view is visible. + */ + val exoPlayer = mediaPlayer.initializeExoplayer() + mediaPlayer.prepare(item.uri!!) + binding.videoView.player = exoPlayer + + binding.videoView.hideController() + if (item.showThumbnail && item.imageUri.isNotEmpty()) { + binding.loadingFrame.postDelayed({ + if (itemView.isAttachedToWindow) { + binding.videoView.player?.playWhenReady = true + } + }, PAN_ANIMATION_DURATION) + } else { + binding.videoView.player?.playWhenReady = true + } + } + + fun showVideoWhenDoneLoading() { + val listener = object : EventListener { + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + super.onPlayerStateChanged(playWhenReady, playbackState) + + binding.videoView.keepScreenOn = + !(playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED || !playWhenReady) + if (playbackState == Player.STATE_READY) { + if (playWhenReady) { + /* + * Player is ready and starts playing. + */ + binding.layout.foreground = null + binding.loadingFrame.visibility = View.INVISIBLE + + binding.videoControlsLayout.playButton.visibility = View.GONE + binding.videoControlsLayout.backButton.visibility = View.GONE + binding.videoControlsLayout.forwardButton.visibility = View.GONE + + itemEventListener.onVideoStarted(adapterPosition) + // Only initialize this 1 time + binding.videoView.player?.duration?.toInt()?.let { + onVideoPlayed.onViewStarted( + it + ) + } + + binding.videoControlsLayout.backButtonHolder.setOnTouchListener { _, motionEvent -> + backwardButtonGestureDetector.onTouchEvent(motionEvent) + } + + binding.videoControlsLayout.playButtonHolder.setOnClickListener { + itemEventListener.onPlayButtonClicked(binding.videoView.player?.playWhenReady?.not()!!) + // start listening for clicks when video is ready. + togglePlayState() + } + + binding.videoControlsLayout.forwardButtonHolder.setOnClickListener { + itemEventListener.onForwardButtonClicked() + } + + binding.videoControlsLayout.forwardButtonHolder.setOnLongClickListener { + itemEventListener.onForwardButtonLongClicked() + true + } + + } else { + /* + * Cancel progressIndicators so that they stop incrementing. + */ + onVideoPlayed.cancel() + } + } + } + + override fun onPositionDiscontinuity(reason: Int) { + super.onPositionDiscontinuity(reason) + if (DISCONTINUITY_REASON_PERIOD_TRANSITION == reason) { + binding.videoView.player?.duration?.toInt()?.let { + onVideoPlayed.onViewStarted( + it + ) + } + } + } + } + + itemView.videoView.player?.addListener(listener) + itemView.videoView.tag = listener + } + + fun uninitialize() { + if (itemView.videoView.tag is EventListener) { + itemView.videoView.player?.removeListener(itemView.videoView.tag as EventListener) + } + + itemView.loadingFrame.visibility = View.VISIBLE + } + + fun onViewDetached() = binding.loadingFrame.cancelLoading() + + private fun isAddIngredientsEnabled() = BuildConfig.DEBUG + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemViewModel.kt new file mode 100644 index 00000000..ba265b7f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ItemViewModel.kt @@ -0,0 +1,640 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +import android.graphics.drawable.Drawable +import android.view.View +import androidx.databinding.ObservableField +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Timestamp +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.cache.VideoCacheManager +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.domain.home.* +import eu.theappfactory.dailyrecipes.domain.search.SearchItemsUseCase +import eu.theappfactory.dailyrecipes.domain.search.SearchParams +import eu.theappfactory.dailyrecipes.media.ImagePreLoader +import eu.theappfactory.dailyrecipes.model.* +import eu.theappfactory.dailyrecipes.model.ItemResponse.Companion.FIRST_TIME_VIDEO_IDS +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.networking.successOr +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.home.ErrorViewEventActions +import eu.theappfactory.dailyrecipes.ui.home.ItemEventActions +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_CHAT_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_FAVORITE_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_INGREDIENTS_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_SOCIAL_MEDIA_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.CLICKED_STORE_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.HAS_SEEN_ALL_VIDEOS +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.SWIPED_UP +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_PRESSES_BACKWARD_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_PRESSES_FASTFORWARD_BUTTON +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_PRESSES_PLAY_BUTTON +import eu.theappfactory.dailyrecipes.util.FileUtils +import eu.theappfactory.dailyrecipes.util.debounceUntilLast +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect +import org.json.JSONObject +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max +import kotlin.math.min + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Singleton +class ItemViewModel @Inject constructor( + signInViewModelDelegate: SignInViewModelDelegate, + private val saveFavoriteItemUseCase: SaveFavoriteItemUseCase, + private val getFirstTimeUseItemsUseCase: GetFirstTimeUseItemsUseCase, + private val updateUpdateAlreadySeenUseCase: UpdateAlreadySeenUseCase, + private val getItemsUserHasNotYetSeen: GetItemsUserHasNotYetSeen, + private val getItemsUseCase: GetItemsUseCase, + private val getFavoriteItemsUseCase: GetFavoriteItemsUseCase, + private val searchItemsUseCase: SearchItemsUseCase, + private val eraseAlreadySeenUseCase: EraseAlreadySeenUseCase, + private val updateLikesUseCase: UpdateLikesUseCase, + private val itemRepository: ItemRepository, + private val preferenceStorage: PreferenceStorage, + private val mixpanel: MixpanelAPI, + private val videoCacheManager: VideoCacheManager, + private val imagePreLoader: ImagePreLoader +) : ViewModel(), ItemEventActions, ErrorViewEventActions, + SignInViewModelDelegate by signInViewModelDelegate { + + var errorViewVisibility = ObservableField(View.GONE) + var viewPagerVisibility = ObservableField(View.GONE) + + private var _items = MutableLiveData>() + var items: LiveData> = _items + + private var _onSocialNetworkButtonClicked = MutableLiveData() + var onSocialNetworkButtonClicked: LiveData = _onSocialNetworkButtonClicked + + private var _onChatButtonClicked = MutableLiveData>() + var onChatButtonClicked: LiveData> = _onChatButtonClicked + + private var _onChatButtonLongClicked = MutableLiveData() + var onChatButtonLongClicked: LiveData = _onChatButtonLongClicked + + private var _onStoreButtonClicked = MutableLiveData() + var onStoreButtonClicked: LiveData = _onStoreButtonClicked + + private var _onSpotifyButtonClicked = MutableLiveData() + var onSpotifyButtonClicked: LiveData = _onSpotifyButtonClicked + + private var _showLoading = MutableLiveData() + var showLoading: LiveData = _showLoading + + private var _onPlayButtonClicked = MutableLiveData() + var onPlayButtonClicked: LiveData = _onPlayButtonClicked + + private var _onBackOrForwardButtonClicked = MutableLiveData() + var onForwardBackButtonClicked: LiveData = _onBackOrForwardButtonClicked + + private var _onBackOrForwardButtonLongClicked = MutableLiveData() + var onForwardBackButtonLongClicked: LiveData = _onBackOrForwardButtonLongClicked + + private var _onFirstTimeUseApp = MutableLiveData() + var onFirstTimeUseApp: LiveData = _onFirstTimeUseApp + + private var _onFirstTimeSwipedUp = MutableLiveData() + var onFirstTimeSwipedUp: LiveData = _onFirstTimeSwipedUp + + private var _onIngredientsButtonClicked = MutableLiveData>() + var onIngredientsButtonClicked: LiveData> = _onIngredientsButtonClicked + + private var _onAddIngredientsButtonClicked = MutableLiveData() + var onAddIngredientsButtonClicked: LiveData = _onAddIngredientsButtonClicked + + private var _onShareButtonClicked = MutableLiveData() + var onShareButtonClicked: LiveData = _onShareButtonClicked + + private var _onTryAgainButtonClicked = MutableLiveData() + var onTryAgainButtonClicked: LiveData = _onTryAgainButtonClicked + + private var _progressBarVisibility = MutableLiveData() + var progressBarVisibility: LiveData = _progressBarVisibility + + private var _timestamp = MutableLiveData() + var timestamp: LiveData = _timestamp + + private var _onImageUriFound = MutableLiveData() + var onImageUriFound: LiveData = _onImageUriFound + + private var _onFirstSaveSnackbar = MutableLiveData>() + var onFirstSaveSnackbar: LiveData> = _onFirstSaveSnackbar + + private val debounceFavoritesListener: (item: ItemResponse) -> Unit = debounceUntilLast(scope = viewModelScope) { + val json = JSONObject().apply { + put("title", it.title) + } + mixpanel.track(CLICKED_FAVORITE_BUTTON, json) + saveFavoriteItem(it) + } + + fun getItemsForNotification(imageUri: String) { + viewModelScope.launch { + onStartFetchingItems() + /* + * Get all new items + */ + when (val allItemsResult = getItemsUseCase(Unit)) { + is Result.Success -> currentFirebaseUser.collect { + getUserId()?.let { userId -> + + /* + * Then filter to have only the items + * that haven't been seen yet. + */ + val itemsUserHasNotSeen = + getItemsUserHasNotYetSeen( + Pair( + userId, + allItemsResult.data.items + ) + ) + + // Get favorites and remove items that are favorited ... + val favorites = + getFavoriteItemsUseCase(userId).successOr(emptyList()) + + /* + * Find item from push notification + */ + val pushItem = allItemsResult.data.items.find { it.imageUri == imageUri } + ?.let { item -> + favorites.find { it.imageUri == item.imageUri + + } + ?.let { + item.isFavorite = true + item + } ?: item + } + + val itemsWithoutFavorites = + (pushItem?.let { listOf(pushItem) } ?: emptyList()) + .plus( + makeListWithOnlyNotFavorites( + itemsUserHasNotSeen.data.orEmpty() + .filterByFoodType(preferenceStorage.foodCategories), + favorites + ) + ) + + if (itemsWithoutFavorites.size < 3) { + /* + * Reset when we only have 2 new items left. + */ + resetAllSeenItems(userId, imageUri) + mixpanel.track(HAS_SEEN_ALL_VIDEOS) + } else if (itemsWithoutFavorites.size >= 3) { + _items.postValue(itemsWithoutFavorites.distinct()) + // .. and then parallel, start preloading the next frames. + imagePreLoader.preloadFrames(itemsUserHasNotSeen.data.orEmpty()) + } + + onSuccessFetchingItems() + } + } + is Result.Error -> onErrorFetchingItems() + else -> Unit + } + } + } + + fun getItems() { + viewModelScope.launch { + + onStartFetchingItems() + /* + * First get 2 videos offline, first time, on phone + */ + val localItems = arrayListOf() + + // This functionality is for first time use and turned of for now. + // Because the user selects his categories now first, + // We should add specific video's per category here as offline items. +// if (preferenceStorage.offlineVideosHaveBeenShown.not()) { +// localItems = +// getFirstTimeUseItemsUseCase(Unit).data?.items?.filterByFoodType( +// preferenceStorage.foodCategories +// ) as ArrayList +// if (localItems.isNotEmpty()) { +// _items.postValue(localItems) +// onSuccessFetchingItems() +// } +// +// preferenceStorage.offlineVideosHaveBeenShown = true +// } + /* + * Then get all new items + */ + when (val allItemsResult = getItemsUseCase(Unit)) { + is Result.Success -> currentFirebaseUser.collect { + getUserId()?.let { + /* + * Then filter to have only the items + * that haven't been seen yet. + */ + val itemsUserHasNotSeen = + getItemsUserHasNotYetSeen( + Pair( + it, + allItemsResult.data.items + ) + ) + + // Get favorites and remove items that are favorited ... + val favorites = + getFavoriteItemsUseCase(it).successOr(emptyList()) + + val itemsWithoutFavorites = makeListWithOnlyNotFavorites( + itemsUserHasNotSeen.data.orEmpty() + .filterByFoodType(preferenceStorage.foodCategories), + favorites + ) + + if (itemsWithoutFavorites.size < 3) { + /* + * Reset when we only have 2 new items left. + */ + resetAllSeenItems(it) + mixpanel.track(HAS_SEEN_ALL_VIDEOS) + } else if (itemsWithoutFavorites.size >= 3) { + localItems.addAll(itemsWithoutFavorites) + _items.postValue(localItems) + // .. and then parallel, start preloading the next frames. + imagePreLoader.preloadFrames(itemsUserHasNotSeen.data.orEmpty()) + } + + onSuccessFetchingItems() + } + } + is Result.Error -> onErrorFetchingItems() + else -> Unit + } + } + } + + private suspend fun resetAllSeenItems(userId: String, imageUri: String = "") { + val eraseAlreadySeen = eraseAlreadySeenUseCase(userId) + + when (eraseAlreadySeen.data) { + is Result.Success -> { + // Get favorites + val favorites = getFavoriteItemsUseCase(userId) + .successOr(emptyList()) + .sortedByDescending { it.date } + //Get all items + val allItems = getItemsUseCase(Unit) + val allItemsWithoutFavorites = + makeListWithOnlyNotFavorites(allItems.data?.items.orEmpty() + .filterByFoodType(preferenceStorage.foodCategories), favorites) + // Get push item + val pushItem = allItems.data?.items?.find { it.imageUri == imageUri } + val pushItemsAsList = pushItem?.let { listOf(pushItem) } ?: emptyList() + _items.postValue(pushItemsAsList.plus(allItemsWithoutFavorites)) + } + } + } + + fun getFavoriteItems() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + onStartFetchingItems() + + val favorites = getFavoriteItemsUseCase(it) + .successOr(emptyList()) + .sortedByDescending { it.date } + + when (val remoteItems = getItemsUseCase(Unit)) { + is Result.Success -> { + val result = makeListWithOnlyFavorites( + remoteItems.data.items, + favorites + ) + val itemData = ItemData(result).apply { + itemType = ItemType.FAVORITE + } + + _items.postValue(itemData.items) + + imagePreLoader.preloadFrames(itemData.items) + onSuccessFetchingItems() + } + is Result.Error -> onErrorFetchingItems() + else -> Unit + } + } + } + } + } + + fun getSearchItems(query: String) { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + onStartFetchingItems() + + when (val remoteItems = searchItemsUseCase(SearchParams(it, query))) { + is Result.Success -> { + _items.postValue(remoteItems.data) + + imagePreLoader.preloadFrames(remoteItems.data) + onSuccessFetchingItems() + } + is Result.Error -> onErrorFetchingItems() + else -> Unit + } + } + } + } + } + + private fun saveFavoriteItem(item: ItemResponse) { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + val result = saveFavoriteItemUseCase( + FavoriteEventParameter( + it, + FavoriteItem(item.id, item.imageUri, item.isFavorite, Timestamp.now(), item.title) + ) + ) + if (FIRST_TIME_VIDEO_IDS.contains(item.id).not()) { + when (result.data) { + UpdatedStatus.FAVORITE -> updateItemLiked(item) + UpdatedStatus.NOT_FAVORITE -> updateItemUnLiked(item) + } + } + } + } + } + } + + override fun onFavoriteClicked(item: ItemResponse) { + debounceFavoritesListener(item) + if (isFirstTimeSave()){ + preferenceStorage.firstTimeSavingItem = false + _onFirstSaveSnackbar.postValue(Event(SnackbarMessage(messageId = R.string.item_first_favorite_snackbar_text,longDuration = true))) + } + } + + override fun onAlreadySeen(item: ItemResponse) { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + updateUpdateAlreadySeenUseCase( + AlreadySeenEventParameter( + it, + AlreadySeenItem( + id = item.id, + imageUri = item.imageUri, + alreadySeen = true + ) + ) + ) + } + } + } + mixpanel.track(SWIPED_UP) + } + + override fun onIngredientsButtonClicked(id: String, directLink: String) { + mixpanel.track(CLICKED_INGREDIENTS_BUTTON) + _onIngredientsButtonClicked.postValue(Pair(id, directLink)) + } + + override fun onAddIngredientsButtonClicked(id: String) { + _onAddIngredientsButtonClicked.postValue(id) + } + + override fun onShareButtonClicked(uri: String) { + _onShareButtonClicked.postValue(uri) + } + + override fun onSocialNetworkButtonClicked(socialNetworkHandle: String) { + mixpanel.track(CLICKED_SOCIAL_MEDIA_BUTTON) + _onSocialNetworkButtonClicked.postValue(socialNetworkHandle) + } + + override fun onChatButtonClicked(chats: List) { + mixpanel.track(CLICKED_CHAT_BUTTON) + _onChatButtonClicked.postValue(chats) + } + + override fun onChatButtonLongClicked(id: String) { + _onChatButtonLongClicked.postValue(id) + } + + override fun onStoreButtonClicked(storeLink: String) { + mixpanel.track(CLICKED_STORE_BUTTON) + _onStoreButtonClicked.postValue(storeLink) + } + + override fun onSpotifyButtonClicked(songId: String) { + _onSpotifyButtonClicked.postValue(songId) + } + + override fun resetAllSeenItems() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + resetAllSeenItems(it) + } + } + } + } + + private fun makeListWithOnlyFavorites( + items: List, + favorites: List + ): ArrayList { + val list = ArrayList() + + for (favorite in favorites) { + for (item in items) { + if (item.imageUri == favorite.imageUri) { + item.isFavorite = true + list.add(item) + } + } + } + + return list + } + + private fun makeListWithOnlyNotFavorites( + items: List, + favorites: List + ) = items.filterNot { item -> favorites.any { favorite -> favorite.imageUri == item.imageUri } } + + fun handleShake(itemResponse: ItemResponse) { + getUserId()?.let { + if (itemResponse.isFavorite) { + viewModelScope.launch(Dispatchers.IO) { + val itemData = itemRepository.retrieveRemoteItemData() + // The original item has the correct date + for (item in itemData.items) { + if (itemResponse.id == item.id) { + _timestamp.postValue(item.date) + } + } + } + } else { + _timestamp.postValue(itemResponse.date) + } + } + } + + override fun onPlayButtonClicked(isPlaying: Boolean) { + _onPlayButtonClicked.postValue(isPlaying) + mixpanel.track(USER_PRESSES_PLAY_BUTTON) + } + + override fun onBackwardButtonClicked() { + _onBackOrForwardButtonClicked.postValue(VideoControls.DECREMENTED) + mixpanel.track(USER_PRESSES_BACKWARD_BUTTON) + } + + override fun onBackwardButtonDoubleClicked() { + _onBackOrForwardButtonLongClicked.postValue(VideoControls.DECREMENTED) + mixpanel.track(USER_PRESSES_BACKWARD_BUTTON) + } + + override fun onBackwardButtonLongClicked() { + _onBackOrForwardButtonLongClicked.postValue(VideoControls.RESTARTED) + mixpanel.track(USER_PRESSES_BACKWARD_BUTTON) + } + + override fun onForwardButtonClicked() { + _onBackOrForwardButtonClicked.postValue(VideoControls.INCREMENTED) + mixpanel.track(USER_PRESSES_FASTFORWARD_BUTTON) + } + + override fun onForwardButtonLongClicked() { + _onBackOrForwardButtonLongClicked.postValue(VideoControls.INCREMENTED) + mixpanel.track(USER_PRESSES_FASTFORWARD_BUTTON) + } + + override fun onVideoStarted(position: Int) { + _progressBarVisibility.postValue(View.VISIBLE) + val currentList = _items.value.orEmpty() + videoCacheManager.preCacheVideos( + currentList.map { it.uriStr }.slice( + IntRange( + position + 1, + min(position + 3, currentList.size - 1) + ) + ) + ) + + imagePreLoader.loadFrameForItem(currentList.getOrNull(position + 1)) + } + + override fun onNextVideo() { + videoCacheManager.cancel() + imagePreLoader.cancel() + _progressBarVisibility.postValue(View.GONE) + } + + override fun onPlayFirstVideo() { + if (isFirstTime()) { + _onFirstTimeUseApp.postValue(true) + } + } + + override fun onFirstTimeSwipeUp() { + preferenceStorage.firstTimeUsingApp = false + _onFirstTimeSwipedUp.postValue(true) + } + + override fun onItemImageRetrieved(file: File, resource: Drawable) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + FileUtils().saveImageToFile(file, resource) + } + } + } + + override fun onTryAgainClicked() { + _onTryAgainButtonClicked.postValue(Unit) + } + + override fun onCleared() { + super.onCleared() + videoCacheManager.cancel() + imagePreLoader.cancel() + } + + fun onPageChanged() { + videoCacheManager.cancel() + imagePreLoader.cancel() + } + + private fun isFirstTime(): Boolean = preferenceStorage.firstTimeUsingApp + + private fun isFirstTimeSave(): Boolean = preferenceStorage.firstTimeSavingItem + + private suspend fun updateItemLiked(item: ItemResponse) { + val likes = (item.likes ?: 0).inc() + item.likes = likes + updateLikesUseCase(UpdateLikesParameter(item.id, likes)) + } + + private suspend fun updateItemUnLiked(item: ItemResponse) { + // to handle liking ones that are favorited already + val likes = max(0, (item.likes ?: 0).dec()) + item.likes = likes + updateLikesUseCase(UpdateLikesParameter(item.id, likes)) + } + + private fun onStartFetchingItems() { + _showLoading.postValue(true) + viewPagerVisibility.set(View.GONE) + errorViewVisibility.set(View.GONE) + } + + private fun onSuccessFetchingItems() { + _showLoading.postValue(false) + viewPagerVisibility.set(View.VISIBLE) + errorViewVisibility.set(View.GONE) + } + + private fun onErrorFetchingItems() { + _showLoading.postValue(false) + viewPagerVisibility.set(View.GONE) + errorViewVisibility.set(View.VISIBLE) + } + + fun getImageUriForToken(token: String) { + viewModelScope.launch { + when (val allItemsResult = getItemsUseCase(Unit)) { + is Result.Success -> { + for (item in allItemsResult.data.items) { + if (item.imageUri.contains(token)) { + _onImageUriFound.postValue(item.imageUri) + } + } + } + } + } + } +} + +enum class VideoControls { + INCREMENTED, + DECREMENTED, + RESTARTED +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ListItemType.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ListItemType.kt new file mode 100644 index 00000000..a336ca7f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/ListItemType.kt @@ -0,0 +1,7 @@ +package eu.theappfactory.dailyrecipes.ui.home.pager + +enum class ListItemType { + HOME, + FAVORITE, + SEARCH +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/di/ItemStepModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/di/ItemStepModule.kt new file mode 100644 index 00000000..903ca008 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/home/pager/di/ItemStepModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.home.pager.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.home.pager.ItemFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface ItemStepModule { + + @ContributesAndroidInjector + fun bindItemStepFragment(): ItemFragment +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientViewHolder.kt new file mode 100644 index 00000000..5ab34d42 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientViewHolder.kt @@ -0,0 +1,21 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ListItemAddIngredientBinding + +class AddIngredientViewHolder( + private val binding: ListItemAddIngredientBinding, + private val ingredientsEventListener: IngredientsEventActions +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: IngredientItem) { + binding.eventListener = ingredientsEventListener + binding.item = item + Glide.with(itemView.context).load(item.imageUrl) + .apply(RequestOptions().placeholder(R.drawable.loading_icon).dontAnimate()) + .into(binding.ingredientsImage) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsActivity.kt new file mode 100644 index 00000000..5f6230e0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsActivity.kt @@ -0,0 +1,147 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.EditText +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityAddIngredientsBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class AddIngredientsActivity : BaseActivity() { + + private lateinit var binding: ActivityAddIngredientsBinding + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + private lateinit var viewModel: AddIngredientsViewModel + private lateinit var adapter: AddIngredientsAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_add_ingredients) + + hideToolbar() + + viewModel = ViewModelProvider(this, viewModelFactory).get(AddIngredientsViewModel::class.java) + binding.viewModel = viewModel + val manager = GridLayoutManager(this, GRID_SPAN_SIZE) + binding.recycler.layoutManager = manager + + adapter = AddIngredientsAdapter(viewModel) + binding.recycler.adapter = adapter + + initViewModel() + snackbarMessageManager.observeNextMessage().observe(this, { + Snackbar.make(binding.root, it.peekContent().messageId, Snackbar.LENGTH_LONG).show() + }) + } + + private fun initViewModel() { + viewModel.apply { + loadData(intent.getStringExtra(EXTRA_ID).orEmpty()) + items.observe(this@AddIngredientsActivity, { + updateView(it) + }) + onSaveSuccessLiveData.observe(this@AddIngredientsActivity, { + finish() + }) + onSaveItemErrorLiveData.observe(this@AddIngredientsActivity, { + showErrors() + }) + onSaveErrorIngredientLiveData.observe(this@AddIngredientsActivity, { + showErrors() + }) + onAddIngredientsLiveData.observe(this@AddIngredientsActivity, { + showAddIngredientsDialog() + }) + } + } + + private fun updateView(items: List) { + if (items.any { it.checked }) { + binding.saveIngredientsButton.visibility = View.VISIBLE + } else { + binding.saveIngredientsButton.visibility = View.GONE + } + + when { + items.isEmpty() -> { + binding.recycler.visibility = View.GONE + if (viewModel.currentSearch.isEmpty()) { + binding.emptyLayout.emptyHolder.visibility = View.VISIBLE + binding.emptySearchLayout.emptyHolder.visibility = View.GONE + } else { + binding.emptyLayout.emptyHolder.visibility = View.GONE + binding.emptySearchLayout.emptyHolder.visibility = View.VISIBLE + } + } + else -> { + binding.recycler.visibility = View.VISIBLE + binding.emptyLayout.emptyHolder.visibility = View.GONE + binding.emptySearchLayout.emptyHolder.visibility = View.GONE + } + } + adapter.items = items + } + + private fun showErrors() { + snackbarMessageManager.addMessage( + SnackbarMessage( + messageId = R.string.snackbar_error_ingredients_storing, + requestChangeId = UUID.randomUUID().toString() + ) + ) + } + + private fun showAddIngredientsDialog() { + val promptsView = LayoutInflater.from(this).inflate(R.layout.dialog_input_layout, null) + MaterialAlertDialogBuilder( + this, + R.style.AlertDialogStyle + ).setView(promptsView) + .setTitle(R.string.search_ingredients_url_title) + .setPositiveButton( + android.R.string.ok + ) { _, _ -> + viewModel.getIngredientsFromSupermarket( + promptsView.findViewById(R.id.inputEditText).editableText.toString() + .trim() + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + + private const val EXTRA_ID = "id" + private const val GRID_SPAN_SIZE = 3 + + fun start(activity: Activity, id: String) { + val intent = Intent(activity, AddIngredientsActivity::class.java) + intent.putExtra(EXTRA_ID, id); + activity.startActivity(intent) + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsAdapter.kt new file mode 100644 index 00000000..c0bf3034 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsAdapter.kt @@ -0,0 +1,47 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemAddIngredientBinding + +class AddIngredientsAdapter(private val ingredientsEventListener: IngredientsEventActions) : + RecyclerView.Adapter() { + + var items = listOf() + set(newItems) { + val oldItems = listOf(items).flatten() + field = newItems + notifyChanges(oldItems, newItems) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddIngredientViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ListItemAddIngredientBinding.inflate(inflater, parent, false) + return AddIngredientViewHolder(binding, ingredientsEventListener) + } + + override fun onBindViewHolder(holder: AddIngredientViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + private fun notifyChanges( + oldList: List, + newList: List + ) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].imageUrl == newList[newItemPosition].imageUrl + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + }) + diff.dispatchUpdatesTo(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsViewModel.kt new file mode 100644 index 00000000..509d9d6a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/AddIngredientsViewModel.kt @@ -0,0 +1,155 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import androidx.lifecycle.* +import eu.theappfactory.dailyrecipes.domain.ingredients.* +import eu.theappfactory.dailyrecipes.model.toAddIngredientItem +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.networking.successOr +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class AddIngredientsViewModel @Inject constructor( + private val searchIngredientsUseCase: SearchIngredientsUseCase, + private val getItemIngredientsUseCase: GetItemIngredientsUseCase, + private val saveIngredientsUseCase: SaveIngredientsUseCase, + private val getIngredientFromAHUseCase: GetIngredientFromAHUseCase, + private val updateIngredientsUseCase: UpdateIngredientsUseCase, + private val uploadIngredientsImageUseCase: UploadIngredientsImageUseCase +) : + ViewModel(), IngredientsEventActions { + + private lateinit var itemId: String + var currentSearch: String = "" + private val _onAddIngredientsLiveData = MutableLiveData() + val onAddIngredientsLiveData: LiveData = _onAddIngredientsLiveData + private val _onSaveSuccessLiveData = MutableLiveData() + val onSaveSuccessLiveData: LiveData = _onSaveSuccessLiveData + private val _onSaveItemErrorLiveData = MutableLiveData() + val onSaveItemErrorLiveData: LiveData = _onSaveItemErrorLiveData + private val _onSaveErrorIngredientLiveData = MutableLiveData() + val onSaveErrorIngredientLiveData: LiveData = _onSaveErrorIngredientLiveData + private val _ingredientItems = MutableLiveData>() + private val _filteredIngredientItems = MutableLiveData>() + + val items = Transformations.map(_filteredIngredientItems) { + val sortedList = it.orEmpty().sortedBy { it.name } + sortedList + } + fun loadData(id: String) { + this.itemId = id + viewModelScope.launch { + val ingredientsResult = getItemIngredientsUseCase(id).data.orEmpty().map { + it.toAddIngredientItem(true, currentSearch) + } + _ingredientItems.postValue(ingredientsResult) + _filteredIngredientItems.postValue(ingredientsResult) + } + } + + fun searchIngredients(searchText: String) = + viewModelScope.launch { + currentSearch = searchText + val currentItems = _ingredientItems.value.orEmpty() + val searchItems = searchIngredientsUseCase(searchText).successOr(listOf()).filter { + !currentItems.containsItemByUrl(it.url) + } + + _filteredIngredientItems.postValue( + currentItems.map { it.copy(searchText = currentSearch) }.plus(searchItems.map { + it.toAddIngredientItem(currentItems.containsItemByUrl(it.url), currentSearch) + }).filterByName(searchText) + ) + } + + fun getIngredientsFromSupermarket(url: String) { + viewModelScope.launch { + val item = getIngredientFromAHUseCase(url) + when (item.data) { + is Result.Success -> item.data?.data?.let { + val uploadedImagePath = uploadIngredientsImageUseCase(it.imageUrl).data.orEmpty() + val updatedItem = it.copy(imageUrl = uploadedImagePath) + when (saveIngredientsUseCase( + updatedItem + )) { + is Result.Success -> _filteredIngredientItems.postValue( + _filteredIngredientItems.value.orEmpty() + .plus(updatedItem.toAddIngredientItem(false, currentSearch)) + ) + is Result.Error -> _onSaveErrorIngredientLiveData.postValue(Unit) + else -> Unit + } + } + is Result.Error -> _onSaveErrorIngredientLiveData.postValue(Unit) + else -> Unit + } + } + } + + fun saveIngredientsToItem() { + viewModelScope.launch { + val result = updateIngredientsUseCase( + UpdateIngredientsParameter( + itemId, + IngredientsUpdateData( + Supermarket.AH.supermarketName, + _ingredientItems.value.orEmpty() + .filter { + it.checked + }.map { it.url }) + ) + ) + + when (result.data) { + is Result.Success<*> -> _onSaveSuccessLiveData.postValue(Unit) + is Result.Error -> _onSaveItemErrorLiveData.postValue(Unit) + else -> Unit + } + } + } + + fun onAddIngredientsClicked() { + _onAddIngredientsLiveData.postValue(Unit) + } + + override fun onIngredientClicked(item: IngredientItem) { + val currentItems = _ingredientItems.value.orEmpty() + val containedInCurrentItems = currentItems.firstOrNull { it.url == item.url } != null + when { + containedInCurrentItems -> { + _ingredientItems.postValue( + currentItems.filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + + _filteredIngredientItems.postValue( + _filteredIngredientItems.value.orEmpty().filterByUrl(item.url).filterByName(currentSearch) + .plus(item.copy(checked = !item.checked)) + ) + } + else -> { + _ingredientItems.postValue( + currentItems.filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + _filteredIngredientItems.postValue( + _filteredIngredientItems.value.orEmpty().filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + .filterByName(currentSearch) + ) + } + } + } + + private fun List?.containsItemByUrl(url: String) = + this?.firstOrNull { it.url == url } != null + + private fun List.filterByUrl(url: String) = filter { it.url != url } + + private fun List.filterByName(name: String) = + filter { it.name.contains(name, true) } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientItem.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientItem.kt new file mode 100644 index 00000000..ccbdc69c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientItem.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +data class IngredientItem( + val name: String, + val imageUrl: String, + val supermarket: String, + val url: String, + val checked: Boolean, + val searchText: String? = null + ) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsActivity.kt new file mode 100644 index 00000000..aefe9a66 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsActivity.kt @@ -0,0 +1,171 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.data.ingredients.AHDataSource +import eu.theappfactory.dailyrecipes.databinding.ActivityIngredientsBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.webview.WebViewActivity +import eu.theappfactory.dailyrecipes.util.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class IngredientsActivity : BaseActivity() { + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var mixpanel: MixpanelAPI + + private lateinit var viewModel: IngredientsViewModel + private lateinit var adapter: IngredientsAdapter + private lateinit var binding: ActivityIngredientsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = + DataBindingUtil.setContentView(this, R.layout.activity_ingredients) + + binding.toolbar.toolbarTitle.text = getString(R.string.ingredients_activity_app_bar_name) + + viewModel = + ViewModelProvider(this, viewModelFactory).get(IngredientsViewModel::class.java) + binding.viewModel = viewModel + val manager = GridLayoutManager(this, GRID_SPAN_SIZE) + binding.recycler.layoutManager = manager + + adapter = IngredientsAdapter(viewModel) + binding.recycler.adapter = adapter + + binding.insertIngredientsButton.setOnClickListener { + startShoppingAppOrStartInsertingIngredients() + } + setToolbarBackButton(binding.toolbar.toolbar) + initViewModel() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK && requestCode == WebViewActivity.WEB_REQUEST_CODE) { + viewModel.setLoggedOut(false) + viewModel.insertIngredients() + } + } + + private fun initViewModel() { + viewModel.apply { + loadData(intent.getStringExtra(EXTRA_ID).orEmpty()) + + items.observe(this@IngredientsActivity, { + updateView(it) + }) + + showLoading.observe(this@IngredientsActivity, { + showLoading(it) + }) + + hideLoading.observe(this@IngredientsActivity, { + hideLoading() + }) + + ingredientsUploaded.observe(this@IngredientsActivity, { + when (it) { + IngredientsResult.IS_EMPTY -> { + showErrorMessage(R.string.snackbar_error_ingredients_empty) + } + IngredientsResult.IS_UPLOADED, IngredientsResult.IS_ALREADY_UPLOADED -> { + onIngredientsUploaded() + } + IngredientsResult.ERROR_FROM_SUPERMARKET -> { + showErrorMessage(R.string.snackbar_error_ingredients_storing) + } + else -> { + Timber.e("Error when uploading ingredients.") + } + } + }) + } + } + + private fun updateView(items: List) { + if (items.any { it.checked }) { + binding.insertIngredientsButton.visibility = View.VISIBLE + } else { + binding.insertIngredientsButton.visibility = View.GONE + } + adapter.items = items + } + + private fun onIngredientsUploaded() { + mixpanel.track(EventTracker.CLICKED_OPEN_SUPERMARKET_APP) + ShareUtils().launchPlayStoreLink(AH_PACKAGE_NAME, activity = this) + } + + private fun showErrorMessage(id: Int) { + viewModel.setLoggedOut(true) + + snackbarMessageManager.addMessage( + SnackbarMessage( + messageId = id, + requestChangeId = UUID.randomUUID().toString() + ) + ) + } + + private fun startShoppingAppOrStartInsertingIngredients() { + if (viewModel.loggedOut.value == true) { + WebViewActivity.startActivityForResult(this, AHDataSource.AH_LOGIN_URL) + + mixpanel.track(EventTracker.CLICKED_SUPERMARKET_LOGIN_BUTTON) + } else { + viewModel.insertIngredients() + } + } + + private fun showLoading(loadingTextId: Int) { + binding.loadingLayout.loadingHolder.setBackgroundColor( + ContextCompat.getColor( + this, + android.R.color.white + ) + ) + binding.loadingLayout.loadingText.text = getString(loadingTextId) + binding.loadingLayout.showWithAnimation() + } + + private fun hideLoading() = binding.loadingLayout.hide() + + companion object { + + private const val EXTRA_ID = "id" + private const val GRID_SPAN_SIZE = 3 + private const val AH_PACKAGE_NAME = "com.icemobile.albertheijn" + + fun start(activity: Activity, id: String) { + val intent = Intent(activity, IngredientsActivity::class.java) + intent.putExtra(EXTRA_ID, id) + activity.startActivity(intent) + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsAdapter.kt new file mode 100644 index 00000000..fa800ca0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsAdapter.kt @@ -0,0 +1,50 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemIngredientBinding + +class IngredientsAdapter(private val ingredientsEventListener: IngredientsEventActions) : + RecyclerView.Adapter() { + + var items = listOf() + set(newItems) { + val oldItems = listOf(items).flatten() + field = newItems + notifyChanges(oldItems, newItems) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): IngredientsViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ListItemIngredientBinding.inflate(inflater, parent, false) + return IngredientsViewHolder(binding, ingredientsEventListener) + } + + override fun onBindViewHolder(holder: IngredientsViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + private fun notifyChanges( + oldList: List, + newList: List + ) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].imageUrl == newList[newItemPosition].imageUrl + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + }) + diff.dispatchUpdatesTo(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsEventActions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsEventActions.kt new file mode 100644 index 00000000..9a3a1b0e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsEventActions.kt @@ -0,0 +1,7 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +interface IngredientsEventActions { + + fun onIngredientClicked(item: IngredientItem) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewHolder.kt new file mode 100644 index 00000000..b4031306 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewHolder.kt @@ -0,0 +1,22 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ListItemIngredientBinding + +class IngredientsViewHolder( + private val binding: ListItemIngredientBinding, + private val ingredientsEventListener: IngredientsEventActions +) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: IngredientItem) { + binding.item = item + binding.eventListener = ingredientsEventListener + Glide.with(itemView.context).load(item.imageUrl) + .apply(RequestOptions().placeholder(R.drawable.loading_icon).dontAnimate()) + .into(binding.ingredientsImage) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewModel.kt new file mode 100644 index 00000000..f5ca93bc --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/IngredientsViewModel.kt @@ -0,0 +1,137 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +import androidx.lifecycle.* +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.domain.home.InsertIngredientsIntoAHAppUseCase +import eu.theappfactory.dailyrecipes.domain.ingredients.GetItemIngredientsUseCase +import eu.theappfactory.dailyrecipes.domain.main.CheckIfUserNeedsToLoginUseCase +import eu.theappfactory.dailyrecipes.model.toIngredientItem +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class IngredientsViewModel @Inject constructor( + private val getItemIngredientsUseCase: GetItemIngredientsUseCase, + private var checkIfUserNeedsToLoginUseCase: CheckIfUserNeedsToLoginUseCase, + private var insertIngredientsIntoAHAppUseCase: InsertIngredientsIntoAHAppUseCase +) : ViewModel(), IngredientsEventActions { + + private var _loggedOut = MutableLiveData() + var loggedOut: LiveData = _loggedOut + + private var _showLoading = MutableLiveData() + var showLoading: LiveData = _showLoading + + private var _hideLoading = MutableLiveData() + var hideLoading: LiveData = _hideLoading + + private var _ingredientsUploaded = MutableLiveData() + var ingredientsUploaded: LiveData = _ingredientsUploaded + + private val _ingredients = MutableLiveData>() + private val _filteredIngredients = MutableLiveData>() + + val items = Transformations.map(_filteredIngredients) { + it.orEmpty().sortedBy { it.name } + } + fun loadData(id: String) { + viewModelScope.launch { + val result = checkIfUserNeedsToLoginUseCase("") + + when (result.data) { + is Result.Success -> { + _loggedOut.postValue(false) + } + is Result.Error -> { + _loggedOut.postValue(true) + } + else -> Unit + } + } + + viewModelScope.launch { + _showLoading.postValue(R.string.loading_text) + val ingredientsResult = getItemIngredientsUseCase(id).data.orEmpty() + .map { it.toIngredientItem() } + _ingredients.postValue(ingredientsResult) + _filteredIngredients.postValue(ingredientsResult) + _hideLoading.postValue(Unit) + } + } + + fun setLoggedOut(loggedOut: Boolean) = _loggedOut.postValue(loggedOut) + + fun insertIngredients() { + viewModelScope.launch { + when { + _ingredients.value.isNullOrEmpty() -> { + _ingredientsUploaded.postValue(IngredientsResult.IS_EMPTY) + } + _ingredientsUploaded.value != null && + _ingredientsUploaded.value != IngredientsResult.ERROR_FROM_SUPERMARKET -> _ingredientsUploaded.postValue( + IngredientsResult.IS_ALREADY_UPLOADED + ) + else -> { + _showLoading.postValue(R.string.loading_ingredients_text) + val result = + insertIngredientsIntoAHAppUseCase(_ingredients.value.orEmpty() + .filter { + it.checked + }.map { it.url }) + + when (result.data) { + is Result.Success -> { + _ingredientsUploaded.postValue(IngredientsResult.IS_UPLOADED) + _hideLoading.postValue(Unit) + } + is Result.Error -> { + _ingredientsUploaded.postValue(IngredientsResult.ERROR_FROM_SUPERMARKET) + _hideLoading.postValue(Unit) + } + else -> Unit + } + } + } + } + } + + override fun onIngredientClicked(item: IngredientItem) { + val currentItems = _ingredients.value.orEmpty() + val containedInCurrentItems = currentItems.firstOrNull { it.url == item.url } != null + when { + containedInCurrentItems -> { + _ingredients.postValue( + currentItems.filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + + _filteredIngredients.postValue( + _filteredIngredients.value.orEmpty().filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + } + else -> { + _ingredients.postValue( + currentItems.filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + _filteredIngredients.postValue( + _filteredIngredients.value.orEmpty().filterByUrl(item.url) + .plus(item.copy(checked = !item.checked)) + ) + } + } + } + + private fun List.filterByUrl(url: String) = filter { it.url != url } +} + +enum class IngredientsResult { + IS_EMPTY, + IS_ALREADY_UPLOADED, + IS_UPLOADED, + ERROR_FROM_SUPERMARKET +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/Supermarket.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/Supermarket.kt new file mode 100644 index 00000000..ea40791b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/Supermarket.kt @@ -0,0 +1,6 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients + +enum class Supermarket(val supermarketName: String) { + AH("albertheijn"), + JUMBO("jumbo") +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/di/IngredientsModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/di/IngredientsModule.kt new file mode 100644 index 00000000..6dd9f90e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/ingredients/di/IngredientsModule.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.ui.ingredients.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.ingredients.AddIngredientsActivity +import eu.theappfactory.dailyrecipes.ui.ingredients.IngredientsActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface IngredientsModule { + + @ContributesAndroidInjector + fun bindAddIngredientsActivity(): AddIngredientsActivity + + @ContributesAndroidInjector + fun bindIngredientsActivity(): IngredientsActivity +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsActivity.kt new file mode 100644 index 00000000..ff9606ca --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsActivity.kt @@ -0,0 +1,115 @@ +package eu.theappfactory.dailyrecipes.ui.instructions + +import android.content.Intent +import android.os.Bundle +import android.text.* +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityInstructionsBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.hide +import eu.theappfactory.dailyrecipes.util.showWithAnimation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class InstructionsActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var viewModel: InstructionsViewModel + + private lateinit var binding: ActivityInstructionsBinding + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = DataBindingUtil.setContentView(this, R.layout.activity_instructions) + binding.toolbar.toolbarTitle.text = getString(R.string.instructions_activity_app_bar_name) + viewModel = ViewModelProvider(this, viewModelFactory).get(InstructionsViewModel::class.java) + + initViewModel() + + binding.saveInstructionsButton.setOnClickListener { + viewModel.save( + binding.instructionsEditText.text.toString(), + Integer.valueOf(binding.amountPersonsEditText.text.toString()), + binding.notesEditText.text.toString(), + binding.variationTipEditText.text.toString() + ) + } + } + + private fun initViewModel() { + viewModel.apply { + loadData(intent.getStringExtra(EXTRA_ID).orEmpty()) + + showLoading.observe(this@InstructionsActivity, { + showLoading(it) + }) + + chatItems.observe(this@InstructionsActivity, { chats -> + var itemStr = "" + val instructions = chats.first().ingredients + if (!instructions.any { it.contains("-") }) { + for (ingredient in instructions) { + itemStr += "- $ingredient\n" + } + } else { + for (ingredient in instructions) { + itemStr += "$ingredient\n" + } + } + var preparationsStr = "" + val preparations = chats.first().preparations + if (!preparations.any { it.contains("-") }){ + for (preparation in preparations){ + preparationsStr += "- $preparation\n" + } + } else{ + for (preparation in preparations){ + preparationsStr += "$preparation\n" + } + } + binding.notesEditText.setText(preparationsStr) + binding.instructionsEditText.setText(itemStr) + binding.amountPersonsEditText.setText(chats.first().persons.toString()) + binding.variationTipEditText.setText(chats.first().message) + hideLoading() + }) + + instructionsUploaded.observe(this@InstructionsActivity, { item -> + val returnIntent = Intent() + returnIntent.putExtra(ITEM_ID, item.id) + setResult(RESULT_OK, returnIntent) + finish() + }) + } + } + + private fun showLoading(loadingTextId: Int) { + binding.loadingLayout.loadingHolder.setBackgroundColor( + ContextCompat.getColor( + this, + android.R.color.white + ) + ) + binding.loadingLayout.loadingText.text = getString(loadingTextId) + binding.loadingLayout.showWithAnimation() + } + + private fun hideLoading() = binding.loadingLayout.hide() + + companion object { + + const val EXTRA_ID = "id" + const val ITEM_ID = "ITEM_ID" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsViewModel.kt new file mode 100644 index 00000000..0b4cd543 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/InstructionsViewModel.kt @@ -0,0 +1,96 @@ +package eu.theappfactory.dailyrecipes.ui.instructions + +import android.os.Build +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.domain.instructions.GetItemUseCase +import eu.theappfactory.dailyrecipes.domain.instructions.UpdateChatsParameter +import eu.theappfactory.dailyrecipes.domain.instructions.UpdateChatsUseCase +import eu.theappfactory.dailyrecipes.model.ChatItem +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class InstructionsViewModel @Inject constructor( + private val getItemUseCase: GetItemUseCase, + private val updateChatsUseCase: UpdateChatsUseCase, + private val itemRepository: ItemRepository +) : ViewModel() { + + private var _showLoading = MutableLiveData() + var showLoading: LiveData = _showLoading + + private var _chatItems = MutableLiveData>() + var chatItems: LiveData> = _chatItems + + private var _instructionsUploaded = MutableLiveData() + var instructionsUploaded: LiveData = _instructionsUploaded + + private var id = "" + private var item: ItemResponse? = null + + fun loadData(id: String) { + this.id = id + + viewModelScope.launch { + _showLoading.postValue(R.string.loading_text) + item = getItemUseCase(id).data + _chatItems.postValue(item?.chats.orEmpty()) + } + } + + fun save(instructions: String, persons: Int, preparation: String, variationTip: String) { + val filteredInstructions = getFilteredText(instructions) + val filteredPreparation = getFilteredText(preparation) + val filteredVariationTip = getFilteredText(variationTip) + val list = filteredInstructions.split("\n").map { it.trim() } as MutableList + val preparationList = filteredPreparation.split("\n").map { it.trim() } as MutableList + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + list.removeIf(String::isEmpty) + list.removeIf { it.trim() == "-" } + preparationList.removeIf(String::isEmpty) + preparationList.removeIf { it.trim() == "-" } + } + val newChats = ChatItem( + item?.chats.orEmpty().first().user, + item?.chats.orEmpty().first().date, + persons, + filteredVariationTip.trim(), + list, + preparationList + ) + + viewModelScope.launch { + val result = updateChatsUseCase.invoke( + UpdateChatsParameter( + id, + newChats + ) + ) + + when (result) { + is Result.Success -> { + if (item != null) { + item?.chats = listOf(newChats) + itemRepository.updateChatItem(item!!) + _instructionsUploaded.postValue(item!!) + } + } + } + } + } + + private fun getFilteredText(text: String): String { + return text.replace(Regex("([^A-Za-z0-9\\s-.,:ëé'()])|((?m)(^ *| +(?= |\$)))"), "").replace( + Regex("[\n]{2,}"),"\n" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/di/InstructionsModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/di/InstructionsModule.kt new file mode 100644 index 00000000..b388ae0b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/instructions/di/InstructionsModule.kt @@ -0,0 +1,16 @@ +package eu.theappfactory.dailyrecipes.ui.instructions.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.instructions.InstructionsActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface InstructionsModule { + + @ContributesAndroidInjector + fun bindInstructionsActivity(): InstructionsActivity +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeActivity.kt new file mode 100644 index 00000000..047cc1a7 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeActivity.kt @@ -0,0 +1,28 @@ +package eu.theappfactory.dailyrecipes.ui.invite + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityInviteCodeBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity + +class InviteCodeActivity : BaseActivity() { + + private lateinit var binding: ActivityInviteCodeBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_invite_code) + hideToolbar() + } + + companion object { + + fun start(activity: Activity) { + val intent = Intent(activity, InviteCodeActivity::class.java) + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeFragment.kt new file mode 100644 index 00000000..35a6440f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeFragment.kt @@ -0,0 +1,112 @@ +package eu.theappfactory.dailyrecipes.ui.invite + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentInviteCodeBinding +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.register.phone.PhoneNumberRegistrationActivity +import eu.theappfactory.dailyrecipes.ui.setUpSnackbar +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.ViewUtils +import eu.theappfactory.dailyrecipes.util.hide +import eu.theappfactory.dailyrecipes.util.showWithAnimation +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class InviteCodeFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + private lateinit var snackbar: FadingSnackbar + + private lateinit var binding: FragmentInviteCodeBinding + + private lateinit var viewModel: InviteCodeViewModel + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentInviteCodeBinding.inflate( + inflater, + container, + false + ).apply { + lifecycleOwner = viewLifecycleOwner + } + + snackbar = binding.snackbar + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel = + ViewModelProvider(this, viewModelFactory).get(InviteCodeViewModel::class.java) + + viewModel.onSuccess.observe(viewLifecycleOwner, { success -> + if (success) { + PhoneNumberRegistrationActivity.start(requireActivity()) + requireActivity().finish() + } else { + binding.inviteCodeEditText.text?.clear() + showLoading(false) + } + }) + + binding.requestAccessButton.setOnClickListener { + checkInputAndInviteCode() + } + + ViewUtils().showKeyboard(binding.inviteCodeEditText) + setUpSnackbar(viewModel.errorLiveData, snackbar, snackbarMessageManager) + } + + private fun checkInputAndInviteCode() { + val length = binding.inviteCodeEditText.text?.length + + var codeStr = "" + if (length ?:0 > 0) { + codeStr = binding.inviteCodeEditText.text.toString() + } + + if (length == 0) { + binding.textInputLayout.error = getString(R.string.invite_code_error_empty_edittext) + return + } else if (codeStr.length != 5) { + binding.textInputLayout.error = getString(R.string.invite_code_error_edittext) + return + } else if ("[0-9\n]+".toRegex().matches(codeStr).not()) { + binding.textInputLayout.error = getString(R.string.invite_code_error_chars_edittext) + return + } else { + showLoading(true) + viewModel.checkCode(codeStr.toLong()) + } + } + + private fun showLoading(loading: Boolean) { + binding.loadingLayout.loadingText.text = getString(R.string.invite_code_check_code) + + if (loading) { + ViewUtils().hideKeyboard(binding.invitationCodeTextView) + binding.loadingLayout.showWithAnimation() + } else { + binding.loadingLayout.hide() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeViewModel.kt new file mode 100644 index 00000000..bd2981a5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/InviteCodeViewModel.kt @@ -0,0 +1,69 @@ +package eu.theappfactory.dailyrecipes.ui.invite + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.domain.invite.CheckInviteCodeUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.util.Event +import kotlinx.coroutines.launch + +class InviteCodeViewModel(private val checkInviteCodeUseCase: CheckInviteCodeUseCase, + private val preferenceStorage: PreferenceStorage +): ViewModel() { + + private var _onSuccess = MutableLiveData() + var onSuccess: LiveData = _onSuccess + + private val _errorLiveData = MutableLiveData>() + val errorLiveData: LiveData> = _errorLiveData + + var codeCounter = 0 + + fun checkCode(code: Long) { + if (codeCounter == 3) { + _errorLiveData.postValue(Event( + SnackbarMessage( + messageId = R.string.invite_code_error_code_too_many_tries_snackbar, + actionId = R.string.dont_show, + true + )) + ) + } + + viewModelScope.launch { + val result = checkInviteCodeUseCase.invoke(code) + + when (result.data) { + is Result.Success -> { + preferenceStorage.codeHasBeenSuccesfullyUsed = true + _onSuccess.postValue(true) + } + is Result.Error -> { + var messageId = R.string.invite_code_error_message_edittext + + if ((result.data as Result.Error).exception.message == "Code has already been used.") { + messageId = R.string.invite_code_error_code_used_snackbar + } else if ((result.data as Result.Error).exception.message == "Code has not been found.") { + messageId = R.string.invite_code_error_code_not_found_snackbar + } + + _errorLiveData.postValue(Event( + SnackbarMessage( + messageId = messageId, + actionId = R.string.dont_show, + true + )) + ) + _onSuccess.postValue(false) + codeCounter++ + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsAdapter.kt new file mode 100644 index 00000000..da5df8b6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsAdapter.kt @@ -0,0 +1,26 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ContactsListItemBinding +import eu.theappfactory.dailyrecipes.model.Contact + +class ContactsAdapter : RecyclerView.Adapter() { + + var items: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactsHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ContactsListItemBinding.inflate(inflater, parent, false) + return ContactsHolder(binding, parent.context) + } + + override fun onBindViewHolder(holder: ContactsHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getItemCount(): Int = items.size +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsHolder.kt new file mode 100644 index 00000000..22207207 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/ContactsHolder.kt @@ -0,0 +1,25 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ContactsListItemBinding +import eu.theappfactory.dailyrecipes.model.Contact + +class ContactsHolder( + val binding: ContactsListItemBinding, + private var context: Context +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(contact: Contact) { + binding.displayName.text = contact.displayName + + val drawable = if (contact.isEven) { + ContextCompat.getDrawable(context, R.drawable.ic_contacts) + } else { + ContextCompat.getDrawable(context, R.drawable.ic_contacts_inverted) + } + binding.contactsIcon.setImageDrawable(drawable) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsFragment.kt new file mode 100644 index 00000000..df243176 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsFragment.kt @@ -0,0 +1,101 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentInviteFriendsBinding +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesActivity +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.hide +import eu.theappfactory.dailyrecipes.util.showWithAnimation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class InviteFriendsFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var binding: FragmentInviteFriendsBinding + + private lateinit var viewModel: InviteFriendsViewModel + + var adapter: ContactsAdapter? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentInviteFriendsBinding.inflate( + inflater, + container, + false + ).apply { + lifecycleOwner = viewLifecycleOwner + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel = + ViewModelProvider(this, viewModelFactory).get(InviteFriendsViewModel::class.java) + + viewModel.onAddressbookResult.observe(viewLifecycleOwner, { addressBook -> + loadAddressBook(addressBook) + }) + + showLoading(true) + viewModel.loadAddressBook() + + binding.inviteViaSMSButton.setOnClickListener { + startActivity(Intent(requireContext(), CategoriesActivity::class.java)) + requireActivity().finish() + } + } + + private fun initializeRecyclerView() { + val manager = activity?.let { LinearLayoutManager(it) } + binding.recycler.layoutManager = manager + binding.recycler.adapter = adapter + binding.recycler.addItemDecoration( + DividerItemDecoration( + activity, + DividerItemDecoration.VERTICAL + ) + ) + } + + private fun loadAddressBook(contacts: ArrayList) { + adapter = ContactsAdapter() + adapter?.items = contacts + adapter?.setHasStableIds(true) + initializeRecyclerView() + adapter?.notifyDataSetChanged() + showLoading(false) + } + + private fun showLoading(loading: Boolean) { + binding.loadingLayout.loadingText.text = + getString(R.string.invite_friends_import_addressbook) + + if (loading) { + binding.loadingLayout.showWithAnimation() + } else { + binding.loadingLayout.hide() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionActivity.kt new file mode 100644 index 00000000..5dec78e8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionActivity.kt @@ -0,0 +1,28 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityInviteFriendsPermissionBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity + +class InviteFriendsPermissionActivity : BaseActivity() { + + private lateinit var binding: ActivityInviteFriendsPermissionBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_invite_friends_permission) + hideToolbar() + } + + companion object { + + fun start(activity: Activity) { + val intent = Intent(activity, InviteFriendsPermissionActivity::class.java) + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionFragment.kt new file mode 100644 index 00000000..bb586e4b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsPermissionFragment.kt @@ -0,0 +1,69 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import android.Manifest +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.navigation.fragment.findNavController +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentInviteFriendsPermissionBinding +import eu.theappfactory.dailyrecipes.ui.PopupDialog +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class InviteFriendsPermissionFragment : DaggerFragment() { + + private lateinit var binding: FragmentInviteFriendsPermissionBinding + + private val requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + findNavController().navigate(R.id.inviteFriendsFragment2) + } else { + PopupDialog(requireActivity()).showWithNoYesNotCancellable( + getString(R.string.invite_friends_dialog_title), + getString(R.string.invite_friends_dialog_text), + object : PopupDialog.ButtonCallback { + override fun onButtonClicked(button: PopupDialog.Companion.Button) { + when (button) { + PopupDialog.Companion.Button.POSITIVE_BTN -> requestPermission() + PopupDialog.Companion.Button.NEGATIVE_BTN -> MainActivity.start(requireContext(), url = null) + } + } + }) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentInviteFriendsPermissionBinding.inflate( + inflater, + container, + false + ).apply { + lifecycleOwner = viewLifecycleOwner + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.openContactsButton.setOnClickListener { + requestPermission() + } + } + + private fun requestPermission() { + requestPermission.launch(Manifest.permission.READ_CONTACTS) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsViewModel.kt new file mode 100644 index 00000000..df1b22a7 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/addressbook/InviteFriendsViewModel.kt @@ -0,0 +1,51 @@ +package eu.theappfactory.dailyrecipes.ui.invite.addressbook + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import eu.theappfactory.dailyrecipes.domain.invite.LoadAddressBookUseCase +import eu.theappfactory.dailyrecipes.domain.invite.UploadAddressBookUseCase +import eu.theappfactory.dailyrecipes.model.Contact +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +class InviteFriendsViewModel( + private val addressBookUseCase: LoadAddressBookUseCase, + private val preferenceStorage: PreferenceStorage, + private val uploadAddressBookUseCase: UploadAddressBookUseCase, + signInViewModelDelegate: SignInViewModelDelegate +) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate { + + private var _onAddressbookResult = MutableLiveData>() + var onAddressbookResult: LiveData> = _onAddressbookResult + + fun loadAddressBook() { + viewModelScope.launch { + val result = addressBookUseCase.invoke(Unit) + _onAddressbookResult.postValue(result.data) + uploadAddressBook(result.data) + } + } + + private fun uploadAddressBook(contactList: ArrayList?) { + viewModelScope.launch { + contactList?.let { + currentFirebaseUser.collect { + getUserId()?.let { + val result = uploadAddressBookUseCase(Pair(it, contactList)) + when (result) { + is Result.Success -> { + preferenceStorage.userHasAddedFriends = true + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/di/InvitationCodeModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/di/InvitationCodeModule.kt new file mode 100644 index 00000000..77d2f2ef --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/invite/di/InvitationCodeModule.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.ui.invite.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.invite.InviteCodeActivity +import eu.theappfactory.dailyrecipes.ui.invite.InviteCodeFragment +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsFragment +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsPermissionActivity +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsPermissionFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface InvitationCodeModule { + + // Activities + @ContributesAndroidInjector + fun bindInviteCodeActivity() : InviteCodeActivity + + @ContributesAndroidInjector + fun bindInviteFriendsPermissionActivity() : InviteFriendsPermissionActivity + + // Fragments + @ContributesAndroidInjector + fun bindInviteFragment() : InviteCodeFragment + + @ContributesAndroidInjector + fun bindInviteFriendsPermissionFragment() : InviteFriendsPermissionFragment + + @ContributesAndroidInjector + fun bindInviteFriendsFragment() : InviteFriendsFragment + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherActivity.kt new file mode 100644 index 00000000..4b52ad34 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherActivity.kt @@ -0,0 +1,204 @@ +package eu.theappfactory.dailyrecipes.ui.launch + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.ViewModelProvider +import com.google.firebase.auth.FirebaseAuth +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.push.PushNotificationHandler.Companion.NOTIFICATION_EXTRA_URI +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesActivity +import eu.theappfactory.dailyrecipes.ui.invite.InviteCodeActivity +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsPermissionActivity +import eu.theappfactory.dailyrecipes.ui.launch.LaunchDestination.* +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.ui.register.createuser.CreateUsernameActivity +import eu.theappfactory.dailyrecipes.ui.register.phone.PhoneNumberRegistrationActivity +import eu.theappfactory.dailyrecipes.util.EventObserver +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_INSTALLED_APP +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.checkAllMatched +import eu.theappfactory.dailyrecipes.util.signin.FirebaseAuthErrorCodeConverter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class LauncherActivity : BaseActivity() { + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var signInHandler: SignInHandler + + @Inject + lateinit var mixpanel: MixpanelAPI + lateinit var viewModel: LauncherViewModel + lateinit var activity: Activity + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideToolbar() + activity = this + + viewModel = ViewModelProvider(this, viewModelFactory).get(LauncherViewModel::class.java) + + viewModel.navigateToUserPreferencesActivity.observe(this, EventObserver { + this.run { + startActivity(Intent(this, CategoriesActivity::class.java)) + finish() + } + }) + + viewModel.navigateToInviteCode.observe(this, EventObserver { + InviteCodeActivity.start(activity) + finish() + }) + + viewModel.navigateToLoginRegister.observe(this, EventObserver { + loginRegister() + }) + + viewModel.navigateToCreateUsername.observe(this, EventObserver { + CreateUsernameActivity.start(activity) + finish() + }) + + viewModel.onUserInitialized.observe(this, { + chooseActivity() + }) + + viewModel.initializeUser() + mixpanel.track(USER_INSTALLED_APP) + } + + private fun chooseActivity() { + viewModel.launchDestination.observe(this, EventObserver { destination -> + /* + * Flow: + * + * 1. InviteCode + * 2. LoginRegister + * 3. CreateUsername + * 4. AddFriends + * 5. FoodCategories + */ + when (destination) { + INVITE_CODE -> { + InviteCodeActivity.start(activity) + finish() + } + + LOGIN_OR_REGISTER_ON_FIREBASE -> loginRegister() + + // We don't store phone number yet locally, so relaunch PhoneNumber flow + FIRSTNAME_LASTNAME -> loginRegister() + + CREATE_USERNAME -> { + CreateUsernameActivity.start(activity) + finish() + } + + ADD_FRIENDS -> { + startActivity( + Intent( + this, + InviteFriendsPermissionActivity::class.java + ) + ) + finish() + } + + USER_FOOD_CATEGORIES -> { + startActivity( + Intent( + this, + CategoriesActivity::class.java + ).putNotificationExtra() + ) + finish() + } + + MAIN_ACTIVITY -> { + startActivity( + Intent( + this, + MainActivity::class.java + ).putNotificationExtra() + ) + finish() + } + + }.checkAllMatched + }) + } + + private fun loginRegister() { + PhoneNumberRegistrationActivity.start(this) + finish() + /** + * Logging in with e-mail is for now disabled but will be enabled in the future. + */ + /* lifecycleScope.launch { + val customLayout = + AuthMethodPickerLayout.Builder(R.layout.activity_launcher) + .setGoogleButtonId(R.id.googleSignin) + .setFacebookButtonId(R.id.facebookSignin) + .setEmailButtonId(R.id.emailSignin) + .build() + val theme = R.style.Theme_MaterialComponents_NoActionBar + val result = signInHandler.makeSignInIntent(customLayout, theme) + startActivityForResult(result, SIGN_IN_ACTIVITY_REQUEST_CODE) + }*/ + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == SIGN_IN_ACTIVITY_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK) { + // Successfully signed in + val user = FirebaseAuth.getInstance().currentUser + if (user != null) { + mixpanel.people.identify(user.uid) + } + //TODO: store user locally + //TODO: When logging in with Facebook, + // FirebaseUiException error is thrown, + // but val user is still correct? + viewModel.isCompleted() + + startActivity(Intent(this, MainActivity::class.java).putNotificationExtra()) + finish() + } else { + // Sign in failed. If response is null the user canceled the + // sign-in flow using the back button. Otherwise check + // response.getError().getErrorCode() and handle the error. + // ... + snackbarMessageManager.addMessage( + SnackbarMessage( + messageId = FirebaseAuthErrorCodeConverter.convert(resultCode), + requestChangeId = UUID.randomUUID().toString() + ) + ) + } + } + } + + private fun Intent.putNotificationExtra() = + putExtra(NOTIFICATION_EXTRA_URI, intent.getStringExtra(NOTIFICATION_EXTRA_URI)) + + companion object { + + const val SIGN_IN_ACTIVITY_REQUEST_CODE = 42 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherViewModel.kt new file mode 100644 index 00000000..5c78d004 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/LauncherViewModel.kt @@ -0,0 +1,149 @@ +package eu.theappfactory.dailyrecipes.ui.launch + +import androidx.lifecycle.* +import com.google.firebase.auth.FirebaseAuth +import eu.theappfactory.dailyrecipes.domain.launch.RegisterOrLoginCompleteUseCase +import eu.theappfactory.dailyrecipes.domain.launch.RegisterOrLoginCompletedUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.UserFoodCategoriesCompletedUseCase +import eu.theappfactory.dailyrecipes.domain.userdetails.GetUserUseCase +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.networking.successOr +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.register.phone.PhoneNumberRegistrationFragment.Companion.PHONE_NUMBER_LENGTH +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class LauncherViewModel( + signInViewModelDelegate: SignInViewModelDelegate, + private val registerOrLoginCompletedUseCase: RegisterOrLoginCompletedUseCase, + private val registerOrLoginCompleteUseCase: RegisterOrLoginCompleteUseCase, + private val userFoodCategoriesCompletedUseCase: UserFoodCategoriesCompletedUseCase, + private val getUserUseCase: GetUserUseCase, + private val preferenceStorage: PreferenceStorage, + private var auth: FirebaseAuth +) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate { + + private val registeringOrLoggingInCompletedResult = + flow { emit(registerOrLoginCompletedUseCase(Unit).successOr(false)) }.catch { e -> e.printStackTrace() } + private val userFoodCategoriesCompletedResult = + flow { emit(userFoodCategoriesCompletedUseCase(Unit).successOr(false)) }.catch { e -> e.printStackTrace() } + + private val _navigateToUserPreferencesActivity = MutableLiveData>() + val navigateToUserPreferencesActivity: LiveData> = + _navigateToUserPreferencesActivity + + private val _navigateToInviteCode = MutableLiveData>() + val navigateToInviteCode: LiveData> = _navigateToInviteCode + + private val _navigateToLoginRegister = MutableLiveData>() + val navigateToLoginRegister: LiveData> = _navigateToLoginRegister + + private val _navigateToCreateUsername = MutableLiveData>() + val navigateToCreateUsername: LiveData> = _navigateToCreateUsername + + private val _onUserInitialized = MutableLiveData() + val onUserInitialized: LiveData = _onUserInitialized + + private var user = UserResponse() + + val launchDestination = + registeringOrLoggingInCompletedResult.combine(userFoodCategoriesCompletedResult) { registerCompleted, userFoodCategoriesCompleted -> + /* + * Flow: + * + * 1. InviteCode + * 2. LoginRegister: phone number + OTP or e-mail address + * 3. CreateUsername + * 4. AddFriends + * 5. FoodCategories + */ + when { + preferenceStorage.codeHasBeenSuccesfullyUsed.not() -> { + Event(LaunchDestination.INVITE_CODE) + } + + user.phoneNumber.isEmpty() -> { + Event(LaunchDestination.LOGIN_OR_REGISTER_ON_FIREBASE) + } + + user.phoneNumber.length == PHONE_NUMBER_LENGTH && auth.currentUser != null && user.firstName.isEmpty() || user.lastName.isEmpty() -> { + Event(LaunchDestination.FIRSTNAME_LASTNAME) + } + + registerCompleted && user.username.isEmpty() -> { + Event(LaunchDestination.CREATE_USERNAME) + } +//TODO: DRA-104 + registerCompleted && preferenceStorage.userHasAddedFriends.not() -> { + Event(LaunchDestination.ADD_FRIENDS) + } + + registerCompleted && !userFoodCategoriesCompleted -> { + Event(LaunchDestination.USER_FOOD_CATEGORIES) + } + + registerCompleted && user.username.isNotEmpty() -> { + Event(LaunchDestination.MAIN_ACTIVITY) + } + + else -> Event(LaunchDestination.LOGIN_OR_REGISTER_ON_FIREBASE) + } + }.asLiveData() + + fun initializeUser() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId().let { userId -> + if (userId != null) { + val result = getUserUseCase(userId) + + when (result) { + is Result.Success -> { + user = result.data.data ?: UserResponse() + _onUserInitialized.postValue(Unit) + } + is Result.Error -> { + _navigateToCreateUsername.postValue(Event(Unit)) + } + } + } else { + //TODO: DRA-104 + if (preferenceStorage.codeHasBeenSuccesfullyUsed) { + _navigateToLoginRegister.postValue(Event(Unit)) + } else { + _navigateToInviteCode.postValue(Event(Unit)) + } + } + } + } + } + } + + fun isCompleted() { + viewModelScope.launch { + registerOrLoginCompleteUseCase(true) + _navigateToUserPreferencesActivity.value = Event(Unit) + } + } +} + +enum class LaunchDestination { + INVITE_CODE, + LOGIN_OR_REGISTER_ON_FIREBASE, + FIRSTNAME_LASTNAME, + CREATE_USERNAME, + USER_FOOD_CATEGORIES, + ADD_FRIENDS, + MAIN_ACTIVITY +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/launch/SignInHandler.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/SignInHandler.kt similarity index 69% rename from app/src/main/java/eu/theappfactory/sampleapp/ui/launch/SignInHandler.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/SignInHandler.kt index f13f14be..08dd9195 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/launch/SignInHandler.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/SignInHandler.kt @@ -1,8 +1,10 @@ -package eu.theappfactory.sampleapp.ui.launch +package eu.theappfactory.dailyrecipes.ui.launch import android.content.Context import android.content.Intent +import com.firebase.ui.auth.AuthMethodPickerLayout import com.firebase.ui.auth.AuthUI +import eu.theappfactory.dailyrecipes.BuildConfig import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -13,7 +15,7 @@ import kotlinx.coroutines.withContext */ interface SignInHandler { - suspend fun makeSignInIntent(): Intent + suspend fun makeSignInIntent(layout: AuthMethodPickerLayout, theme: Int): Intent suspend fun signOut(context: Context, onComplete: () -> Unit = {}) } @@ -30,18 +32,24 @@ class DefaultSignInHandler( * * To observe the result you must pass this to startActivityForResult. */ - override suspend fun makeSignInIntent(): Intent { + override suspend fun makeSignInIntent(layout: AuthMethodPickerLayout, theme: Int): Intent { return withContext(ioDispatcher) { // Choose authentication providers val providers = arrayListOf( AuthUI.IdpConfig.EmailBuilder().build(), AuthUI.IdpConfig.GoogleBuilder().build() -// AuthUI.IdpConfig.FacebookBuilder().build() +// TODO doesn't work: AuthUI.IdpConfig.FacebookBuilder().build() ) + // Create and launch sign-in intent AuthUI.getInstance() .createSignInIntentBuilder() + .setTosAndPrivacyPolicyUrls( + BuildConfig.TERMS_AND_CONDITIONS, + BuildConfig.PRIVACY_AND_COOKIE_STATEMENT) + .setTheme(theme) .setAvailableProviders(providers) + .setAuthMethodPickerLayout(layout) .build() } } diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/LauncherModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/LauncherModule.kt new file mode 100644 index 00000000..135eeb4a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/LauncherModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.launch.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.launch.LauncherActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface LauncherModule { + + @ContributesAndroidInjector + fun bindLauncherActivity(): LauncherActivity +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/SignInModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/SignInModule.kt new file mode 100644 index 00000000..8d9ab3e0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/launch/di/SignInModule.kt @@ -0,0 +1,37 @@ +package eu.theappfactory.dailyrecipes.ui.launch.di + +import com.google.firebase.auth.FirebaseAuth +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.data.signin.AuthStateUserDataSource +import eu.theappfactory.dailyrecipes.data.signin.FirebaseAuthStateUserDataSource +import eu.theappfactory.dailyrecipes.di.IoDispatcher +import eu.theappfactory.dailyrecipes.ui.launch.DefaultSignInHandler +import eu.theappfactory.dailyrecipes.ui.launch.SignInHandler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Singleton + +@ExperimentalCoroutinesApi +@Module +internal class SignInModule { + + @Provides + fun provideSignInHandler(@IoDispatcher ioDispatcher: CoroutineDispatcher): SignInHandler { + return DefaultSignInHandler(ioDispatcher) + } + + @Singleton + @Provides + fun provideAuthStateUserDataSource( + firebase: FirebaseAuth + ): AuthStateUserDataSource { + return FirebaseAuthStateUserDataSource(firebase) + } + + @Singleton + @Provides + fun provideFirebaseAuth(): FirebaseAuth { + return FirebaseAuth.getInstance() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/MainActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/MainActivity.kt new file mode 100644 index 00000000..199c0a85 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/MainActivity.kt @@ -0,0 +1,152 @@ +package eu.theappfactory.dailyrecipes.ui.main + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.core.os.bundleOf +import androidx.databinding.DataBindingUtil +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.navigation.ui.setupWithNavController +import com.firebase.ui.auth.IdpResponse +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityMainBinding +import eu.theappfactory.dailyrecipes.media.MediaPlayer +import eu.theappfactory.dailyrecipes.push.PushNotificationHandler.Companion.NOTIFICATION_EXTRA_URI +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.deeplink.DeeplinkActivity.Companion.TOKEN +import eu.theappfactory.dailyrecipes.ui.home.pager.ItemFragment +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.signin.FirebaseAuthErrorCodeConverter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class MainActivity : BaseActivity() { + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var mixpanel: MixpanelAPI + + @Inject + lateinit var mediaPlayer: MediaPlayer + + private lateinit var binding: ActivityMainBinding + + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = DataBindingUtil.setContentView( + this, R.layout.activity_main + ).apply { + lifecycleOwner = this@MainActivity + } + + navController = findNavController(R.id.nav_host_fragment) + binding.navView.setupWithNavController(navController) + binding.navView.setOnNavigationItemReselectedListener { + when (it.itemId) { + R.id.navigation_home -> supportFragmentManager.findFragmentById(R.id.nav_host_fragment)?.childFragmentManager?.fragments?.firstOrNull() + ?.let { + if (it is ItemFragment) it.loadNextVideo() + } + else -> navController.navigate(it.itemId) + } + } + + intent.getStringExtra(NOTIFICATION_EXTRA_URI)?.let { imageUri -> + var uri = imageUri + if (imageUri.contains(IMAGE_URI_DOMAIN).not()) { + uri = IMAGE_URI_DOMAIN + uri + } + + navController.navigate(R.id.navigation_home, bundleOf(NOTIFICATION_EXTRA_URI to uri)) + } + + intent.getStringExtra(TOKEN)?.let { token -> + navController.navigate(R.id.navigation_home, bundleOf(TOKEN to token)) + } + } + + override fun onDestroy() { + mixpanel.flush() + super.onDestroy() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + // Handle errors coming from Firebase Auth - e.g. user cancels flow + if (resultCode == Activity.RESULT_CANCELED) { + Timber.d("Main Activity: An activity returned RESULT_CANCELED") + val response = IdpResponse.fromResultIntent(data) + response?.error?.let { + snackbarMessageManager.addMessage( + SnackbarMessage( + messageId = FirebaseAuthErrorCodeConverter.convert(it.errorCode), + requestChangeId = UUID.randomUUID().toString() + ) + ) + } + } + } + + /** + * After 30 seconds, the backlight is turned off. + * This method overrides that behaviour and keeps + * the screen at the current brightness. + */ + fun keepScreenOn(activity: Activity) { + activity.window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + fun disableKeepScreenOn(activity: Activity) { + activity.window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + fun onPlayButtonClicked(isPlaying: Boolean, isFavorite: Boolean = false) { + if (isFavorite.not()) { + /* + * Favorite video's must always keep screen on, + * also when pausing the video. + * because this is used for cooking. + */ + if (isPlaying) { + keepScreenOn(this) + } else { + disableKeepScreenOn(this) + } + } + } + + companion object { + const val IMAGE_URI_DOMAIN = "https://firebasestorage.googleapis.com/v0/b" + + fun start(context: Context, url: String? = null) { + val intent = Intent(context, MainActivity::class.java) + intent.putExtra(TOKEN, url) + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/di/MainModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/di/MainModule.kt new file mode 100644 index 00000000..6fc1fe37 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/main/di/MainModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.main.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface MainModule { + + @ContributesAndroidInjector + fun bindMainActivity(): MainActivity +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameActivity.kt new file mode 100644 index 00000000..5ecb5563 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameActivity.kt @@ -0,0 +1,28 @@ +package eu.theappfactory.dailyrecipes.ui.register.createuser + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityCreateUsernameBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity + +class CreateUsernameActivity : BaseActivity() { + + private lateinit var binding: ActivityCreateUsernameBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_create_username) + hideToolbar() + } + + companion object { + + fun start(activity: Activity) { + val intent = Intent(activity, CreateUsernameActivity::class.java) + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameFragment.kt new file mode 100644 index 00000000..d1cb26c8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameFragment.kt @@ -0,0 +1,191 @@ +package eu.theappfactory.dailyrecipes.ui.register.createuser + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.navArgs +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestOptions +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentCreateUsernameBinding +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsPermissionActivity +import eu.theappfactory.dailyrecipes.ui.launch.LauncherActivity +import eu.theappfactory.dailyrecipes.ui.main.MainActivity +import eu.theappfactory.dailyrecipes.ui.setUpSnackbar +import eu.theappfactory.dailyrecipes.util.* +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import java.util.* +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class CreateUsernameFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + private lateinit var snackbar: FadingSnackbar + + private lateinit var binding: FragmentCreateUsernameBinding + + private lateinit var viewModel: CreateUsernameViewModel + + private var imageUri: Uri? = null + + private val args : CreateUsernameFragmentArgs by navArgs() + + private val selectImageFromGalleryResult = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + imageUri = uri + if (uri != null) { + val size = FileUtils().getUriSizeInMb(uri, requireContext()) + if (size > MAX_IMAGE_SIZE || size == 0.0) { + imageUri = null + snackbarMessageManager.addMessage( + SnackbarMessage( + R.string.create_username_upload_limit_error, + longDuration = true, + requestChangeId = UUID.randomUUID().toString() + ) + ) + } else { + var requestOptions = RequestOptions() + requestOptions = requestOptions.transforms(CenterCrop(), RoundedCorners(2000)) + Glide.with(requireContext()) + .load(uri) + .apply(requestOptions) + .into(binding.profilePicture) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentCreateUsernameBinding.inflate( + inflater, + container, + false + ).apply { + lifecycleOwner = viewLifecycleOwner + } + + // load until we got the username + showLoading(loading = true, R.string.create_username_checking_text) + + binding.addProfilePicture.setOnClickListener { + selectImageFromGallery() + } + + binding.profilePicture.setOnClickListener { + selectImageFromGallery() + } + + binding.completeButton.setOnClickListener { + onButtonClick() + } + + snackbar = binding.snackbar + + return binding.root + } + + private fun onButtonClick() { + val username = binding.usernameEditText.text.toString() + val email = binding.emailEditText.text.toString() + + if (username.isEmpty() || username.length < 3 || !"[A-Za-z0-9_\n]+".toRegex().matches(username)) { + binding.textInputLayout.error = + getString(R.string.create_username_wrong_characters_error) + } + + if (email.isValidEmail().not()) { + binding.emailInputLayout.error = + getString(R.string.create_username_email_not_valid_error) + } + + if (username.isNotEmpty() && username.length >= 3 && + "[A-Za-z0-9_\n]+".toRegex().matches(username) && + (binding.notifyViaEmailChecked.isChecked && email.isValidEmail() || !binding.notifyViaEmailChecked.isChecked)) { + + showLoading(loading = true, R.string.create_username_loading_text) + + viewModel.createUser( + args.firstName, + args.lastName, + args.phoneNumber, + username.toLowerCase(), + email, + imageUri, + binding.notifyViaEmailChecked.isChecked + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel = + ViewModelProvider(this, viewModelFactory).get(CreateUsernameViewModel::class.java) + + viewModel.onAccountCompletion.observe(viewLifecycleOwner, { + MainActivity.start(requireContext()) + }) + + viewModel.onUsername.observe(viewLifecycleOwner, { username -> + binding.usernameEditText.setText(username) + }) + + viewModel.onNotLoggedIn.observe(viewLifecycleOwner, { + val intent = Intent(context, LauncherActivity::class.java) + context?.startActivity(intent) + }) + + viewModel.onLoading.observe(viewLifecycleOwner, { show -> + showLoading(show, R.string.create_username_loading_text) + }) + + viewModel.onUserNameCreated.observe(viewLifecycleOwner, { + InviteFriendsPermissionActivity.start(requireActivity()) + requireActivity().finish() + }) + + viewModel.getUsername() + viewModel.preloadItems() + + setUpSnackbar(viewModel.errorLiveData, snackbar, snackbarMessageManager) + } + + private fun showLoading(loading: Boolean, textId: Int = 0) { + if (loading) { + binding.loadingLayout.loadingText.text = getString(textId) + ViewUtils().hideKeyboard(binding.usernameEditText) + binding.loadingLayout.showWithAnimation() + } else { + binding.loadingLayout.hide() + } + } + + private fun selectImageFromGallery() = selectImageFromGalleryResult.launch("image/*") + + + companion object { + const val MAX_IMAGE_SIZE = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameViewModel.kt new file mode 100644 index 00000000..8d46362e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/createuser/CreateUsernameViewModel.kt @@ -0,0 +1,188 @@ +package eu.theappfactory.dailyrecipes.ui.register.createuser + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.domain.home.GetItemsUseCase +import eu.theappfactory.dailyrecipes.domain.launch.RegisterOrLoginCompleteUseCase +import eu.theappfactory.dailyrecipes.domain.register.UpdateUserUseCase +import eu.theappfactory.dailyrecipes.domain.userdetails.GetUserUseCase +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.EventTracker +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class CreateUsernameViewModel @Inject constructor( + signInViewModelDelegate: SignInViewModelDelegate, + private val updateUserUseCase: UpdateUserUseCase, + private val getItemsUseCase: GetItemsUseCase, + private val getUserUseCase: GetUserUseCase, + private val registerOrLoginCompleteUseCase: RegisterOrLoginCompleteUseCase, + private val mixpanel: MixpanelAPI +) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate { + + private var _onUsername = MutableLiveData() + var onUsername: LiveData = _onUsername + + private var _onNotLoggedInError = MutableLiveData() + var onNotLoggedIn: LiveData = _onNotLoggedInError + + private val _errorLiveData = MutableLiveData>() + val errorLiveData: LiveData> = _errorLiveData + + private var _onLoading = MutableLiveData() + var onLoading: LiveData = _onLoading + + private var _onAccountCompletion = MutableLiveData() + var onAccountCompletion: LiveData = _onAccountCompletion + + private var _onUserNameCreated = MutableLiveData() + var onUserNameCreated: LiveData = _onUserNameCreated + + private var uniqueUserId = "" + + fun preloadItems() { + viewModelScope.launch { + // preload items for faster loading of next screen. + getItemsUseCase(Unit) + } + } + + fun getUsername() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId().let { userId -> + + if (userId != null) { + uniqueUserId = userId + val result = getUserUseCase(userId) + + when (result.data) { + is Result.Success -> { + val user = result.data?.data + + if (user?.firstName?.length ?: 0 >= 2 && user?.lastName?.length ?: 0 >= 2 && user?.phoneNumber?.length == 10 && user.username.length > 2) { + isCompleted() + _onAccountCompletion.postValue(Unit) + } else { + _onUsername.postValue(user?.username) + _onLoading.postValue(false) + } + } + + is Result.Error -> { + _onLoading.postValue(false) + _errorLiveData.postValue( + Event( + SnackbarMessage( + actionId = R.string.dont_show, + messageId = R.string.create_username_retrieving_user_error, + requestChangeId = UUID.randomUUID().toString(), + longDuration = false + ) + ) + ) + } + } + } else { + _onLoading.postValue(false) + _onNotLoggedInError.postValue("") + } + } + } + } + } + + fun createUser( + firstName: String, + lastName: String, + phoneNumber: String, + username: String, + emailAddress: String, + photoUri: Uri?, + emailSubscribe: Boolean + ) { + viewModelScope.launch { + val result = if (photoUri == null) { + updateUserUseCase.invoke( + UserResponse( + uniqueUserId, + username, + firstName, + lastName, + phoneNumber, + photoUri = "", + photo = null, + emailSubscribe = emailSubscribe, + emailAddress = emailAddress + ) + ).data + } else { + updateUserUseCase.invoke( + UserResponse( + uniqueUserId, + username, + firstName, + lastName, + phoneNumber, + photoUri.toString(), + photoUri, + emailSubscribe, + ) + ).data + } + + when (result) { + is Result.Success -> { + isCompleted() + _onUserNameCreated.postValue(Unit) + } + + is Result.Error -> { + var errorMessage = R.string.create_username_updating_error + if (result.exception.message == "username is already taken.") { + errorMessage = R.string.create_username_taken_error + } else if (result.exception.message == "uploading photo went wrong") { + errorMessage = R.string.create_username_uploading_photo_error + } + + _errorLiveData.postValue( + Event( + SnackbarMessage( + actionId = R.string.dont_show, + messageId = errorMessage, + requestChangeId = UUID.randomUUID().toString(), + longDuration = false + ) + ) + ) + _onLoading.postValue(false) + } + } + } + } + + fun isCompleted() { + viewModelScope.launch { + registerOrLoginCompleteUseCase(true) + } + mixpanel.track(EventTracker.USER_SUCCESFULLY_REGISTERED) + } + + fun showSnackbarwarning(message: SnackbarMessage) { + _errorLiveData.postValue(Event(message)) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/di/RegisterModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/di/RegisterModule.kt new file mode 100644 index 00000000..6d4ed694 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/di/RegisterModule.kt @@ -0,0 +1,36 @@ +package eu.theappfactory.dailyrecipes.ui.register.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.register.createuser.CreateUsernameActivity +import eu.theappfactory.dailyrecipes.ui.register.createuser.CreateUsernameFragment +import eu.theappfactory.dailyrecipes.ui.register.name.FirstNameLastNameFragment +import eu.theappfactory.dailyrecipes.ui.register.otp.OTPRegistrationFragment +import eu.theappfactory.dailyrecipes.ui.register.phone.PhoneNumberRegistrationActivity +import eu.theappfactory.dailyrecipes.ui.register.phone.PhoneNumberRegistrationFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface RegisterModule { + + @ContributesAndroidInjector + fun bindPhoneNumberRegistrationActivity(): PhoneNumberRegistrationActivity + + @ContributesAndroidInjector + fun bindPhoneNumberRegistrationFragment(): PhoneNumberRegistrationFragment + + @ContributesAndroidInjector + fun bindOTPRegistrationFragment(): OTPRegistrationFragment + + @ContributesAndroidInjector + fun bindFirstnameLastnameFragment(): FirstNameLastNameFragment + + @ContributesAndroidInjector + fun bindCreateUsernameFragment(): CreateUsernameFragment + + @ContributesAndroidInjector + fun bindCreateUsernameActivity(): CreateUsernameActivity +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/name/FirstNameLastNameFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/name/FirstNameLastNameFragment.kt new file mode 100644 index 00000000..279743b0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/name/FirstNameLastNameFragment.kt @@ -0,0 +1,77 @@ +package eu.theappfactory.dailyrecipes.ui.register.name + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentFirstnameLastnameBinding +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class FirstNameLastNameFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var binding: FragmentFirstnameLastnameBinding + + private val args : FirstNameLastNameFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFirstnameLastnameBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.nextButton.setOnClickListener { + checkInputFieldsAndContinue() + } + } + + private fun checkInputFieldsAndContinue() { + val firstName = binding.firstNameEditText.text.toString() + val lastName = binding.lastNameEditText.text.toString() + + if (firstName.isEmpty() || firstName.length <= 1) { + binding.firstnameTextInputLayout.error = getString(R.string.firstname_lastname_registration_firstname_error_text) + } + + if (lastName.isEmpty() || lastName.length <= 1) { + binding.lastNameEditTextInputLayout.error = getString(R.string.firstname_lastname_registration_lastname_error_text) + } + + if (firstName.isNotEmpty() && firstName.length >= 2 && lastName.isNotEmpty() && lastName.length >= 2) { + navigateToCreateUserFragment(firstName, lastName) + } + } + + private fun navigateToCreateUserFragment(firstName: String, lastName: String) { + val bundle = bundleOf( + UserResponse.PHONE_NUMBER to args.phoneNumber, + UserResponse.FIRST_NAME to firstName, + UserResponse.LAST_NAME to lastName + ) + findNavController().navigate( + R.id.firstnameLastnameFragmentToCreateUsernameFragment, + bundle + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/FirebaseOTPErrorCodeConverter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/FirebaseOTPErrorCodeConverter.kt new file mode 100644 index 00000000..8642368f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/FirebaseOTPErrorCodeConverter.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.ui.register.otp + +import eu.theappfactory.dailyrecipes.R + +object FirebaseOTPErrorCodeConverter { + + fun convert(message: String): Int { + return when (message) { + ERROR_INVALID_VERIFICATION_CODE -> R.string.otp_registration_wrong_code_entered_snackbar_text + BLOCK_REQUESTS_UNUSUAL_ACTIVITY -> R.string.otp_registration_too_many_attempts_snackbar_text + INVALID_SMS_CODE -> R.string.otp_registration_wrong_code_snackbar_text + else -> R.string.generic_error_try_again_snackbar_text + } + } + + private const val ERROR_INVALID_VERIFICATION_CODE = "ERROR_INVALID_VERIFICATION_CODE" + private const val BLOCK_REQUESTS_UNUSUAL_ACTIVITY = "We have blocked all requests from this device due to unusual activity. Try again later." + private const val INVALID_SMS_CODE = "The sms verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user." +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/OTPRegistrationFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/OTPRegistrationFragment.kt new file mode 100644 index 00000000..7658c581 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/otp/OTPRegistrationFragment.kt @@ -0,0 +1,186 @@ +package eu.theappfactory.dailyrecipes.ui.register.otp + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.firebase.FirebaseException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentOtpRegistrationBinding +import eu.theappfactory.dailyrecipes.model.UserResponse +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.ViewUtils +import eu.theappfactory.dailyrecipes.widget.FadingSnackbar +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class OTPRegistrationFragment : DaggerFragment() { + + @Inject + lateinit var auth: FirebaseAuth + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var binding: FragmentOtpRegistrationBinding + + private lateinit var snackbar: FadingSnackbar + + private val args : OTPRegistrationFragmentArgs by navArgs() + + var verification: String? = null + + private var resendToken: PhoneAuthProvider.ForceResendingToken? = null + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentOtpRegistrationBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + snackbar = binding.snackbar + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ViewUtils().showKeyboard(binding.otpInputEditText) + + binding.otpInputEditText.doAfterTextChanged { + val otpCode = binding.otpInputEditText.text.toString() + + if (otpCode.isNotEmpty() && otpCode.length == OTP_CODE_LENGTH && !verification.isNullOrEmpty()) { + val credential = PhoneAuthProvider.getCredential(verification!!, otpCode) + signin(credential) + binding.resendButton.visibility = View.VISIBLE + } + } + + binding.resendButton.setOnClickListener { + sendOTPCode(args.phoneNumber) + } + + sendOTPCode(args.phoneNumber) + } + + private fun sendOTPCode(phoneNumber: String) { + var number = phoneNumber + if (number.startsWith("0")) { + number = phoneNumber.drop(1) + } + number = COUNTRY_CODE.plus(number) + + val options = PhoneAuthOptions.newBuilder(auth) + .setPhoneNumber(number) + .setTimeout(TIME_OUT, TimeUnit.SECONDS) + .setActivity(requireActivity()) + .setCallbacks(object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + + override fun onVerificationCompleted(credential: PhoneAuthCredential) { + signin(credential) + } + + override fun onVerificationFailed(exception: FirebaseException) { + binding.snackbar.show( + messageId = FirebaseOTPErrorCodeConverter.convert(exception.message ?: ""), + longDuration = true + ) + } + + override fun onCodeSent( + verificationId: String, + token: PhoneAuthProvider.ForceResendingToken + ) { + verification = verificationId + resendToken = token + } + }) + + PhoneAuthProvider.verifyPhoneNumber(options.build()) + startCountdownToButtonEnable() + } + + private fun startCountdownToButtonEnable() { + enableButton(isEnabled = false) + + lifecycleScope.launch { + val totalSeconds = OTP_TIMER_SECONDS + val tickSeconds = OTP_TICK_SECOND + + for (second in totalSeconds downTo tickSeconds) { + binding.resendButton.text = + getString(R.string.otp_registration_button_resend_text).plus(": ").plus( + String.format( + getString(R.string.otp_registration_timer_seconds), + second - TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(second)) + ) + ) + delay(OTP_TIMER_DELAY_TIME) + } + + binding.resendButton.text = getString(R.string.otp_registration_button_resend_text) + enableButton(isEnabled = true) + } + } + + private fun enableButton(isEnabled: Boolean) { + if (isEnabled) { + binding.resendButton.alpha = 1.0F + } else { + binding.resendButton.alpha = 0.7F + } + binding.resendButton.isEnabled = isEnabled + binding.resendButton.isClickable = isEnabled + } + + private fun signin(credential: PhoneAuthCredential) { + auth.signInWithCredential(credential) + .addOnCompleteListener(requireActivity()) { task -> + if (task.isSuccessful) { + findNavController().navigate(R.id.actionOTPFragmentToFirstNameLastNameFragment, bundleOf( + UserResponse.PHONE_NUMBER to args.phoneNumber) + ) + } else { + val exception = task.exception as FirebaseException + binding.snackbar.show( + messageId = FirebaseOTPErrorCodeConverter.convert(exception.message ?: ""), + longDuration = true + ) + + binding.otpInputEditText.text.clear() + } + } + } + + companion object { + const val COUNTRY_CODE = "+31" + const val TIME_OUT = 60L + const val OTP_CODE_LENGTH = 6 + const val OTP_TIMER_DELAY_TIME = 1000L + const val OTP_TICK_SECOND = 1 + const val OTP_TIMER_SECONDS = 59L + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationActivity.kt new file mode 100644 index 00000000..70f7a5cd --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationActivity.kt @@ -0,0 +1,38 @@ +package eu.theappfactory.dailyrecipes.ui.register.phone + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityPhoneNumberRegistrationBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class PhoneNumberRegistrationActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var binding: ActivityPhoneNumberRegistrationBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_phone_number_registration) + + hideToolbar() + } + + companion object { + + fun start(activity: Activity) { + val intent = Intent(activity, PhoneNumberRegistrationActivity::class.java) + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationFragment.kt new file mode 100644 index 00000000..443adf5b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/register/phone/PhoneNumberRegistrationFragment.kt @@ -0,0 +1,55 @@ +package eu.theappfactory.dailyrecipes.ui.register.phone + +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.widget.doAfterTextChanged +import androidx.navigation.fragment.findNavController +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentPhoneNumberRegistrationBinding +import eu.theappfactory.dailyrecipes.model.UserResponse.Companion.PHONE_NUMBER +import eu.theappfactory.dailyrecipes.util.ViewUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class PhoneNumberRegistrationFragment : DaggerFragment() { + + private lateinit var binding: FragmentPhoneNumberRegistrationBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentPhoneNumberRegistrationBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + + initializeInputFields() + return binding.root + } + + private fun initializeInputFields() { + ViewUtils().showKeyboard(binding.phoneNumberEditText) + binding.checkboxTermsAndConditions.movementMethod = LinkMovementMethod.getInstance() + + binding.phoneNumberEditText.doAfterTextChanged { + val phoneNumber = binding.phoneNumberEditText.text.toString() + if (phoneNumber.isNotEmpty() && phoneNumber.length == PHONE_NUMBER_LENGTH) { + findNavController().navigate(R.id.otpRegistrationFragment, bundleOf(PHONE_NUMBER to phoneNumber)) + binding.phoneNumberEditText.text.clear() + } + } + } + + companion object { + + const val PHONE_NUMBER_LENGTH = 10 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchAdapter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchAdapter.kt new file mode 100644 index 00000000..b7b488d8 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchAdapter.kt @@ -0,0 +1,30 @@ +package eu.theappfactory.dailyrecipes.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemSearchBinding +import eu.theappfactory.dailyrecipes.model.ItemResponse + +class SearchAdapter(private val searchEventActions: SearchEventActions) : + RecyclerView.Adapter() { + + private val items = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SearchViewHolder( + ListItemSearchBinding.inflate(LayoutInflater.from(parent.context)), + searchEventActions + ) + + override fun onBindViewHolder(holder: SearchViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + fun setItems(items: List) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchEventActions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchEventActions.kt new file mode 100644 index 00000000..de78c13f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchEventActions.kt @@ -0,0 +1,11 @@ +package eu.theappfactory.dailyrecipes.ui.search + +import android.graphics.drawable.Drawable +import java.io.File + +interface SearchEventActions { + + fun onItemClicked(imageUri: String) + + fun onItemImageRetrieved(file: File, resource: Drawable) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchFragment.kt new file mode 100644 index 00000000..bb1a049e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchFragment.kt @@ -0,0 +1,133 @@ +package eu.theappfactory.dailyrecipes.ui.search + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.GridLayoutManager +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentSearchBinding +import eu.theappfactory.dailyrecipes.ui.SnackbarMessage +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.ui.home.pager.ListItemType +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_HAS_FOUND_VIDEO_USING_SEARCH_BAR +import eu.theappfactory.dailyrecipes.ui.GridItemDecoration +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import eu.theappfactory.dailyrecipes.util.ViewUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import org.json.JSONObject +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class SearchFragment : DaggerFragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + @Inject + lateinit var mixpanel: MixpanelAPI + + private lateinit var binding: FragmentSearchBinding + + private lateinit var viewModel: SearchViewModel + + private lateinit var adapter: SearchAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentSearchBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupBackDispatcher() + viewModel = ViewModelProvider(this, viewModelFactory).get(SearchViewModel::class.java) + binding.viewModel = viewModel + + adapter = SearchAdapter(viewModel) + binding.recycler.layoutManager = GridLayoutManager(requireContext(), SPAN_COUNT) + binding.recycler.addItemDecoration(GridItemDecoration(10)) + binding.recycler.adapter = adapter + + viewModel.savedStateHandle.getLiveData(SAVED_STATE_LAYOUT_MANAGER_KEY) + .observe(viewLifecycleOwner, { + binding.recycler.layoutManager?.onRestoreInstanceState(it) + }) + + viewModel.items.observe(viewLifecycleOwner, { + adapter.setItems(it) + }) + + viewModel.errorLiveData.observe(viewLifecycleOwner, { + snackbarMessageManager.addMessage(SnackbarMessage(R.string.error_no_connection_text)) + }) + + viewModel.searchItemClicked.observe(viewLifecycleOwner, { + it.getContentIfNotHandled()?.let { + ViewUtils().hideKeyboard(binding.searchView) + + val action = SearchFragmentDirections.actionNavigationSearchToNavigationSearchItem( + it, + "", + ListItemType.SEARCH + ) + action.query = binding.searchView.text.toString().trim() + NavHostFragment.findNavController(this).navigate(action) + + sendTrackingEvent(binding.searchView.text.toString()) + } + }) + + ViewUtils().showKeyboard(binding.searchView) + binding.root.setOnClickListener { + ViewUtils().hideKeyboard(binding.searchView) + } + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.savedStateHandle.set( + SAVED_STATE_LAYOUT_MANAGER_KEY, + binding.recycler.layoutManager?.onSaveInstanceState() + ) + } + + private fun setupBackDispatcher() { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + requireActivity().finishAffinity() + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) + } + + private fun sendTrackingEvent(content: String) { + val json = JSONObject().apply { + put("searchTerm",content) + } + mixpanel.track(USER_HAS_FOUND_VIDEO_USING_SEARCH_BAR, json) + } + + private companion object { + + const val SPAN_COUNT = 2 + const val SAVED_STATE_LAYOUT_MANAGER_KEY = "SAVED_STATE_LAYOUT_MANAGER" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewHolder.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewHolder.kt new file mode 100644 index 00000000..ab9307ed --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewHolder.kt @@ -0,0 +1,38 @@ +package eu.theappfactory.dailyrecipes.ui.search + +import android.graphics.drawable.Drawable +import androidx.recyclerview.widget.RecyclerView +import eu.theappfactory.dailyrecipes.databinding.ListItemSearchBinding +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.util.FileUtils +import eu.theappfactory.dailyrecipes.util.HtmlUtils +import eu.theappfactory.dailyrecipes.util.ImageListener +import eu.theappfactory.dailyrecipes.util.loadFromCacheOrFallbackToUrl + +class SearchViewHolder( + private val binding: ListItemSearchBinding, + private val searchEventActions: SearchEventActions +) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(itemResponse: ItemResponse) { + binding.item = itemResponse + binding.eventListener = searchEventActions + val listener = object : ImageListener { + override fun onImageRetrievedFromRemote(drawable: Drawable, url: String) { + searchEventActions.onItemImageRetrieved( + FileUtils().getFileFromUrl( + itemView.context, + url + ), drawable + ) + } + } + + binding.searchImage.loadFromCacheOrFallbackToUrl( + FileUtils().getFileFromUrl(itemView.context, itemResponse.imageUri), + itemResponse.imageUri, listener + ) + HtmlUtils().getHtmlText(binding.title,itemResponse.title) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..4c9f9f22 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/SearchViewModel.kt @@ -0,0 +1,67 @@ +package eu.theappfactory.dailyrecipes.ui.search + +import android.graphics.drawable.Drawable +import android.view.View +import androidx.databinding.ObservableField +import androidx.lifecycle.* +import eu.theappfactory.dailyrecipes.domain.search.SearchItemsUseCase +import eu.theappfactory.dailyrecipes.domain.search.SearchParams +import eu.theappfactory.dailyrecipes.model.ItemResponse +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import eu.theappfactory.dailyrecipes.util.FileUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class SearchViewModel @Inject constructor( + private val signInViewModelDelegate: SignInViewModelDelegate, + private val searchItemsUseCase: SearchItemsUseCase +) : ViewModel(), SearchEventActions, + SignInViewModelDelegate by signInViewModelDelegate { + + var savedStateHandle = SavedStateHandle() + var recyclerViewVisibility = ObservableField(View.GONE) + var emptyViewVisibility = ObservableField(View.VISIBLE) + + private val _items = MutableLiveData>() + val items: LiveData> = _items + + private val _errorLiveData = MutableLiveData() + val errorLiveData: LiveData = _errorLiveData + + private val _searchItemClicked = MutableLiveData>() + val searchItemClicked: LiveData> = _searchItemClicked + + fun searchItems(text: String) { + viewModelScope.launch { + signInViewModelDelegate.currentFirebaseUser.collect { + when (val result = searchItemsUseCase(SearchParams(getUserId().orEmpty(), text))) { + is Result.Success -> { + recyclerViewVisibility.set(if (text.isNotBlank()) View.VISIBLE else View.GONE) + emptyViewVisibility.set(if (text.isNotBlank()) View.GONE else View.VISIBLE) + _items.postValue(result.data) + } + is Result.Error -> _errorLiveData.postValue(Unit) + else -> Unit + } + } + } + } + + override fun onItemClicked(imageUri: String) { + _searchItemClicked.postValue(Event(imageUri)) + } + + override fun onItemImageRetrieved(file: File, resource: Drawable) { + viewModelScope.launch(Dispatchers.IO) { + FileUtils().saveImageToFile(file, resource) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/di/SearchModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/di/SearchModule.kt new file mode 100644 index 00000000..6f4f276e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/search/di/SearchModule.kt @@ -0,0 +1,16 @@ +package eu.theappfactory.dailyrecipes.ui.search.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.search.SearchFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface SearchModule { + + @ContributesAndroidInjector + fun bindSearchFragment(): SearchFragment +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugActivity.kt new file mode 100644 index 00000000..68d704c4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugActivity.kt @@ -0,0 +1,84 @@ +package eu.theappfactory.dailyrecipes.ui.settings + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import com.google.firebase.Timestamp +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityDebugBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.ui.SnackbarMessageManager +import eu.theappfactory.dailyrecipes.util.EventObserver +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class DebugActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var snackbarMessageManager: SnackbarMessageManager + + private lateinit var binding: ActivityDebugBinding + private lateinit var timestamp: Timestamp + + lateinit var viewModel: DebugViewModel + + private val simpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.ENGLISH) + private fun getDateString(time: Long): String = simpleDateFormat.format(time * 1000L) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_debug) + hideToolbar() + + if (intent.extras != null) { + if (intent.extras?.getParcelable(TIMESTAMP) != null) { + timestamp = intent.extras?.getParcelable(TIMESTAMP) as Timestamp + } + } + + binding.dateCurrentVideoText.text = + getString(R.string.settings_title_date_textview) + getDateString(timestamp.seconds) + + viewModel = ViewModelProvider(this, viewModelFactory).get(DebugViewModel::class.java) + + viewModel.result.observe(this, EventObserver { + runOnUiThread { + Toast.makeText( + applicationContext, + R.string.snackbar_reset_videos, + Toast.LENGTH_LONG + ).show() + } + }) + + viewModel.doneLoading.observe(this, { + binding.videoResetButton.setOnClickListener { viewModel.eraseAlreadySeen() } + binding.userIdText.text = getString(R.string.settings_title_user_id_textview) + it + }) + + viewModel.load() + } + + companion object { + const val TIMESTAMP = "timestamp" + + fun start(activity: Activity, timestamp: Timestamp) { + val intent = Intent(activity, DebugActivity::class.java) + intent.putExtra(TIMESTAMP, timestamp) + activity.startActivity(intent) + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugViewModel.kt new file mode 100644 index 00000000..29f2afaa --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/DebugViewModel.kt @@ -0,0 +1,57 @@ +package eu.theappfactory.dailyrecipes.ui.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import eu.theappfactory.dailyrecipes.domain.home.EraseAlreadySeenUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.data +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.util.Event +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +@ExperimentalCoroutinesApi +class DebugViewModel( + private val eraseAlreadySeenUseCase: EraseAlreadySeenUseCase, + signInViewModelDelegate: SignInViewModelDelegate, + private val preferenceStorage: PreferenceStorage +) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate { + + private val _result = MutableLiveData>() + val result: LiveData> = _result + + private val _doneLoading = MutableLiveData() + val doneLoading: LiveData = _doneLoading + + private var userId = "" + + fun load() { + viewModelScope.launch { + currentFirebaseUser.collect { + getUserId()?.let { + userId = it + _doneLoading.postValue(userId) + } + } + } + } + + fun eraseAlreadySeen() { + if (userId.isNotEmpty()) { + viewModelScope.launch { + val result = eraseAlreadySeenUseCase.invoke(userId) + + when (result.data) { + is Result.Success -> { + _result.postValue(Event(Unit)) + preferenceStorage.firstTimeUsingApp = true + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsActivity.kt new file mode 100644 index 00000000..3c2370cf --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsActivity.kt @@ -0,0 +1,35 @@ +package eu.theappfactory.dailyrecipes.ui.settings + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.ViewModelProvider +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class SettingsActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + lateinit var viewModel: SettingsViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + hideToolbar() + viewModel = ViewModelProvider(this, viewModelFactory)[SettingsViewModel::class.java] + } + + companion object { + fun start(activity: Activity) { + val intent = Intent(activity, SettingsActivity::class.java) + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsFragment.kt new file mode 100644 index 00000000..b062e56a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsFragment.kt @@ -0,0 +1,103 @@ +package eu.theappfactory.dailyrecipes.ui.settings + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.firebase.ui.auth.AuthUI +import com.google.firebase.auth.FirebaseAuth +import eu.theappfactory.dailyrecipes.BuildConfig +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentProfileSettingsBinding +import eu.theappfactory.dailyrecipes.ui.BaseFragment +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesActivity +import eu.theappfactory.dailyrecipes.ui.launch.LauncherActivity +import eu.theappfactory.dailyrecipes.util.ShareUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class SettingsFragment : BaseFragment() { + + private val viewModel: SettingsViewModel by activityViewModels() + private lateinit var binding: FragmentProfileSettingsBinding + + @Inject + lateinit var auth: FirebaseAuth + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentProfileSettingsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setToolbarBackButton(binding.profileToolbar.toolbar) + binding.profileToolbar.toolbarTitle.text = getString(R.string.favourite_instellingen) + + binding.selectCategoriesLayout.setOnClickListener { + val intent = Intent(requireActivity(), CategoriesActivity::class.java) + intent.putExtra(KEY_IS_FROM_SETTINGS, true) + startActivity(intent) + } + binding.contactUsLayout.setOnClickListener { + ShareUtils().composeEmail( + arrayOf(getString(R.string.contact_form_company_mail_address)), + getString(R.string.contact_form_subject), + requireContext() + ) + } + binding.aboutUsLayout.setOnClickListener { + findNavController().navigate(SettingsFragmentDirections.actionProfileSettingsFragmentToFragmentAboutUs()) + } + binding.logoutButton.setOnClickListener { + viewModel.onLogoutClicked() + auth.signOut() + AuthUI.getInstance().signOut(requireContext()).addOnCompleteListener { + val intent = Intent(requireActivity(), LauncherActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + requireActivity().finishAffinity() + } + } + binding.privacyStatementLayout.setOnClickListener { + ShareUtils().openUrl( + requireContext(), + BuildConfig.PRIVACY_AND_COOKIE_STATEMENT + ) + } + binding.termsOfServiceLayout.setOnClickListener { + ShareUtils().openUrl( + requireContext(), + BuildConfig.TERMS_AND_CONDITIONS + ) + } + setVersionText() + } + + private fun setVersionText() { + val builder = StringBuilder() + builder.append(getString(R.string.app_name)) + .append(" ") + .append(BuildConfig.VERSION_NAME) + .append(" ") + .append("(${BuildConfig.VERSION_CODE})") + binding.version.text = builder.toString() + } + + companion object { + const val KEY_IS_FROM_SETTINGS = "IS_FROM_SETTINGS" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..292e0830 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/SettingsViewModel.kt @@ -0,0 +1,17 @@ +package eu.theappfactory.dailyrecipes.ui.settings + +import androidx.lifecycle.ViewModel +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class SettingsViewModel @Inject constructor( + private val preferenceStorage: PreferenceStorage +) : ViewModel() { + + fun onLogoutClicked() { + preferenceStorage.clearLoggedInSession() + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/aboutus/AboutUsFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/aboutus/AboutUsFragment.kt new file mode 100644 index 00000000..07a052c4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/aboutus/AboutUsFragment.kt @@ -0,0 +1,34 @@ +package eu.theappfactory.dailyrecipes.ui.settings.aboutus + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentAboutUsBinding +import eu.theappfactory.dailyrecipes.ui.BaseFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +class AboutUsFragment : BaseFragment() { + private lateinit var binding : FragmentAboutUsBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentAboutUsBinding.inflate(inflater,container,false).apply { + lifecycleOwner = viewLifecycleOwner + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.aboutUsToolbar.toolbarTitle.text = getString(R.string.settings_about_us) + setToolbarBackButton(binding.aboutUsToolbar.toolbar) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/di/SettingsModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/di/SettingsModule.kt new file mode 100644 index 00000000..088ba94c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/settings/di/SettingsModule.kt @@ -0,0 +1,30 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.settings.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.settings.DebugActivity +import eu.theappfactory.dailyrecipes.ui.settings.SettingsActivity +import eu.theappfactory.dailyrecipes.ui.settings.SettingsFragment +import eu.theappfactory.dailyrecipes.ui.settings.aboutus.AboutUsFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface SettingsModule { + + @ContributesAndroidInjector + fun bindDebugActivity(): DebugActivity + + @ContributesAndroidInjector + fun bindSettingsActivity(): SettingsActivity + + @ContributesAndroidInjector + fun bindSettingsFragment(): SettingsFragment + + @ContributesAndroidInjector + fun bindAboutUsFragment(): AboutUsFragment +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/SignInViewModelDelegate.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/SignInViewModelDelegate.kt new file mode 100644 index 00000000..7e36b2ba --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/SignInViewModelDelegate.kt @@ -0,0 +1,68 @@ +package eu.theappfactory.dailyrecipes.ui.signin + +import androidx.lifecycle.MutableLiveData +import eu.theappfactory.dailyrecipes.data.signin.AuthenticatedUserInfo +import eu.theappfactory.dailyrecipes.domain.auth.ObserveUserAuthStateUseCase +import eu.theappfactory.dailyrecipes.networking.Result +import eu.theappfactory.dailyrecipes.networking.Result.Success +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +/** + * Interface to implement sign-in functionality in a ViewModel. + * + * This implementation is injected via Dagger2 in the ViewModel + * + */ +interface SignInViewModelDelegate { + /** + * Stream of the current Firebase user + */ + val currentFirebaseUser: Flow> + + fun isSignedIn(): Boolean + + /** + * Returns the current user ID or null if not available. + */ + fun getUserId(): String? +} + +/** + * Implementation of SignInViewModelDelegate that uses Firebase's auth mechanisms. + */ +@ExperimentalCoroutinesApi +internal class FirebaseSignInViewModelDelegate @Inject constructor( + observeUserAuthStateUseCase: ObserveUserAuthStateUseCase +) : SignInViewModelDelegate { + + override val currentFirebaseUser: Flow> + + private val _isSignedIn = MutableLiveData() + + private var authenticatedUserInfo: AuthenticatedUserInfo? = null + + init { + currentFirebaseUser = observeUserAuthStateUseCase(Any()).map { + if (it is Success) { + authenticatedUserInfo = it.data + _isSignedIn.value = it.data?.isSignedIn() ?: false + } else if (it is Result.Error) { + Timber.e(it.exception) + } + + it + } + } + + override fun isSignedIn(): Boolean { + return _isSignedIn.value == true + } + + override fun getUserId(): String? { + return authenticatedUserInfo?.getUid() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/di/SignInViewModelDelegateModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/di/SignInViewModelDelegateModule.kt new file mode 100644 index 00000000..83ec002e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/signin/di/SignInViewModelDelegateModule.kt @@ -0,0 +1,22 @@ +package eu.theappfactory.dailyrecipes.ui.signin.di + +import dagger.Module +import dagger.Provides +import eu.theappfactory.dailyrecipes.domain.auth.ObserveUserAuthStateUseCase +import eu.theappfactory.dailyrecipes.ui.signin.FirebaseSignInViewModelDelegate +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Singleton + +@ExperimentalCoroutinesApi +@Module +class SignInViewModelDelegateModule { + + @Singleton + @Provides + fun provideSignInViewModelDelegate( + dataSource: ObserveUserAuthStateUseCase + ): SignInViewModelDelegate { + return FirebaseSignInViewModelDelegate(dataSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/terms/TermsAndConditionsActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsActivity.kt similarity index 52% rename from app/src/main/java/eu/theappfactory/sampleapp/ui/terms/TermsAndConditionsActivity.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsActivity.kt index ef3b0216..4c6a09db 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/terms/TermsAndConditionsActivity.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsActivity.kt @@ -1,14 +1,18 @@ -package eu.theappfactory.sampleapp.ui.terms +package eu.theappfactory.dailyrecipes.ui.terms import android.os.Bundle import androidx.databinding.DataBindingUtil -import androidx.lifecycle.ViewModelProviders -import eu.theappfactory.sampleapp.R -import eu.theappfactory.sampleapp.databinding.ActivityTermsConditionsBinding -import eu.theappfactory.sampleapp.ui.BaseActivity -import eu.theappfactory.sampleapp.util.ViewModelFactory +import androidx.lifecycle.ViewModelProvider +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityTermsConditionsBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity +import eu.theappfactory.dailyrecipes.util.ViewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi import javax.inject.Inject +@ExperimentalCoroutinesApi +@InternalCoroutinesApi class TermsAndConditionsActivity : BaseActivity() { @Inject @@ -18,7 +22,7 @@ class TermsAndConditionsActivity : BaseActivity() { super.onCreate(savedInstanceState) val termsAndConditionsViewModel = - ViewModelProviders.of(this, viewModelFactory).get(TermsAndConditionsViewModel::class.java) + ViewModelProvider(this, viewModelFactory).get(TermsAndConditionsViewModel::class.java) DataBindingUtil.setContentView( this, R.layout.activity_terms_conditions @@ -27,4 +31,4 @@ class TermsAndConditionsActivity : BaseActivity() { lifecycleOwner = this@TermsAndConditionsActivity } } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsViewModel.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsViewModel.kt new file mode 100644 index 00000000..fc3dcfe4 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/TermsAndConditionsViewModel.kt @@ -0,0 +1,12 @@ +package eu.theappfactory.dailyrecipes.ui.terms + +import android.util.Patterns +import androidx.lifecycle.ViewModel +import javax.inject.Inject + +class TermsAndConditionsViewModel @Inject constructor() : + ViewModel() { + + private fun String.isValidEmail(): Boolean = + this.isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/di/TermsAndConditionsModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/di/TermsAndConditionsModule.kt new file mode 100644 index 00000000..60d4b1fc --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/terms/di/TermsAndConditionsModule.kt @@ -0,0 +1,18 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.terms.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.terms.TermsAndConditionsActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface TermsAndConditionsModule { + + @ContributesAndroidInjector + fun bindEmailLoginActivity(): TermsAndConditionsActivity +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewActivity.kt new file mode 100644 index 00000000..c1780a2f --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewActivity.kt @@ -0,0 +1,49 @@ +package eu.theappfactory.dailyrecipes.ui.webview + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.ActivityWebviewBinding +import eu.theappfactory.dailyrecipes.ui.BaseActivity + +class WebViewActivity : BaseActivity() { + + private lateinit var binding: ActivityWebviewBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideToolbar() + + val url = intent.extras?.getString(URL) ?: "" + + binding = DataBindingUtil.setContentView(this, R.layout.activity_webview) + showFragment(WebviewFragment.newInstance(url)) + } + + companion object { + + const val WEB_REQUEST_CODE = 999 + const val URL = "url" + + fun startActivityForResult(activity: Activity, url: String) { + val intent = Intent(activity, WebViewActivity::class.java) +// intent.addFlags( +// Intent.FLAG_ACTIVITY_NEW_TASK or +// Intent.FLAG_ACTIVITY_CLEAR_TOP or +// Intent.FLAG_ACTIVITY_SINGLE_TOP) +// intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + intent.putExtra(URL, url) + activity.startActivityForResult(intent, WEB_REQUEST_CODE) + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + + fun start(activity: Activity, url: String) { + val intent = Intent(activity, WebViewActivity::class.java) + intent.putExtra(URL, url) + activity.startActivity(intent) + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewHandler.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewHandler.kt new file mode 100644 index 00000000..31480ed2 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebViewHandler.kt @@ -0,0 +1,98 @@ +package eu.theappfactory.dailyrecipes.ui.webview + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Activity.RESULT_OK +import android.webkit.* +import eu.theappfactory.dailyrecipes.data.ingredients.AHDataSource.Companion.AH_API_DASHBOARD +import eu.theappfactory.dailyrecipes.data.ingredients.AHDataSource.Companion.AH_DASHBOARD +import eu.theappfactory.dailyrecipes.data.ingredients.AHDataSource.Companion.AH_LOGIN_URL +import eu.theappfactory.dailyrecipes.data.ingredients.AHDataSource.Companion.AH_REDIRECT +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import timber.log.Timber + +class WebViewHandler(private val preferenceStorage: PreferenceStorage) { + + interface Callback { + fun onLoading() + fun onWrongLoginButtonPressed() + } + + var lastKnownCookie = preferenceStorage.lastKnownCookie + + @ExperimentalStdlibApi + @SuppressLint("JavascriptInterface", "SetJavaScriptEnabled") + fun initializeWebView(webView: WebView, url: String?, activity: Activity, callback: Callback) { + initializeSettings(webView.settings) + + webView.webViewClient = WebViewClient() + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + // Already logged in + if (url == AH_DASHBOARD) { + callback.onLoading() + activity.setResult(RESULT_OK) + activity.finish() + } + + // User tries logging in using Bol.com + if (url.orEmpty().contains(AH_REDIRECT)) { + webView.stopLoading() + callback.onWrongLoginButtonPressed() + webView.loadUrl(AH_LOGIN_URL) + } + + return super.shouldOverrideUrlLoading(view, url) + } + + override fun shouldInterceptRequest( + view: WebView?, + url: String? + ): WebResourceResponse? { + if (url == AH_API_DASHBOARD) { + callback.onLoading() + activity.setResult(RESULT_OK) + activity.finish() + } + return super.shouldInterceptRequest(view, url) + } + + override fun onPageFinished(webView: WebView?, url: String) { + super.onPageFinished(webView, url) + storeCookie(webView, url) + } + } + + url?.let { webView.loadUrl(it) } + } + + private fun storeCookie(webView: WebView?, url: String) { + val syncManager = + CookieSyncManager.createInstance(webView?.context) + syncManager.startSync() + CookieSyncManager.getInstance().sync() + + val cookieManager: CookieManager = CookieManager.getInstance() + cookieManager.setAcceptThirdPartyCookies(webView, true) + + if (cookieManager.getCookie(url) != null) { + val cookie: String = cookieManager.getCookie(url) + lastKnownCookie = cookie + preferenceStorage.lastKnownCookie = cookie + Timber.d("Cookie = $lastKnownCookie") + } + syncManager.sync() + } + + private fun initializeSettings(settings: WebSettings) { + settings.allowContentAccess = true + settings.blockNetworkLoads = false + settings.domStorageEnabled = true + settings.setAppCacheEnabled(true) + settings.javaScriptEnabled = true + settings.builtInZoomControls = false + settings.setGeolocationEnabled(true) + settings.setAppCacheEnabled(false) + settings.loadsImagesAutomatically = true + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebviewFragment.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebviewFragment.kt new file mode 100644 index 00000000..e8822e65 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/WebviewFragment.kt @@ -0,0 +1,83 @@ +package eu.theappfactory.dailyrecipes.ui.webview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AnimationUtils +import androidx.core.content.ContextCompat +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.android.support.DaggerFragment +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.FragmentWebviewBinding +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.PopupDialog +import eu.theappfactory.dailyrecipes.ui.webview.WebViewActivity.Companion.URL +import eu.theappfactory.dailyrecipes.util.EventTracker.Companion.USER_WANTS_TO_LOGIN_WITH_BOL +import eu.theappfactory.dailyrecipes.util.lazyFast +import javax.inject.Inject + +class WebviewFragment : DaggerFragment() { + + @Inject + lateinit var preferenceStorage: PreferenceStorage + + @Inject + lateinit var mixpanel: MixpanelAPI + + private lateinit var binding: FragmentWebviewBinding + + private val url: String? by lazyFast { + val args = arguments + args?.getString(URL) as String + } + + @ExperimentalStdlibApi + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentWebviewBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + } + WebViewHandler(preferenceStorage).initializeWebView(binding.webView, url, requireActivity(), object: WebViewHandler.Callback { + override fun onLoading() { + activity?.runOnUiThread { showLoading(true) } + } + + override fun onWrongLoginButtonPressed() { + PopupDialog(requireActivity()).show( + getString(R.string.alertdialog_error_message_title), + getString(R.string.alertdialog_error_message), null) + mixpanel.track(USER_WANTS_TO_LOGIN_WITH_BOL) + } + }) + + return binding.root + } + + fun showLoading(loading: Boolean) { + if (loading) { + binding.loadingLayout.loadingHolder.visibility = View.VISIBLE + val animFadeIn = AnimationUtils.loadAnimation(context, R.anim.blink) + binding.loadingLayout.loadingImage.startAnimation(animFadeIn) + + val animBlink = AnimationUtils.loadAnimation(context, R.anim.blink) + binding.loadingLayout.loadingText.startAnimation(animBlink) + + binding.loadingLayout.loadingHolder.background = ContextCompat.getDrawable(requireContext(), R.color.half_transparant_black) + } else { + binding.loadingLayout.loadingImage.clearAnimation() + binding.loadingLayout.loadingHolder.visibility = View.GONE + + binding.loadingLayout.loadingText.clearAnimation() + } + } + + companion object { + + fun newInstance(url: String): WebviewFragment { + val args = Bundle().apply { + putString(URL, url) + } + return WebviewFragment().apply { arguments = args } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/di/WebViewModule.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/di/WebViewModule.kt new file mode 100644 index 00000000..87decb68 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/ui/webview/di/WebViewModule.kt @@ -0,0 +1,22 @@ +@file:Suppress("unused") + +package eu.theappfactory.dailyrecipes.ui.webview.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import eu.theappfactory.dailyrecipes.ui.webview.WebViewActivity +import eu.theappfactory.dailyrecipes.ui.webview.WebviewFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Module +interface WebViewModule { + + @ContributesAndroidInjector + fun bindWebviewActivity(): WebViewActivity + + @ContributesAndroidInjector + fun bindWebviewFragment(): WebviewFragment +} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/CrashlyticsTree.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/CrashlyticsTree.kt similarity index 55% rename from app/src/main/java/eu/theappfactory/sampleapp/util/CrashlyticsTree.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/util/CrashlyticsTree.kt index 95a20779..8ec53f7f 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/CrashlyticsTree.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/CrashlyticsTree.kt @@ -1,7 +1,6 @@ -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.util import android.util.Log -import com.crashlytics.android.Crashlytics import timber.log.Timber class CrashlyticsTree : Timber.Tree() { @@ -11,15 +10,15 @@ class CrashlyticsTree : Timber.Tree() { return } - Crashlytics.setInt(CRASHLYTICS_KEY_PRIORITY, priority) - Crashlytics.setString(CRASHLYTICS_KEY_TAG, tag) - Crashlytics.setString(CRASHLYTICS_KEY_MESSAGE, message) - - if (t == null) { - Crashlytics.logException(Exception(message)) - } else { - Crashlytics.logException(t) - } +// Crashlytics.setInt(CRASHLYTICS_KEY_PRIORITY, priority) +// Crashlytics.setString(CRASHLYTICS_KEY_TAG, tag) +// Crashlytics.setString(CRASHLYTICS_KEY_MESSAGE, message) +// +// if (t == null) { +// Crashlytics.logException(Exception(message)) +// } else { +// Crashlytics.logException(t) +// } } companion object { diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/DateUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/DateUtils.kt new file mode 100644 index 00000000..fbcbb7cf --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/DateUtils.kt @@ -0,0 +1,10 @@ +package eu.theappfactory.dailyrecipes.util + +import java.text.SimpleDateFormat +import java.util.* + +object DateUtils { + + private val simpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.ENGLISH) + fun getDateString(time: Long): String = simpleDateFormat.format(time * 1000L) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/Debounce.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Debounce.kt new file mode 100644 index 00000000..86f04952 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Debounce.kt @@ -0,0 +1,21 @@ +package eu.theappfactory.dailyrecipes.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun debounceUntilLast( + delayMillis: Long = 500L, + scope: CoroutineScope?, + action: (T) -> Unit +): (T) -> Unit { + var job: Job? = null + return { param: T -> + job?.cancel() + job = scope?.launch { + delay(delayMillis) + action(param) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/DeviceUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/DeviceUtils.kt new file mode 100644 index 00000000..aa63ade5 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/DeviceUtils.kt @@ -0,0 +1,24 @@ +package eu.theappfactory.dailyrecipes.util + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator + +class DeviceUtils { + + @Suppress("DEPRECATION") + fun vibrate(context: Context): Vibrator { + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + + if (vibrator.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(effect) + } else { + vibrator.vibrate(500) + } + } + return vibrator + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/Event.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Event.kt similarity index 96% rename from app/src/main/java/eu/theappfactory/sampleapp/util/Event.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/util/Event.kt index 15be6f30..0ee7a3e9 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/Event.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Event.kt @@ -1,4 +1,4 @@ -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.util import androidx.lifecycle.Observer diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/EventTracker.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/EventTracker.kt new file mode 100644 index 00000000..1ccedf7c --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/EventTracker.kt @@ -0,0 +1,36 @@ +package eu.theappfactory.dailyrecipes.util + +class EventTracker { + + companion object { + + // Login & Register + const val USER_INSTALLED_APP = "userInstalledApp" + const val USER_SUCCESFULLY_REGISTERED = "userSuccessfullyRegistered" + + // Home + const val SWIPED_UP = "userSwipedUp" + const val CLICKED_FAVORITE_BUTTON = "userClickedFavoriteButton" + const val CLICKED_SOCIAL_MEDIA_BUTTON = "userClickedSocialMediaButton" + const val CLICKED_CHAT_BUTTON = "userClickedChatButton" + const val CLICKED_STORE_BUTTON = "userClickedStoreButton" + + const val CLICKED_INGREDIENTS_BUTTON = "userClickedIngredientsButton" + const val CLICKED_FAVORITE_VIDEO = "userClickedOnFavoriteVideo" + + // Ingredients + const val CLICKED_SUPERMARKET_LOGIN_BUTTON = "userClickedSupermarketLoginButton" + const val CLICKED_OPEN_SUPERMARKET_APP = "userClickedOpenSupermarketApp" + const val USER_WANTS_TO_LOGIN_WITH_BOL = "userWantsToLoginWithBol" + + // Search + const val USER_HAS_FOUND_VIDEO_USING_SEARCH_BAR = "userHasFoundVideoUsingSearchBar" + + const val HAS_SEEN_ALL_VIDEOS = "userHasSeenAllVideos" + + // Video Controls + const val USER_PRESSES_FASTFORWARD_BUTTON = "userPressesFastForwardButton" + const val USER_PRESSES_BACKWARD_BUTTON = "userPressesBackwardButton" + const val USER_PRESSES_PLAY_BUTTON = "userPressesPlayButton" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/Extensions.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Extensions.kt new file mode 100644 index 00000000..311369ce --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/Extensions.kt @@ -0,0 +1,28 @@ +package eu.theappfactory.dailyrecipes.util + +/** + * Helper to force a when statement to assert all options are matched in a when statement. + * + * By default, Kotlin doesn't care if all branches are handled in a when statement. However, if you + * use the when statement as an expression (with a value) it will force all cases to be handled. + * + * This helper is to make a lightweight way to say you meant to match all of them. + * + * Usage: + * + * ``` + * when(sealedObject) { + * is OneType -> // + * is AnotherType -> // + * }.checkAllMatched + */ +val T.checkAllMatched: T + get() = this + +/** + * Implementation of lazy that is not thread safe. Useful when you know what thread you will be + * executing on and are not worried about synchronization. + */ +fun lazyFast(operation: () -> T): Lazy = lazy(LazyThreadSafetyMode.NONE) { + operation() +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/FileUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/FileUtils.kt new file mode 100644 index 00000000..43ec32c9 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/FileUtils.kt @@ -0,0 +1,50 @@ +package eu.theappfactory.dailyrecipes.util + +import android.content.Context +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.net.Uri +import android.provider.MediaStore +import androidx.annotation.WorkerThread +import java.io.File +import java.io.FileOutputStream +import kotlin.math.abs + +class FileUtils { + + @WorkerThread + fun saveImageToFile(file: File, resource: Drawable) { + val fos = FileOutputStream(file) + val bitmap = Bitmap.createBitmap( + resource.intrinsicWidth, + resource.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + resource.setBounds(0, 0, canvas.width, canvas.height) + resource.draw(canvas) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos) + bitmap.recycle() + fos.close() + } + + fun getFileFromUrl(context: Context, url: String) = + File(context.filesDir, abs(url.hashCode()).toString()) + + fun getUriSizeInMb(uri: Uri, context: Context): Double { + var cursor: Cursor? = null + return try { + val proj = arrayOf(MediaStore.Audio.Media.SIZE) + cursor = context.contentResolver.query(uri, proj, null, null, null) + val columnIndex: Int? = cursor?.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + cursor?.moveToFirst() + columnIndex?.let { + ((cursor?.getString(it)?.toDouble() ?: 0.0) / 1024) / 1024 + } ?: run { 0.0 } + } finally { + cursor?.close() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/HtmlUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/HtmlUtils.kt new file mode 100644 index 00000000..eed7370b --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/HtmlUtils.kt @@ -0,0 +1,66 @@ +package eu.theappfactory.dailyrecipes.util + +import android.os.Build +import android.text.Html +import android.text.SpannableString +import android.util.Patterns +import android.widget.TextView +import java.util.regex.Matcher +import java.util.regex.Pattern + +class HtmlUtils { + + fun extractLinks(text: String): Array? { + val links: MutableList = ArrayList() + val m: Matcher = Patterns.WEB_URL.matcher(text) + while (m.find()) { + val url: String = m.group() + links.add(url) + } + return links.toTypedArray() + } + + fun getSpannableString(text: String): SpannableString { + val spannableText: SpannableString? + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + spannableText = SpannableString(Html.fromHtml(text, Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)) + } else { + @Suppress("DEPRECATION") + spannableText = SpannableString(Html.fromHtml(text)) + } + return spannableText + } + + fun addHtmlUrls(urls: String): String { + try { + val pattern = Pattern.compile("(?i)\\b((?:https?://|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:\'\".,<>?“”‘’]))") + val matcher = pattern.matcher(urls) + return if (matcher.find()) { + if (matcher.group(1)?.startsWith(HTTP_PREFIX)!!) { + matcher.replaceAll("$LINK_PREFIX\"$1\">$1$CLOSING_LINK") + } else { + matcher.replaceAll("$LINK_PREFIX\"$1\">$1$CLOSING_LINK") + } + } else { + urls + } + } catch (e: Exception) { + return urls + } + } + + fun getHtmlText(textView: TextView, title: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + textView.text = Html.fromHtml(title, Html.FROM_HTML_MODE_COMPACT) + } else { + textView.text = Html.fromHtml(title) + } + } + + companion object { + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + const val LINK_PREFIX = "" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageListener.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageListener.kt new file mode 100644 index 00000000..895b9716 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageListener.kt @@ -0,0 +1,8 @@ +package eu.theappfactory.dailyrecipes.util + +import android.graphics.drawable.Drawable + +interface ImageListener { + + fun onImageRetrievedFromRemote(drawable: Drawable, url: String) +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageView.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageView.kt new file mode 100644 index 00000000..131e25b0 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ImageView.kt @@ -0,0 +1,50 @@ +package eu.theappfactory.dailyrecipes.util + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import java.io.File + +fun ImageView.loadFromCacheOrFallbackToUrl(file: File, url: String, imageListener: ImageListener, doCircleCrop: Boolean = false) = + loadImageWithListener(file, url, imageListener, doCircleCrop) + +fun ImageView.cancelLoading() = Glide.with(this).clear(this) + +private fun ImageView.loadImageWithListener(file: File, url: String, imageListener: ImageListener, doCircleCrop: Boolean = false) { + val requestBuilder = if (file.exists()) { + Glide.with(this).load(file).apply(RequestOptions().fitCenter()) + } else { + Glide.with(this).load(url).apply(RequestOptions().fitCenter()) + }.apply{ + if (doCircleCrop) apply(RequestOptions().circleCrop()) + } + + requestBuilder.addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + resource?.let { + imageListener.onImageRetrievedFromRemote(resource, url) + } + return false + } + }).into(this) +} diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/LiveData.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/LiveData.kt new file mode 100644 index 00000000..4d4374ec --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/LiveData.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +fun LiveData.combineWith( + liveData1: LiveData, + block: (T1?, T2?) -> R +): LiveData { + val result = MediatorLiveData() + result.addSource(this) { + result.value = block(this.value, liveData1.value) + } + + result.addSource(liveData1) { + result.value = block(this.value, liveData1.value) + } + + return result +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/LoadingLayoutBinding.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/LoadingLayoutBinding.kt new file mode 100644 index 00000000..37f360c2 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/LoadingLayoutBinding.kt @@ -0,0 +1,20 @@ +package eu.theappfactory.dailyrecipes.util + +import android.view.View +import android.view.animation.AnimationUtils +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.databinding.LoadingLayoutBinding + +fun LoadingLayoutBinding.showWithAnimation() { + loadingHolder.visibility = View.VISIBLE + val animFadeIn = AnimationUtils.loadAnimation(loadingHolder.context, R.anim.blink) + loadingImage.startAnimation(animFadeIn) + val animBlink = AnimationUtils.loadAnimation(loadingHolder.context, R.anim.blink) + loadingText.startAnimation(animBlink) +} + +fun LoadingLayoutBinding.hide() { + loadingImage.clearAnimation() + loadingHolder.visibility = View.GONE + loadingText.clearAnimation() +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/NetworkUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/NetworkUtils.kt similarity index 54% rename from app/src/main/java/eu/theappfactory/sampleapp/util/NetworkUtils.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/util/NetworkUtils.kt index 3f671a91..b64cf6ec 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/NetworkUtils.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/NetworkUtils.kt @@ -1,7 +1,5 @@ -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.util -import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager @@ -12,6 +10,10 @@ import javax.inject.Inject open class NetworkUtils @Inject constructor(private val context: Context) { + companion object { + const val TIMEOUT = 20L + } + fun hasNetworkConnection(): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -26,24 +28,9 @@ open class NetworkUtils @Inject constructor(private val context: Context) { return resultCode == ConnectionResult.SUCCESS } - fun launchPlayStoreLink(activity: Activity) { - val appPackageName = "com.google.android.gms" - try { - activity.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("market://details?id=$appPackageName") - ) - ) - activity.finish() - } catch (ex: ActivityNotFoundException) { - activity.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=$appPackageName") - ) - ) - activity.finish() - } + fun startBrowserWithUrl(url: String) { + val browserIntent = + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(browserIntent) } } diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/PreferencesErrorConverter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/PreferencesErrorConverter.kt new file mode 100644 index 00000000..824ee96e --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/PreferencesErrorConverter.kt @@ -0,0 +1,19 @@ +package eu.theappfactory.dailyrecipes.util + +import eu.theappfactory.dailyrecipes.R +import eu.theappfactory.dailyrecipes.data.userEvent.FirestoreUserEventDataSource.Companion.ERROR_UPDATING_USER_PREFS +import eu.theappfactory.dailyrecipes.data.userEvent.FirestoreUserEventDataSource.Companion.ERROR_USER_PREFS +import kotlinx.coroutines.ExperimentalCoroutinesApi + +@ExperimentalCoroutinesApi +object PreferencesErrorConverter { + + fun convert(message: String): Int { + return when (message) { + ERROR_USER_PREFS -> R.string.user_preferences_retrieve_error + ERROR_UPDATING_USER_PREFS -> R.string.user_preferences_update_error + + else -> R.string.error_no_connection_text + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ShareUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ShareUtils.kt new file mode 100644 index 00000000..53747b28 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ShareUtils.kt @@ -0,0 +1,83 @@ +package eu.theappfactory.dailyrecipes.util + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.net.UrlQuerySanitizer +import android.webkit.URLUtil + +class ShareUtils { + + fun composeEmail(addresses: Array?, subject: String?, context: Context) { + val intent = Intent(Intent.ACTION_SENDTO) + intent.data = Uri.parse("mailto:") + intent.putExtra(Intent.EXTRA_EMAIL, addresses) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + if (context.packageManager?.let { intent.resolveActivity(it) } != null) { + context.startActivity(intent) + } + } + + fun launchPlayStoreLink(appPackageName: String, activity: Activity) { + try { + var intent = activity.packageManager.getLaunchIntentForPackage(appPackageName) + if (intent == null) { + intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=$appPackageName") + ) + } + activity.startActivity(intent) + } catch (ex: ActivityNotFoundException) { + activity.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$appPackageName") + ) + ) + } finally { + activity.finish() + } + } + + fun openUrl(context: Context, url: String) { + var validUrl = url + if(!URLUtil.isValidUrl(url)){ + validUrl = HTTPS_PREFIX.plus(url) + } + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(validUrl)) + context.startActivity(browserIntent) + } + + fun openShareSheet(activity: Activity, uri: String) { + val sanitizer = UrlQuerySanitizer() + sanitizer.allowUnregisteredParamaters = true + sanitizer.parseUrl(uri) + val token = sanitizer.getValue("token") + + val dynamicLink = + "$DYNAMIC_LINKS_SUBDOMAIN?link=$DAILY_RECIPES_SITE&token=$token&apn=$ANDROID_IPHONE_PACKAGE_NAME&ifl=$IPHONE_IPAD_LANDING_PAGE&ipfl=$IPHONE_IPAD_LANDING_PAGE&ofl=$DAILY_RECIPES_SITE&ius=$DAILY_RECIPES_SITE/iphone-app&efr=1&ibi=$ANDROID_IPHONE_PACKAGE_NAME" + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Hoi, dit recept moet je proberen: $dynamicLink") + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + activity.startActivity(shareIntent) + } + + companion object { + + // Dynamic links are being build manually based on this guide: + // https://firebase.google.com/docs/dynamic-links/create-manually + const val IPHONE_IPAD_LANDING_PAGE = "https://theappfactory.eu/dailychef-iphone-app/" + const val DYNAMIC_LINKS_SUBDOMAIN = "https://links.dagelijkserecepten.tv/" + const val DAILY_RECIPES_SITE = "https://dailychef.eu" + const val ANDROID_IPHONE_PACKAGE_NAME = "eu.theappfactory.dailyrecipes" + const val HTTPS_PREFIX = "https://" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/String.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/String.kt new file mode 100644 index 00000000..4dd9c400 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/String.kt @@ -0,0 +1,36 @@ +package eu.theappfactory.dailyrecipes.util + +import android.graphics.Color +import android.text.Spannable +import android.text.style.ForegroundColorSpan +import android.util.Patterns + +private const val INVALID_POSITION = -1 +private const val SPAN_COLOR = "#f50057" + +fun String.generateSpanMatches(searchText: String): Spannable { + val spanText = Spannable.Factory.getInstance() + .newSpannable(this) + val startPos = this.toLowerCase().indexOf(searchText.toLowerCase()) + val endPos = startPos + searchText.length + if (startPos > INVALID_POSITION) { + spanText.setSpan( + ForegroundColorSpan(Color.parseColor(SPAN_COLOR)), + startPos, + endPos, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + return spanText +} + +fun String.convertHyphenToBullet(): String{ + var transformedString = this + if (this.startsWith("-")) { + transformedString = + this.substring(0, 1).replace("-", "•").plus(this.substring(1)) + } + return transformedString +} + +fun String?.isValidEmail() = !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/UseCase.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/UseCase.kt similarity index 92% rename from app/src/main/java/eu/theappfactory/sampleapp/util/UseCase.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/util/UseCase.kt index 761d5eb7..2babd67b 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/UseCase.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/UseCase.kt @@ -1,5 +1,6 @@ -package eu.theappfactory.sampleapp.util +package eu.theappfactory.dailyrecipes.util +import eu.theappfactory.dailyrecipes.networking.Result import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import timber.log.Timber diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewModelFactory.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewModelFactory.kt new file mode 100644 index 00000000..b0701ee6 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewModelFactory.kt @@ -0,0 +1,183 @@ +package eu.theappfactory.dailyrecipes.util + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.google.firebase.auth.FirebaseAuth +import com.mixpanel.android.mpmetrics.MixpanelAPI +import eu.theappfactory.dailyrecipes.cache.VideoCacheManager +import eu.theappfactory.dailyrecipes.data.ItemRepository +import eu.theappfactory.dailyrecipes.domain.home.* +import eu.theappfactory.dailyrecipes.domain.ingredients.* +import eu.theappfactory.dailyrecipes.domain.instructions.GetItemUseCase +import eu.theappfactory.dailyrecipes.domain.instructions.UpdateChatsUseCase +import eu.theappfactory.dailyrecipes.domain.invite.CheckInviteCodeUseCase +import eu.theappfactory.dailyrecipes.domain.invite.LoadAddressBookUseCase +import eu.theappfactory.dailyrecipes.domain.invite.UploadAddressBookUseCase +import eu.theappfactory.dailyrecipes.domain.launch.RegisterOrLoginCompleteUseCase +import eu.theappfactory.dailyrecipes.domain.launch.RegisterOrLoginCompletedUseCase +import eu.theappfactory.dailyrecipes.domain.main.CheckIfUserNeedsToLoginUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.GetUserFoodCategoriesUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.UpdateUserPreferencesUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.UserFoodCategoriesCompleteUseCase +import eu.theappfactory.dailyrecipes.domain.preferences.UserFoodCategoriesCompletedUseCase +import eu.theappfactory.dailyrecipes.domain.register.UpdateUserUseCase +import eu.theappfactory.dailyrecipes.domain.search.SearchItemsUseCase +import eu.theappfactory.dailyrecipes.domain.userdetails.GetUserUseCase +import eu.theappfactory.dailyrecipes.media.ImagePreLoader +import eu.theappfactory.dailyrecipes.persistence.PreferenceStorage +import eu.theappfactory.dailyrecipes.ui.categories.CategoriesViewModel +import eu.theappfactory.dailyrecipes.ui.favorites.FavoritesViewModel +import eu.theappfactory.dailyrecipes.ui.home.pager.ItemViewModel +import eu.theappfactory.dailyrecipes.ui.ingredients.AddIngredientsViewModel +import eu.theappfactory.dailyrecipes.ui.ingredients.IngredientsViewModel +import eu.theappfactory.dailyrecipes.ui.instructions.InstructionsViewModel +import eu.theappfactory.dailyrecipes.ui.invite.InviteCodeViewModel +import eu.theappfactory.dailyrecipes.ui.invite.addressbook.InviteFriendsViewModel +import eu.theappfactory.dailyrecipes.ui.launch.LauncherViewModel +import eu.theappfactory.dailyrecipes.ui.register.createuser.CreateUsernameViewModel +import eu.theappfactory.dailyrecipes.ui.search.SearchViewModel +import eu.theappfactory.dailyrecipes.ui.settings.DebugViewModel +import eu.theappfactory.dailyrecipes.ui.settings.SettingsViewModel +import eu.theappfactory.dailyrecipes.ui.signin.SignInViewModelDelegate +import eu.theappfactory.dailyrecipes.ui.terms.TermsAndConditionsViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import javax.inject.Inject +import javax.inject.Singleton + +@ExperimentalCoroutinesApi +@InternalCoroutinesApi +@Singleton +class ViewModelFactory @Inject constructor( + private val registerOrLoginCompletedUseCase: RegisterOrLoginCompletedUseCase, + private val registerOrLoginCompleteUseCase: RegisterOrLoginCompleteUseCase, + private val getFirstTimeUseItemsUseCase: GetFirstTimeUseItemsUseCase, + private val getItemsUseCase: GetItemsUseCase, + private val saveFavoriteItemUseCase: SaveFavoriteItemUseCase, + private val signInViewModelDelegate: SignInViewModelDelegate, + private val updateAlreadySeenUseCase: UpdateAlreadySeenUseCase, + private val getFavoriteItemsUseCase: GetFavoriteItemsUseCase, + private val getItemsUserHasNotYetSeen: GetItemsUserHasNotYetSeen, + private val eraseAlreadySeenUseCase: EraseAlreadySeenUseCase, + private val updateLikesUseCase: UpdateLikesUseCase, + private val insertIngredientsIntoAHAppUseCase: InsertIngredientsIntoAHAppUseCase, + private val checkIfUserNeedsToLoginUseCase: CheckIfUserNeedsToLoginUseCase, + private val searchIngredientsUseCase: SearchIngredientsUseCase, + private val getItemIngredientsUseCase: GetItemIngredientsUseCase, + private val saveIngredientsUseCase: SaveIngredientsUseCase, + private val getIngredientFromAHUseCase: GetIngredientFromAHUseCase, + private val updateIngredientsUseCase: UpdateIngredientsUseCase, + private val uploadIngredientsImageUseCase: UploadIngredientsImageUseCase, + private val searchItemsUseCase: SearchItemsUseCase, + private val getUserFoodCategoriesUseCase: GetUserFoodCategoriesUseCase, + private val updateUserPreferencesUseCase: UpdateUserPreferencesUseCase, + private val userFoodCategoriesCompletedUseCase: UserFoodCategoriesCompletedUseCase, + private val userFoodCategoriesCompleteUseCase: UserFoodCategoriesCompleteUseCase, + private val getItemUseCase: GetItemUseCase, + private val updateChatsUseCase: UpdateChatsUseCase, + private val updateUserUseCase: UpdateUserUseCase, + private val checkInviteCodeUseCase: CheckInviteCodeUseCase, + private val loadAddressBookUseCase: LoadAddressBookUseCase, + private val preferenceStorage: PreferenceStorage, + private val itemRepository: ItemRepository, + private val mixpanelAPI: MixpanelAPI, + private val videoCacheManager: VideoCacheManager, + private val imagePreLoader: ImagePreLoader, + private val getUserUseCase: GetUserUseCase, + private val auth: FirebaseAuth, + private val uploadAddressBookUseCase: UploadAddressBookUseCase +) : ViewModelProvider.NewInstanceFactory() { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = when (modelClass.simpleName) { + LauncherViewModel::class.java.simpleName -> LauncherViewModel( + signInViewModelDelegate, + registerOrLoginCompletedUseCase, + registerOrLoginCompleteUseCase, + userFoodCategoriesCompletedUseCase, + getUserUseCase, + preferenceStorage, + auth + ) as T + ItemViewModel::class.java.simpleName -> ItemViewModel( + signInViewModelDelegate, + saveFavoriteItemUseCase, + getFirstTimeUseItemsUseCase, + updateAlreadySeenUseCase, + getItemsUserHasNotYetSeen, + getItemsUseCase, + getFavoriteItemsUseCase, + searchItemsUseCase, + eraseAlreadySeenUseCase, + updateLikesUseCase, + itemRepository, + preferenceStorage, + mixpanelAPI, + videoCacheManager, + imagePreLoader + ) as T + SearchViewModel::class.java.simpleName -> SearchViewModel( + signInViewModelDelegate, + searchItemsUseCase + ) as T + FavoritesViewModel::class.java.simpleName -> FavoritesViewModel( + signInViewModelDelegate, + getFavoriteItemsUseCase, + getUserUseCase, + getUserFoodCategoriesUseCase + ) as T + TermsAndConditionsViewModel::class.java.simpleName -> TermsAndConditionsViewModel() as T + DebugViewModel::class.java.simpleName -> DebugViewModel( + eraseAlreadySeenUseCase, + signInViewModelDelegate, + preferenceStorage + ) as T + AddIngredientsViewModel::class.java.simpleName -> AddIngredientsViewModel( + searchIngredientsUseCase, + getItemIngredientsUseCase, + saveIngredientsUseCase, + getIngredientFromAHUseCase, + updateIngredientsUseCase, + uploadIngredientsImageUseCase + ) as T + IngredientsViewModel::class.java.simpleName -> IngredientsViewModel( + getItemIngredientsUseCase, + checkIfUserNeedsToLoginUseCase, + insertIngredientsIntoAHAppUseCase + ) as T + CategoriesViewModel::class.java.simpleName -> CategoriesViewModel( + getUserFoodCategoriesUseCase, + updateUserPreferencesUseCase, + userFoodCategoriesCompleteUseCase, + signInViewModelDelegate + ) as T + InstructionsViewModel::class.java.simpleName -> InstructionsViewModel( + getItemUseCase, + updateChatsUseCase, + itemRepository + ) as T + CreateUsernameViewModel::class.java.simpleName -> CreateUsernameViewModel( + signInViewModelDelegate, + updateUserUseCase, + getItemsUseCase, + getUserUseCase, + registerOrLoginCompleteUseCase, + mixpanelAPI + ) as T + InviteCodeViewModel::class.java.simpleName -> InviteCodeViewModel( + checkInviteCodeUseCase, + preferenceStorage + ) as T + InviteFriendsViewModel::class.java.simpleName -> InviteFriendsViewModel( + loadAddressBookUseCase, + preferenceStorage, + uploadAddressBookUseCase, + signInViewModelDelegate + ) as T + SettingsViewModel::class.java.simpleName -> SettingsViewModel( + preferenceStorage + ) as T + + else -> throw NullPointerException("View model not set") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewPager2.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewPager2.kt new file mode 100644 index 00000000..5e154b95 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewPager2.kt @@ -0,0 +1,6 @@ +package eu.theappfactory.dailyrecipes.util + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 + +fun ViewPager2.getRecyclerView() = (getChildAt(0) as? RecyclerView) \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewUtils.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewUtils.kt new file mode 100644 index 00000000..ef7e2a6a --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/ViewUtils.kt @@ -0,0 +1,60 @@ +package eu.theappfactory.dailyrecipes.util + +import android.content.Context +import android.view.View +import android.view.animation.* +import android.view.inputmethod.InputMethodManager +import kotlin.math.sin + +private const val ANIMATION_DURATION = 1200L +const val PAN_ANIMATION_DURATION = 2000L +const val FADE_ANIMATION_DURATION = 1500L +const val FADE_ANIMATION_START_DURATION = 1000L +private const val PAN_ANIMATION_DEGREE = 300.0 +fun View.addFadeAnimation() { + val fadeIn = AlphaAnimation(0.0f, 1.0f) + fadeIn.duration = ANIMATION_DURATION + fadeIn.fillAfter = true + startAnimation(fadeIn) +} + +fun View.addFadeOutAnimation() { + val fadeOut = AlphaAnimation(1.0f, 0.0f) + fadeOut.duration = ANIMATION_DURATION + fadeOut.fillAfter = true + startAnimation(fadeOut) +} + +fun View.addPanOutAnimation(){ + val animationSet = AnimationSet(false) + val anim: Animation + val pivotY = (1 + sin(PAN_ANIMATION_DEGREE) / 2).toFloat() + anim = ScaleAnimation( + 1.3f, 1f, 1.3f, 1f, Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, pivotY + ) + anim.duration = PAN_ANIMATION_DURATION + anim.setInterpolator(DecelerateInterpolator()) + val fadeOut = AlphaAnimation(1.0f, 0f) + fadeOut.duration = FADE_ANIMATION_DURATION + fadeOut.startOffset = FADE_ANIMATION_START_DURATION + fadeOut.fillAfter = true + fadeOut.interpolator = AccelerateInterpolator() + animationSet.addAnimation(anim) + animationSet.addAnimation(fadeOut) + startAnimation(animationSet) +} + +class ViewUtils { + + fun showKeyboard(view: View) { + view.requestFocus() + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } + + fun hideKeyboard(view: View) { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/util/signin/FirebaseAuthErrorCodeConverter.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/util/signin/FirebaseAuthErrorCodeConverter.kt similarity index 95% rename from app/src/main/java/eu/theappfactory/sampleapp/util/signin/FirebaseAuthErrorCodeConverter.kt rename to app/src/main/java/eu/theappfactory/dailyrecipes/util/signin/FirebaseAuthErrorCodeConverter.kt index 811e6330..22aa5873 100644 --- a/app/src/main/java/eu/theappfactory/sampleapp/util/signin/FirebaseAuthErrorCodeConverter.kt +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/util/signin/FirebaseAuthErrorCodeConverter.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package eu.theappfactory.sampleapp.util.signin +package eu.theappfactory.dailyrecipes.util.signin import com.firebase.ui.auth.ErrorCodes -import eu.theappfactory.sampleapp.R +import eu.theappfactory.dailyrecipes.R import timber.log.Timber /** diff --git a/app/src/main/java/eu/theappfactory/dailyrecipes/widget/FadingSnackbar.kt b/app/src/main/java/eu/theappfactory/dailyrecipes/widget/FadingSnackbar.kt new file mode 100644 index 00000000..ceede106 --- /dev/null +++ b/app/src/main/java/eu/theappfactory/dailyrecipes/widget/FadingSnackbar.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.theappfactory.dailyrecipes.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.postDelayed +import eu.theappfactory.dailyrecipes.R + +/** + * A custom snackbar implementation allowing more control over placement and entry/exit animations. + */ +class FadingSnackbar(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) { + + private val message: TextView + private val action: Button + + init { + val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true) + message = view.findViewById(R.id.snackbar_text) + action = view.findViewById(R.id.snackbar_action) + } + + fun dismiss() { + if (visibility == VISIBLE && alpha == 1f) { + animate() + .alpha(0f) + .withEndAction { visibility = GONE } + .duration = EXIT_DURATION + } + } + + fun show( + @StringRes messageId: Int = 0, + messageText: CharSequence? = null, + @StringRes actionId: Int? = null, + longDuration: Boolean = true, + actionClick: () -> Unit = { dismiss() }, + dismissListener: () -> Unit = { } + ) { + message.text = messageText ?: context.getString(messageId) + if (actionId != null) { + action.run { + visibility = VISIBLE + text = context.getString(actionId) + setOnClickListener { + actionClick() + } + } + } else { + action.visibility = GONE + } + alpha = 0f + visibility = VISIBLE + animate() + .alpha(1f) + .duration = ENTER_DURATION + val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION + postDelayed(showDuration) { + dismiss() + dismissListener() + } + } + + companion object { + private const val ENTER_DURATION = 300L + private const val EXIT_DURATION = 200L + private const val SHORT_DURATION = 1_500L + private const val LONG_DURATION = 2_750L + } +} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/dagger/ActivitiesModule.kt b/app/src/main/java/eu/theappfactory/sampleapp/dagger/ActivitiesModule.kt deleted file mode 100644 index b24ea078..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/dagger/ActivitiesModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -@file:Suppress("unused") - -package eu.theappfactory.sampleapp.dagger - -import dagger.Module -import dagger.android.ContributesAndroidInjector -import eu.theappfactory.sampleapp.ui.main.di.MainModule -import eu.theappfactory.sampleapp.ui.BaseActivity -import eu.theappfactory.sampleapp.ui.di.BaseModule -import eu.theappfactory.sampleapp.ui.home.di.HomeModule -import eu.theappfactory.sampleapp.ui.launch.di.LauncherModule -import eu.theappfactory.sampleapp.ui.launch.di.SignInModule -import eu.theappfactory.sampleapp.ui.notifications.di.NotificationsModule -import eu.theappfactory.sampleapp.ui.terms.di.TermsAndConditionsModule - -@Module( - includes = [ - LauncherModule::class, - MainModule::class, - HomeModule::class, - NotificationsModule::class, - TermsAndConditionsModule::class, - SignInModule::class - ] -) -interface ActivitiesModule { - - @ContributesAndroidInjector(modules = [BaseModule::class]) - fun bindBaseActivity(): BaseActivity -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/dagger/AppComponent.kt b/app/src/main/java/eu/theappfactory/sampleapp/dagger/AppComponent.kt deleted file mode 100644 index b584603c..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/dagger/AppComponent.kt +++ /dev/null @@ -1,30 +0,0 @@ -package eu.theappfactory.sampleapp.dagger - -import android.app.Application -import android.content.Context -import dagger.BindsInstance -import dagger.Component -import dagger.android.AndroidInjectionModule -import dagger.android.AndroidInjector -import eu.theappfactory.sampleapp.MainApplication -import eu.theappfactory.sampleapp.ui.ThemedActivityDelegateModule -import javax.inject.Singleton - -@Singleton -@Component(modules = [ - AndroidInjectionModule::class, - ActivitiesModule::class, - AppModule::class, - CoroutinesModule::class, - ThemedActivityDelegateModule::class -]) -interface AppComponent : AndroidInjector { - - override fun inject(instance: MainApplication) - - @Component.Factory - interface Factory { - - fun create(@BindsInstance applicationContext: Context, @BindsInstance application: Application): AppComponent - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/interactors/ObserveThemeModeUseCase.kt b/app/src/main/java/eu/theappfactory/sampleapp/interactors/ObserveThemeModeUseCase.kt deleted file mode 100644 index 9abb9980..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/interactors/ObserveThemeModeUseCase.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package eu.theappfactory.sampleapp.interactors - -import android.os.Build -import eu.theappfactory.sampleapp.dagger.DefaultDispatcher -import eu.theappfactory.sampleapp.persistence.PreferenceStorage -import eu.theappfactory.sampleapp.ui.Theme -import eu.theappfactory.sampleapp.ui.themeFromStorageKey -import eu.theappfactory.sampleapp.util.FlowUseCase -import eu.theappfactory.sampleapp.util.Result -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -open class ObserveThemeModeUseCase @Inject constructor( - private val preferenceStorage: PreferenceStorage, - @DefaultDispatcher coroutineDispatcher: CoroutineDispatcher -) : FlowUseCase(coroutineDispatcher) { - - override fun execute(parameters: Unit): Flow> { - return preferenceStorage.observableSelectedTheme.map { - val theme = when { - it != null -> themeFromStorageKey( - it - ) - // Provide defaults for when there is no theme set - Build.VERSION.SDK_INT >= 29 -> Theme.SYSTEM - else -> Theme.BATTERY_SAVER - } - Result.Success(theme) - } - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/persistence/PreferenceStorage.kt b/app/src/main/java/eu/theappfactory/sampleapp/persistence/PreferenceStorage.kt deleted file mode 100644 index 0aca2a46..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/persistence/PreferenceStorage.kt +++ /dev/null @@ -1,109 +0,0 @@ -package eu.theappfactory.sampleapp.persistence - -import android.content.Context -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import androidx.annotation.WorkerThread -import androidx.core.content.edit -import eu.theappfactory.sampleapp.persistence.BooleanPreference.Companion.PREFS_NAME -import eu.theappfactory.sampleapp.persistence.BooleanPreference.Companion.PREF_DARK_MODE_ENABLED -import eu.theappfactory.sampleapp.persistence.BooleanPreference.Companion.PREF_ONBOARDING -import eu.theappfactory.sampleapp.persistence.BooleanPreference.Companion.PREF_SNACKBAR_IS_STOPPED -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.ConflatedBroadcastChannel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - -/** - * Storage for app and user preferences. - */ -interface PreferenceStorage { - var registerCompleted: Boolean - var selectedTheme: String? - var snackbarIsStopped: Boolean - var observableSelectedTheme: Flow -} - -/** - * [PreferenceStorage] impl backed by [android.content.SharedPreferences]. - */ -@Singleton -@ExperimentalCoroutinesApi -@FlowPreview -class SharedPreferenceStorage @Inject constructor(context: Context) : PreferenceStorage { - - private val selectedThemeChannel: ConflatedBroadcastChannel by lazy { - ConflatedBroadcastChannel().also { channel -> - channel.offer(selectedTheme) - } - } - - private val prefs: Lazy = lazy { - // Lazy to prevent IO access to main thread. - context.applicationContext.getSharedPreferences( - PREFS_NAME, Context.MODE_PRIVATE - ).apply { - registerOnSharedPreferenceChangeListener(changeListener) - } - } - - private val changeListener = OnSharedPreferenceChangeListener { _, key -> - when (key) { - PREF_DARK_MODE_ENABLED -> selectedThemeChannel.offer(selectedTheme) - } - } - - override var registerCompleted by BooleanPreference(prefs, PREF_ONBOARDING, false) - - override var selectedTheme by StringPreference(prefs, PREF_DARK_MODE_ENABLED, null) - - override var snackbarIsStopped by BooleanPreference(prefs, PREF_SNACKBAR_IS_STOPPED, false) - - override var observableSelectedTheme: Flow - get() = selectedThemeChannel.asFlow() - set(_) = throw IllegalAccessException("This property can't be changed") -} - -class BooleanPreference( - private val preferences: Lazy, - private val name: String, - private val defaultValue: Boolean -) : ReadWriteProperty { - - @WorkerThread - override fun getValue(thisRef: Any, property: KProperty<*>): Boolean { - return preferences.value.getBoolean(name, defaultValue) - } - - override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) { - preferences.value.edit { putBoolean(name, value) } - } - - companion object { - const val PREFS_NAME = "adssched" - const val PREF_ONBOARDING = "pref_onboarding" - const val PREF_DARK_MODE_ENABLED = "pref_dark_mode" - const val PREF_SNACKBAR_IS_STOPPED = "pref_snackbar_is_stopped" - } -} - -class StringPreference( - private val preferences: Lazy, - private val name: String, - private val defaultValue: String? -) : ReadWriteProperty { - - @WorkerThread - override fun getValue(thisRef: Any, property: KProperty<*>): String? { - return preferences.value.getString(name, defaultValue) - } - - override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) { - preferences.value.edit { putString(name, value) } - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/BaseActivity.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/BaseActivity.kt deleted file mode 100644 index be742967..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/BaseActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eu.theappfactory.sampleapp.ui - -import dagger.android.support.DaggerAppCompatActivity - -abstract class BaseActivity : DaggerAppCompatActivity() { - - protected fun hideToolbar() { - supportActionBar?.hide() - actionBar?.hide() - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/PopupDialog.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/PopupDialog.kt deleted file mode 100644 index a1252e9b..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/PopupDialog.kt +++ /dev/null @@ -1,31 +0,0 @@ -package eu.theappfactory.sampleapp.ui - -import android.app.AlertDialog -import android.content.Context -import androidx.core.content.ContextCompat -import eu.theappfactory.sampleapp.R - -class PopupDialog { - - interface PopupDialogCallback { - fun hasClicked(btn: Int) - } - - fun show(context: Context, title: String, message: String, popupDialogCallback: PopupDialogCallback?) { - val dialog = AlertDialog.Builder(context, R.style.Theme_AppCompat_DayNight_Dialog) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - popupDialogCallback?.hasClicked(0) - } - .setCancelable(false) - .create() - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) - } - - dialog.show() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/Theme.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/Theme.kt deleted file mode 100644 index fb254c1c..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/Theme.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package eu.theappfactory.sampleapp.ui - -/** - * Represents the available UI themes for the application - */ -enum class Theme(val storageKey: String) { - LIGHT("light"), - DARK("dark"), - SYSTEM("system"), - BATTERY_SAVER("battery_saver") -} - -/** - * Returns the matching [Theme] for the given [storageKey] value. - */ -fun themeFromStorageKey(storageKey: String): Theme { - return Theme.values().first { it.storageKey == storageKey } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegate.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegate.kt deleted file mode 100644 index b7587c00..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegate.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package eu.theappfactory.sampleapp.ui - -import androidx.lifecycle.LiveData -import androidx.lifecycle.liveData -import eu.theappfactory.sampleapp.interactors.ObserveThemeModeUseCase -import eu.theappfactory.sampleapp.util.successOr -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.flow.collect -import javax.inject.Inject - -/** - * Interface to implement activity theming via a ViewModel. - * - * You can inject a implementation of this via Dagger2, then use the implementation as an interface - * delegate to add the functionality without writing any code - * - * Example usage: - * ``` - * class MyViewModel @Inject constructor( - * themedActivityDelegate: ThemedActivityDelegate - * ) : ViewModel(), ThemedActivityDelegate by themedActivityDelegate { - * ``` - */ -interface ThemedActivityDelegate { - /** - * Allows observing of the current theme - */ - val theme: LiveData -} - -class ThemedActivityDelegateImpl @Inject constructor( - private val observeThemeUseCase: ObserveThemeModeUseCase -) : ThemedActivityDelegate { - - @InternalCoroutinesApi - @ExperimentalCoroutinesApi - override val theme: LiveData = liveData { - observeThemeUseCase(Unit).collect { - emit(it.successOr(Theme.SYSTEM)) - } - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegateModule.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegateModule.kt deleted file mode 100644 index d90a587b..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/ThemedActivityDelegateModule.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eu.theappfactory.sampleapp.ui - -import dagger.Binds -import dagger.Module -import javax.inject.Singleton - -@Module -abstract class ThemedActivityDelegateModule { - @Singleton - @Binds - abstract fun provideThemedActivityDelegate( - impl: ThemedActivityDelegateImpl - ): ThemedActivityDelegate -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/di/BaseModule.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/di/BaseModule.kt deleted file mode 100644 index e97cb740..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/di/BaseModule.kt +++ /dev/null @@ -1,6 +0,0 @@ -package eu.theappfactory.sampleapp.ui.di - -import dagger.Module - -@Module -interface BaseModule diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeFragment.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeFragment.kt deleted file mode 100644 index 633d4cf2..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eu.theappfactory.sampleapp.ui.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders -import androidx.navigation.fragment.NavHostFragment -import dagger.android.support.DaggerFragment -import eu.theappfactory.sampleapp.R -import eu.theappfactory.sampleapp.util.ViewModelFactory -import javax.inject.Inject - -class HomeFragment : DaggerFragment() { - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private lateinit var viewModel: HomeViewModel - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = ViewModelProviders.of(this, viewModelFactory).get(HomeViewModel::class.java) - - val root = inflater.inflate(R.layout.fragment_home, container, false) - val textView: TextView = root.findViewById(R.id.text_home) - viewModel.text.observe(viewLifecycleOwner, Observer { - textView.text = it - }) - return root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - view.findViewById(R.id.button_home).setOnClickListener { - val action = HomeFragmentDirections - .actionHomeFragmentToHomeSecondFragment("From HomeFragment") - NavHostFragment.findNavController(this@HomeFragment) - .navigate(action) - } - } -} diff --git a/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeSecondFragment.kt b/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeSecondFragment.kt deleted file mode 100644 index 9dcda260..00000000 --- a/app/src/main/java/eu/theappfactory/sampleapp/ui/home/HomeSecondFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.theappfactory.sampleapp.ui.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import dagger.android.support.DaggerFragment -import eu.theappfactory.sampleapp.R -import eu.theappfactory.sampleapp.util.ViewModelFactory -import javax.inject.Inject - -class HomeSecondFragment : DaggerFragment() { - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val args: HomeSecondFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_home_second, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - view.findViewById(R.id.textview_home_second).text = - getString(R.string.hello_home_second, args.myArg) - - view.findViewById