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
+[](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