Initial commit

This commit is contained in:
2020-05-15 11:04:34 +01:00
commit 9f23f6ab05
77 changed files with 3495 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
*.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

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# MoneyBox Android Technical Task
A small application for viewing investment products.
# Part A
### Bug 1 - Layout does not look as expected
- Constraints added to all TextInputLayouts
- Edit text views within TextInputLayouts and TextInputLayouts within match parent and the TextInputLayouts match parents
- correct spacing between edges margin of 12dp left and right
- email TextInputLayout with a 48dp margin at the top
### Bug 2 - Validation is incorrect
- Email matching android email matcher
- allValid within allFieldsValid() is true by default
- once allValid changes to false it remains false and no login called
- Changes to false every failed validation
### Bug 3 - Animation is looping incorrectly
- set minimum and maximum frames based the firstAnim Pair<Int, Int> range respectively
- set an animation completion listener and wait for first animation play to finish
- on completion of first animation change the min and max frames based on secondAnim Pair<Int, Int> range
- play animation with new min and max frames
# Part B
## Requirements
Minimum android SDK version 21, Android 5.0.0 (Lollipop)
Permissions : Internet, Network State
## Features
- Login (Name optional)
- View investment products
- Add one off payment of £20 to an investment
## Architectural Pattern
MVVM - Model View Viewmodel
SOLID coding
## Jetpack
* [AndroidX](https://developer.android.com/jetpack)
## Unit tests
### Test case one
- Respository Unit test (Networkings)
### Test case two
- Repository Unit test (Storage)
### Test case one
- Login viewmodel test
### Test case two
- UserAccount viewmodel test
## Integration tests
### Test case one
- LoginActivity UI test
## Built With
* [Kodein](https://github.com/Kodein-Framework/Kodein-DI) - Painless Kotlin Dependency Injection
* [Retrofit](https://github.com/square/retrofit) - Type-safe HTTP client for Android and Java by Square, Inc
* [Secured Preference Store](https://github.com/iamMehedi/Secured-Preference-Store) - A SharedPreferences wrapper for Android that encrypts the content with 256 bit AES encryption
* [Lottie](https://github.com/airbnb/lottie-android) - Lottie is a mobile library for Android and iOS that parses Adobe After Effects animations exported as json with Bodymovin and renders them natively on mobile!
## Submitted by
* **Haider Malik** - *Android Developer*

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

76
app/build.gradle Normal file
View File

@@ -0,0 +1,76 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
repositories {
google()
jcenter()
}
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.example.minimoneybox"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
targetCompatibility = 1.8
sourceCompatibility = "1.8"
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
implementation 'com.airbnb.android:lottie:2.7.0'
androidTestImplementation 'androidx.test:rules:1.1.1'
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
//mock websever for testing retrofit responses
testImplementation("com.squareup.okhttp3:mockwebserver:4.6.0")
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
//mockito and livedata testing
testImplementation 'org.mockito:mockito-inline:2.13.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
//Retrofit and GSON
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
//Kotlin Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
//Kodein Dependency Injection
implementation "org.kodein.di:kodein-di-generic-jvm:6.2.1"
implementation "org.kodein.di:kodein-di-framework-android-x:6.2.1"
//security for secured preferences
implementation "androidx.security:security-crypto:1.0.0-rc01"
//keystore preferences wrapper
implementation 'online.devliving:securedpreferencestore:0.7.4'
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# 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
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,138 @@
package com.example.minimoneybox
import android.view.View
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.example.minimoneybox.ui.login.LoginActivity
import com.google.android.material.textfield.TextInputLayout
import org.hamcrest.CoreMatchers.not
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
val validEmail = "email_address@domain.com"
val validPassword = "oLa95he!ui"
val invalidEmail = "email_address"
val invalidPassword = "oaa"
val validName = "Ludacris"
val invalidName = "Dj"
@Rule
@JvmField
var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
@Test
fun noEntriesSubmission_invalidLogin() {
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_password)).check(matches(hasTextInputLayoutErrorText(getString(R.string.password_error))))
onView(withId(R.id.til_email)).check(matches(hasTextInputLayoutErrorText(getString(R.string.email_address_error))))
}
@Test
fun validEmailNoPasswordNoName_invalidLogin() {
onView(withId(R.id.et_email)).perform(typeText(validEmail)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_password)).check(matches(hasTextInputLayoutErrorText(getString(R.string.password_error))))
onView(withId(R.id.til_email)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.email_address_error)))))
}
@Test
fun validPasswordNoEmailNoName_invalidLogin() {
onView(withId(R.id.et_password)).perform(typeText(validPassword)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_email)).check(matches(hasTextInputLayoutErrorText(getString(R.string.email_address_error))))
onView(withId(R.id.til_password)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.password_error)))))
}
@Test
fun validEmailAndPassword_noName_validLogin() {
onView(withId(R.id.et_email)).perform(typeText(validEmail)).check(matches(isDisplayed()))
onView(withId(R.id.et_password)).perform(typeText(validPassword)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_email)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.email_address_error)))))
onView(withId(R.id.til_password)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.password_error)))))
}
@Test
fun validEmailAndPassword_validName_validLogin() {
onView(withId(R.id.et_email)).perform(typeText(validEmail)).check(matches(isDisplayed()))
onView(withId(R.id.et_password)).perform(typeText(validPassword)).check(matches(isDisplayed()))
onView(withId(R.id.et_name)).perform(typeText(validName)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_email)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.email_address_error)))))
onView(withId(R.id.til_password)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.password_error)))))
onView(withId(R.id.til_name)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.full_name_error)))))
}
@Test
fun validEmailAndPassword_invalidName_validLogin() {
onView(withId(R.id.et_email)).perform(typeText(validEmail)).check(matches(isDisplayed()))
onView(withId(R.id.et_password)).perform(typeText(validPassword)).check(matches(isDisplayed()))
onView(withId(R.id.et_name)).perform(typeText(invalidName)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_email)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.email_address_error)))))
onView(withId(R.id.til_password)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.password_error)))))
onView(withId(R.id.til_name)).check(matches(hasTextInputLayoutErrorText(getString(R.string.full_name_error))))
}
@Test
fun invalidEmailValidPasswordNoName_invalidLogin() {
onView(withId(R.id.et_email)).perform(typeText(invalidEmail)).check(matches(isDisplayed()))
onView(withId(R.id.et_password)).perform(typeText(validPassword)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_password)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.password_error)))))
onView(withId(R.id.til_email)).check(matches(hasTextInputLayoutErrorText(getString(R.string.email_address_error))))
}
@Test
fun validEmailInvalidPasswordNoName_invalidLogin() {
onView(withId(R.id.et_email)).perform(typeText(validEmail)).check(matches(isDisplayed()))
onView(withId(R.id.et_password)).perform(typeText(invalidPassword)).check(matches(isDisplayed()))
onView(withId(R.id.btn_sign_in)).perform(click())
onView(withId(R.id.til_password)).check(matches(hasTextInputLayoutErrorText(getString(R.string.password_error))))
onView(withId(R.id.til_email)).check(matches(not(hasTextInputLayoutErrorText(getString(R.string.email_address_error)))))
}
private fun getString(id: Int): String = mActivityTestRule.activity.getString(id)
private fun hasTextInputLayoutErrorText(expectedErrorText: String): Matcher<View?>? {
return object : TypeSafeMatcher<View?>() {
override fun matchesSafely(item: View?): Boolean {
if (item !is TextInputLayout) {
return false
}
val error = (item).error ?: return false
val hint = error.toString()
return expectedErrorText == hint
}
override fun describeTo(description: org.hamcrest.Description?) {}
}
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.minimoneybox">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-sdk tools:overrideLibrary="androidx.security"/>
<application
android:name=".application.MoneyBoxApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ui.user.AccountActivity"></activity>
<activity
android:name=".ui.login.LoginActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
</application>
</manifest>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
package com.example.minimoneybox.application
import android.app.Application
import com.example.minimoneybox.data.network.LoginApi
import com.example.minimoneybox.data.network.UserAccountApi
import com.example.minimoneybox.data.network.interceptors.HeaderInterceptor
import com.example.minimoneybox.data.network.interceptors.NetworkConnectionInterceptor
import com.example.minimoneybox.data.prefs.KeystoreStorage
import com.example.minimoneybox.data.repository.MoneyBoxRepositoryImpl
import com.example.minimoneybox.ui.login.LoginViewModelFactory
import com.example.minimoneybox.ui.user.UserAccountsViewModelFactory
import com.google.gson.Gson
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
class MoneyBoxApplication: Application(), KodeinAware{
//Kodein aware to initialise the classes used for DI
override val kodein = Kodein.lazy {
import(androidXModule(this@MoneyBoxApplication))
//instance is context
bind() from singleton { NetworkConnectionInterceptor(instance()) }
bind() from singleton { HeaderInterceptor() }
//instance is context
bind() from singleton { KeystoreStorage(instance()) }
//instances above 2 interceptors
bind() from singleton { LoginApi(instance(), instance()) }
bind() from singleton { UserAccountApi(instance(), instance()) }
bind() from singleton { Gson() }
//instances are context,
bind() from singleton { MoneyBoxRepositoryImpl(instance(),instance(),instance(), instance()) }
//Viewmodel created from context
bind() from provider {
LoginViewModelFactory(
instance()
)
}
bind() from provider {
UserAccountsViewModelFactory(
instance()
)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.example.minimoneybox.data.models
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class Product {
@SerializedName("Id")
@Expose
var id: Int? = null
@SerializedName("Name")
@Expose
var name: String? = null
@SerializedName("CategoryType")
@Expose
var categoryType: String? = null
@SerializedName("Type")
@Expose
var type: String? = null
@SerializedName("FriendlyName")
@Expose
var friendlyName: String? = null
@SerializedName("CanWithdraw")
@Expose
var canWithdraw: Boolean? = null
@SerializedName("ProductHexCode")
@Expose
var productHexCode: String? = null
@SerializedName("AnnualLimit")
@Expose
var annualLimit: Double? = null
@SerializedName("DepositLimit")
@Expose
var depositLimit: Double? = null
@SerializedName("MinimumWeeklyDeposit")
@Expose
var minimumWeeklyDeposit: Double? = null
@SerializedName("MaximumWeeklyDeposit")
@Expose
var maximumWeeklyDeposit: Double? = null
}

View File

@@ -0,0 +1,38 @@
package com.example.minimoneybox.data.models
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class ProductsResponse {
@SerializedName("Id")
@Expose
var id: Int? = null
@SerializedName("PlanValue")
@Expose
var planValue: Double? = null
@SerializedName("Moneybox")
@Expose
var moneybox: Double? = null
@SerializedName("SubscriptionAmount")
@Expose
var subscriptionAmount: Double? = null
@SerializedName("TotalFees")
@Expose
var totalFees: Double? = null
@SerializedName("IsSelected")
@Expose
var isSelected: Boolean? = null
@SerializedName("IsFavourite")
@Expose
var isFavourite: Boolean? = null
@SerializedName("CollectionDayMessage")
@Expose
var collectionDayMessage: String? = null
@SerializedName("Product")
@Expose
var product: Product? = null
@SerializedName("state")
@Expose
var state: Int? = null
}

View File

@@ -0,0 +1,20 @@
package com.example.minimoneybox.data.models
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class SessionObject {
@SerializedName("BearerToken")
@Expose
var bearerToken: String? = null
@SerializedName("ExternalSessionId")
@Expose
var externalSessionId: String? = null
@SerializedName("SessionExternalId")
@Expose
var sessionExternalId: String? = null
@SerializedName("ExpiryInSeconds")
@Expose
var expiryInSeconds: Int? = null
}

View File

@@ -0,0 +1,7 @@
package com.example.minimoneybox.data.models
data class UserLoginObject(
val Email: String,
val Password: String,
val Idfa: String = "ANYTHING"
)

View File

@@ -0,0 +1,46 @@
package com.example.minimoneybox.data.network
import com.example.minimoneybox.data.network.interceptors.HeaderInterceptor
import com.example.minimoneybox.data.network.interceptors.NetworkConnectionInterceptor
import com.example.minimoneybox.data.network.response.LoginResponse
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.POST
interface LoginApi {
@POST("users/login")
suspend fun loginApiRequest(@Body body: HashMap<String, String>): Response<LoginResponse>
/**
* Return [Retrofit] class with api to login
* [NetworkConnectionInterceptor] to intercept when there is no network
* [HeaderInterceptor] add custom headers to retrofit calls
*/
companion object{
operator fun invoke(
//injected @params
networkConnectionInterceptor: NetworkConnectionInterceptor,
customHeaderInterceptor: HeaderInterceptor
) : LoginApi{
//okHttpClient with interceptors
val okkHttpclient = OkHttpClient.Builder()
.addNetworkInterceptor(networkConnectionInterceptor)
.addInterceptor(customHeaderInterceptor)
.build()
//retrofit to be used in @Repository
return Retrofit.Builder()
.client(okkHttpclient)
.baseUrl("https://api-test01.moneyboxapp.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(LoginApi::class.java)
}
}
}

View File

@@ -0,0 +1,63 @@
package com.example.minimoneybox.data.network
import android.util.Log
import org.json.JSONException
import org.json.JSONObject
import retrofit2.Response
import java.io.IOException
/**
* This abstract class extract objects from [Response]
*/
abstract class SafeApiCall {
suspend fun <T : Any> responseUnwrap(
call: suspend () -> Response<T>
): T {
val response = call.invoke()
if (response.isSuccessful) {
//return the object within the response body
return response.body()!!
} else {
//the response was unsuccessful
//throw IOException error
throw IOException(errorMessage(response))
}
}
private fun <T> errorMessage(errorResponse: Response<T>): String {
val errorBody = errorResponse.errorBody()?.string()
val errorCode = "Error Code: ${errorResponse.code()}"
val errorMessageString = errorBody.getError()
//build a log message to log in console
val log = if (errorMessageString.isNullOrEmpty()){
errorCode
}else{
StringBuilder()
.append(errorCode)
.append("\n")
.append(errorMessageString)
.toString()
}
Log.e("Api Response Error", log)
//return error message
//if null return error code
return errorMessageString ?: errorCode
}
private fun String?.getError(): String? {
this?.let {
try {
//convert response to JSON
//extract ["Message"] from error body
return JSONObject(it).getString("Message")
} catch (e: JSONException) {
Log.e(SafeApiCall::class.java.simpleName, e.message)
}
}
return null
}
}

View File

@@ -0,0 +1,57 @@
package com.example.minimoneybox.data.network
import com.example.minimoneybox.data.network.interceptors.HeaderInterceptor
import com.example.minimoneybox.data.network.interceptors.NetworkConnectionInterceptor
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
interface UserAccountApi {
@GET("investorproducts")
suspend fun getProductsFromApi(
@Header("Authorization") authHeader: String
): Response<ProductApiResponse>
@POST("oneoffpayments")
suspend fun oneOffPaymentsFromApi(
@Body body: HashMap<String, Int>,
@Header("Authorization") authHeader: String
): Response<PaymentResponse>
/**
* Return [Retrofit] class with api to User accounts
* [NetworkConnectionInterceptor] to intercept when there is no network
* [HeaderInterceptor] add custom headers to retrofit calls
*/
companion object {
operator fun invoke(
//injected @params
networkConnectionInterceptor: NetworkConnectionInterceptor,
customHeaderInterceptor: HeaderInterceptor
): UserAccountApi {
//okHttpClient with interceptors
val okkHttpclient = OkHttpClient.Builder()
.addNetworkInterceptor(networkConnectionInterceptor)
.addInterceptor(customHeaderInterceptor)
.build()
//retrofit to be used in @Repository
return Retrofit.Builder()
.client(okkHttpclient)
.baseUrl("https://api-test01.moneyboxapp.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(UserAccountApi::class.java)
}
}
}

View File

@@ -0,0 +1,23 @@
package com.example.minimoneybox.data.network.interceptors
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class HeaderInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val request: Request = original.newBuilder()
.header("AppId", "3a97b932a9d449c981b595")
.header("Content-Type", "application/json")
.header("appVersion","7.8.0")
.header("apiVersion", "3.0.0")
.method(original.method(), original.body())
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,44 @@
package com.example.minimoneybox.data.network.interceptors
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.annotation.RequiresApi
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
class NetworkConnectionInterceptor(
context: Context
) : Interceptor {
private val applicationContext = context.applicationContext
override fun intercept(chain: Interceptor.Chain): Response {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& !isInternetAvailable()) {
throw IOException("Make sure you have an active data connection")
}
return chain.proceed(chain.request())
}
@RequiresApi(Build.VERSION_CODES.M)
private fun isInternetAvailable(): Boolean {
var result = false
val connectivityManager =
applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
connectivityManager?.let {
it.getNetworkCapabilities(connectivityManager.activeNetwork)?.apply {
result = when {
hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
}
return result
}
}

View File

@@ -0,0 +1,11 @@
package com.example.minimoneybox.data.network.response
import com.example.minimoneybox.data.models.SessionObject
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class LoginResponse(
@SerializedName("Session")
@Expose
val session: SessionObject? = null
)

View File

@@ -0,0 +1,11 @@
package com.example.minimoneybox.data.network.response
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class PaymentResponse {
@SerializedName("Moneybox")
@Expose
var moneybox: Double? = null
}

View File

@@ -0,0 +1,27 @@
package com.example.minimoneybox.data.network.response
import com.example.minimoneybox.data.models.ProductsResponse
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class ProductApiResponse {
@SerializedName("MoneyboxEndOfTaxYear")
@Expose
var moneyboxEndOfTaxYear: String? = null
@SerializedName("TotalPlanValue")
@Expose
var totalPlanValue: Double? = null
@SerializedName("TotalEarnings")
@Expose
var totalEarnings: Double? = null
@SerializedName("TotalContributionsNet")
@Expose
var totalContributionsNet: Double? = null
@SerializedName("TotalEarningsAsPercentage")
@Expose
var totalEarningsAsPercentage: Double? = null
@SerializedName("ProductResponses")
@Expose
var productsRespons: List<ProductsResponse>? = null
}

View File

@@ -0,0 +1,71 @@
package com.example.minimoneybox.data.prefs
import android.content.Context
import devliving.online.securedpreferencestore.DefaultRecoveryHandler
import devliving.online.securedpreferencestore.SecuredPreferenceStore
const val emailConstant = "EMAIL"
const val passwordConstant = "PASSWORD"
const val authToken = "AUTH"
/**
* Secured preferences class storing values in keystore
* inclusive of version down to [android.os.Build.VERSION_CODES.LOLLIPOP]
*/
class KeystoreStorage(context: Context) {
//lazy initialization
private val prefStore: SecuredPreferenceStore by lazy {
SecuredPreferenceStore.getSharedInstance()
}
//init block to create instance of
init {
val storeFileName = "securedStore"
val keyPrefix = "mba"
val seedKey = "SecuredSeedData".toByteArray()
SecuredPreferenceStore.init(
context.applicationContext,
storeFileName,
keyPrefix,
seedKey,
DefaultRecoveryHandler()
)
}
@Synchronized
fun saveCredentialsInPrefs(
email: String,
password: String
) {
val editor = prefStore.edit()
editor.putString(emailConstant, email)
editor.putString(passwordConstant, password)
editor.apply()
}
@Synchronized
fun loadCredentialsFromPrefs(): Pair<String, String> {
val email = prefStore.getString(emailConstant, "")
val password = prefStore.getString(passwordConstant, "")
return Pair(email, password)
}
@Synchronized
fun saveTokenInPrefs(
token: String
) {
val editor = prefStore.edit()
editor.putString(authToken, token)
editor.apply()
}
@Synchronized
fun loadTokenFromPrefs(): String? {
return prefStore.getString(authToken, "")
}
}

View File

@@ -0,0 +1,59 @@
package com.example.minimoneybox.data.prefs
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class SecurePrefs(context: Context){
val emailConstant = "EMAIL"
val passwordConstant = "PASSWORD"
val nameConstant = "NAME"
val authToken = "AUTH"
private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val prefs = EncryptedSharedPreferences.create(
"encrypted_shared_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
@Synchronized
fun saveCredentialsInPrefs(
email: String,
password: String
) {
val editor = prefs.edit()
editor.putString(emailConstant, email)
editor.putString(passwordConstant, password)
editor.apply()
}
@Synchronized
fun loadCredentialsFromPrefs(): Pair<String,String> {
val email = prefs.getString(emailConstant, "")
val password = prefs.getString(passwordConstant, "")
return Pair(email, password)
}
@Synchronized
fun saveTokenInPrefs(
token: String
) {
val editor = prefs.edit()
editor.putString(authToken, token)
editor.apply()
}
@Synchronized
fun loadTokenFromPrefs(): String? {
return prefs.getString(authToken, "")
}
}

View File

@@ -0,0 +1,29 @@
package com.example.minimoneybox.data.repository
import com.example.minimoneybox.data.network.interceptors.HeaderInterceptor
import com.example.minimoneybox.data.network.interceptors.NetworkConnectionInterceptor
import com.example.minimoneybox.data.network.response.LoginResponse
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import retrofit2.Retrofit
/**
* Repository interface to implement repository methods
*
*/
interface MoneyBoxRepository {
suspend fun loginUser(email: String, password: String): LoginResponse?
fun saveCredentials(email: String, password: String)
fun loadCredentials(): Pair<String, String>
fun saveAuthToken(token: String)
fun loadAuthToken(): String?
suspend fun getProducts(authCode: String): ProductApiResponse?
suspend fun oneOffPayment(produceId: Int, authCode: String): PaymentResponse?
}

View File

@@ -0,0 +1,73 @@
package com.example.minimoneybox.data.repository
import com.example.minimoneybox.data.network.LoginApi
import com.example.minimoneybox.data.network.UserAccountApi
import com.example.minimoneybox.data.network.response.LoginResponse
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import com.example.minimoneybox.data.prefs.KeystoreStorage
import com.example.minimoneybox.data.network.SafeApiCall
import com.example.minimoneybox.data.network.interceptors.HeaderInterceptor
import com.example.minimoneybox.data.network.interceptors.NetworkConnectionInterceptor
import com.google.gson.Gson
import retrofit2.Retrofit
/**
* [MoneyBoxRepository] implementations in this Repository
*
*/
open class MoneyBoxRepositoryImpl(
private val prefs: KeystoreStorage,
private val loginApi: LoginApi,
private val userAccountApi: UserAccountApi,
private val gson: Gson
) : MoneyBoxRepository, SafeApiCall() {
override suspend fun loginUser(
email: String,
password: String
): LoginResponse{
val hash = HashMap<String, String>()
hash["Email"] = email
hash["Password"] = password
hash["Idfa"] = "ANYTHING"
return responseUnwrap{
loginApi.loginApiRequest(hash)
}
}
override fun saveCredentials(email: String, password: String) {
prefs.saveCredentialsInPrefs(email, password)
}
override fun loadCredentials(): Pair<String, String> {
return prefs.loadCredentialsFromPrefs()
}
override fun saveAuthToken(token: String) {
prefs.saveTokenInPrefs(token)
}
override fun loadAuthToken(): String? {
return prefs.loadTokenFromPrefs()
}
override suspend fun getProducts(authCode: String): ProductApiResponse {
return responseUnwrap{
userAccountApi.getProductsFromApi("Bearer $authCode")
}
}
override suspend fun oneOffPayment(
produceId: Int, authCode: String
): PaymentResponse? {
val hash = HashMap<String, Int>()
hash["Amount"] = 20
hash["InvestorProductId"] = produceId
return responseUnwrap {
userAccountApi.oneOffPaymentsFromApi(hash,"Bearer $authCode")
}
}
}

View File

@@ -0,0 +1,142 @@
package com.example.minimoneybox.ui.login
import android.animation.Animator
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.example.minimoneybox.R
import com.example.minimoneybox.ui.user.AccountActivity
import com.example.minimoneybox.utils.displayToast
import com.example.minimoneybox.utils.hide
import com.example.minimoneybox.utils.show
import kotlinx.android.synthetic.main.activity_login.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
import java.util.regex.Pattern
import android.util.Patterns.EMAIL_ADDRESS
/**
* A login screen that offers login via email/password.
*/
class LoginActivity : AppCompatActivity(), KodeinAware {
//retrieve the viewmodel factory from the kodein dependency injection
override val kodein by kodein()
private val factory : LoginViewModelFactory by instance()
lateinit var viewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
//views replaced with kotlin synthetic views
//no need to use findViewById()
setupViews()
setupViewModel()
setupLiveData()
}
override fun onStart() {
super.onStart()
setupAnimation()
}
private fun setupViews() {
btn_sign_in.setOnClickListener {
if (allFieldsValid()) {
progress_circular.show()
startLogin()
}
}
}
private fun startLogin(){
viewModel.attemptLogin(
et_email.text.toString(),
et_password.text.toString(),
et_name.text.toString()
)
}
private fun allFieldsValid(): Boolean {
var validity = true
//Using Android Email address matcher
if (!EMAIL_ADDRESS.matcher(et_email.text.toString()).matches()) {
til_email.error = getString(R.string.email_address_error)
validity = false
}
if (!Pattern.matches(PASSWORD_REGEX, et_password.text.toString())) {
til_password.error = getString(R.string.password_error)
validity = false
}
if (et_name.text.isNotEmpty() &&
!Pattern.matches(NAME_REGEX, et_name.text.toString())
) {
til_name.error = getString(R.string.full_name_error)
validity = false
}
return validity
}
private fun setupAnimation() {
//animation completion listener attached
//once the animation has run from frame first to frame last
//then set new frame min and max then play animation again
animation_view.apply {
setAnimation("pig.json")
setMinAndMaxFrame(firstAnim.first, firstAnim.second)
addAnimatorListener(object : Animator.AnimatorListener {
override fun onAnimationEnd(animation: Animator?) {
setMinAndMaxFrame(secondAnim.first, secondAnim.second)
playAnimation()
}
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
playAnimation()
}
}
private fun setupViewModel(){
viewModel = ViewModelProviders.of(this, factory).get(LoginViewModel::class.java)
}
private fun setupLiveData(){
viewModel.operationFailed.observe(this, Observer {
progress_circular.hide()
if (it != null) {
displayToast(it)
}
})
viewModel.operationSuccess.observe(this, Observer {
progress_circular.hide()
if (it == true){ startNewActivity() }
})
}
private fun startNewActivity(){
Intent(this, AccountActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("NAME_USER", viewModel.name);
startActivity(this)
}
}
companion object {
val EMAIL_REGEX = "[^@]+@[^.]+\\..+"
val NAME_REGEX = "[a-zA-Z]{6,30}"
val PASSWORD_REGEX = "^(?=.*[0-9])(?=.*[A-Z]).{10,50}$"
val firstAnim = 0 to 109
val secondAnim = 131 to 158
}
}

View File

@@ -0,0 +1,57 @@
package com.example.minimoneybox.ui.login
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.minimoneybox.data.repository.MoneyBoxRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
class LoginViewModel(
val repository: MoneyBoxRepository
) : ViewModel() {
//stored @Nullable value for users name
var name: String? = null
//operation results livedata based on outcome of operation
val operationSuccess = MutableLiveData<Boolean>()
val operationFailed = MutableLiveData<String>()
fun attemptLogin(
email: String,
password: String,
usersName: String?
){
//clear name before operation
name = null
//open a coroutine on the IO thread and run async operations
//Network calls and secured shared preferences
CoroutineScope(Dispatchers.Main).launch {
try {
//retrieve response from API call from login api network request
val response = repository.loginUser(email, password)
//null safety check to ensure bearer token exists
response?.session?.bearerToken?.let {
//save data in secured prefs
repository.saveAuthToken(it)
repository.saveCredentials(email,password)
//update name in this viewmodel for later use
name = usersName
operationSuccess.postValue(true)
return@launch
}
}catch (exception: IOException){
operationFailed.postValue(
exception.message ?: "could not receive token")
return@launch
}
operationFailed.postValue("Could not login user")
}
}
}

View File

@@ -0,0 +1,22 @@
package com.example.minimoneybox.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.minimoneybox.data.repository.MoneyBoxRepositoryImpl
/**
* Viewmodel factory for [UserAccountsViewModel]
* @repository injected into MainViewModel
*/
@Suppress("UNCHECKED_CAST")
class LoginViewModelFactory(
private val repositoryImpl: MoneyBoxRepositoryImpl
): ViewModelProvider.NewInstanceFactory(){
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return LoginViewModel(
repositoryImpl
) as T
}
}

View File

@@ -0,0 +1,80 @@
package com.example.minimoneybox.ui.user
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.example.minimoneybox.R
import com.example.minimoneybox.utils.displayToast
import com.example.minimoneybox.utils.hide
import com.example.minimoneybox.utils.setAnimation
import com.example.minimoneybox.utils.show
import kotlinx.android.synthetic.main.account_activity.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
/**
* Activity holding the fragments for products.
*/
class AccountActivity : AppCompatActivity(), KodeinAware {
//retrieve the viewmodel factory from the kodein dependency injection
override val kodein by kodein()
private val factory : UserAccountsViewModelFactory by instance()
val accountFragmentManager: FragmentManager by lazy {
supportFragmentManager
}
//to be used by the fragments
lateinit var viewModel: UserAccountsViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.account_activity)
viewModel = ViewModelProviders
.of(this , factory).get(UserAccountsViewModel::class.java)
intent.extras?.getString("NAME_USER").let {
viewModel.usersName = it
}
if (savedInstanceState == null) {
accountFragmentManager.beginTransaction()
.replace(R.id.container, UserAccountsFragment())
.setAnimation()
.commit()
}
fetchData()
setupOperationObservers()
}
//override back button
//conditional back pressing
override fun onBackPressed() {
if(accountFragmentManager.backStackEntryCount > 0){
accountFragmentManager.popBackStack()
}else{
super.onBackPressed()
}
}
private fun fetchData(){
progress_circular_account.show()
viewModel.getInvestorProducts()
}
private fun setupOperationObservers(){
viewModel.operationSuccess.observe(this, Observer {
progress_circular_account.hide()
})
viewModel.operationFailed.observe(this, Observer {
displayToast(it)
progress_circular_account.hide()
})
}
}

View File

@@ -0,0 +1,102 @@
package com.example.minimoneybox.ui.user
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.example.minimoneybox.R
import com.example.minimoneybox.data.models.ProductsResponse
import com.example.minimoneybox.utils.show
import com.example.minimoneybox.utils.toCurrency
import kotlinx.android.synthetic.main.account_activity.*
import kotlinx.android.synthetic.main.investment_fragment.*
import kotlinx.android.synthetic.main.investment_fragment.view.*
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
/**
* A Fragment showing product selected in [UserAccountsFragment].
*/
class CurrentInvestmentFragment : Fragment() {
private var currentItemPosition: Int? = null
private val accountActivity: AccountActivity by lazy {
activity as AccountActivity
}
//grab viewmodel from the activity hosting fragment
private val viewModel by lazy {
accountActivity.viewModel
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
currentItemPosition = it.getInt(ARG_PARAM1)
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(
R.layout.investment_fragment, container,
false
).apply {
//setup Observers after view instantiated
setUpObserver()
}
}
private fun setUpObserver(){
viewModel.plans.observe(this, Observer { list ->
//get list of products
currentItemPosition?.let {
populateViews(list[it])
}
})
}
//setting up views via kotlin synthetics
//assign text and button on click
private fun populateViews(currentProducts: ProductsResponse) {
account_name_tv.text = currentProducts.product?.name
friendly_account_tv.text = currentProducts.product?.friendlyName
plan_val_tv.text = currentProducts.planValue?.toCurrency()
moneybox_val_tv.text = currentProducts.moneybox?.toCurrency()
btn_add_20.setOnClickListener {
currentProducts.id?.let { id -> addOneOffPayment(id) }
}
}
private fun addOneOffPayment(id: Int) {
currentItemPosition?.let {
accountActivity.progress_circular_account.show()
viewModel.oneOffPayment(id)
}
}
companion object {
/**
* fragment Instance using the provided parameters.
*
* @param param1 Parameter 1.
* @return A new instance of fragment CurrentInvestmentFragment.
*/
@JvmStatic
fun newInstance(param1: Int) =
CurrentInvestmentFragment().apply {
arguments = Bundle().apply {
putInt(ARG_PARAM1, param1)
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.example.minimoneybox.ui.user
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.minimoneybox.R
import com.example.minimoneybox.ui.user.recyclerview.ProductsAdapter
import com.example.minimoneybox.ui.user.recyclerview.RecyclerClickListener
import com.example.minimoneybox.utils.setAnimation
import com.example.minimoneybox.utils.toCurrency
import kotlinx.android.synthetic.main.user_accounts_fragment.*
/**
* A Fragment showing the first overview of products list.
*/
class UserAccountsFragment : Fragment(),
RecyclerClickListener {
private val viewModel by lazy {
(activity as AccountActivity).viewModel
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(
R.layout.user_accounts_fragment, container, false
)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setupDataObservers()
setupName()
setupRecyclerView()
}
private fun setupRecyclerView() {
products_recycler_view.apply {
setHasFixedSize(false)
layoutManager = LinearLayoutManager(context)
}
}
private fun setupName() {
val name = viewModel.usersName
user_name_tv.text = if (name.isNullOrEmpty()) {
"Hello !"
} else {
"Hello $name !"
}
}
private fun setupDataObservers() {
viewModel.totalPlanValue.observe(this, Observer { planValue ->
total_plan_val_tv.text = getTotalString(planValue)
})
viewModel.plans.observe(this, Observer {
ProductsAdapter(
it, this).apply {
products_recycler_view.adapter = this
notifyDataSetChanged()
}
})
}
private fun getTotalString(planValue: Double?): CharSequence? {
return StringBuilder()
.append("Total Plan: ")
.append(planValue?.toCurrency())
.toString()
}
override fun onItemSelected(position: Int) {
(activity as AccountActivity).accountFragmentManager
.beginTransaction()
.replace(
R.id.container,
CurrentInvestmentFragment.newInstance(position)
)
.setAnimation()
.addToBackStack("CurrentInvestment")
.commit()
}
}

View File

@@ -0,0 +1,129 @@
package com.example.minimoneybox.ui.user
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.minimoneybox.data.models.ProductsResponse
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import com.example.minimoneybox.data.repository.MoneyBoxRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
class UserAccountsViewModel(
val repository: MoneyBoxRepository
) : ViewModel() {
var usersName: String? = null
val totalPlanValue = MutableLiveData<Double>()
val plans = MutableLiveData<List<ProductsResponse>>()
//operation results livedata based on outcome of operation
val operationSuccess = MutableLiveData<Boolean>()
val operationFailed = MutableLiveData<String>()
fun getInvestorProducts(){
//retrieve auth token from safe storage/keystore
//or finish operation
val token = repository.loadAuthToken()
if (token.isNullOrEmpty()){
operationFailed.postValue("Failed to retrieve token")
return
}
//open a coroutine on the IO thread and run async operations
//Network calls and secured shared preferences
CoroutineScope(Dispatchers.IO).launch {
try {
repository.getProducts(token)?.let {
getProducts(it)
return@launch
}
}catch (exception: IOException){
val credential = repository.loadCredentials()
val loginResponse =
repository.loginUser(credential.first, credential.second)
val code = loginResponse?.session?.bearerToken?.let {
repository.saveAuthToken(it)
it
}
//retrieve response from API call from login api network request
val response = code?.let { repository.getProducts(it) }
response?.let {
getProducts(it)
return@launch
}
}catch (exception: IOException){
operationFailed.postValue(exception.message)
return@launch
}
operationFailed.postValue("Could not retrieve products")
}
}
fun oneOffPayment(productId: Int){
//retrieve auth token from safe storage/keystore
//or finish operation
val token = repository.loadAuthToken()
if (token.isNullOrEmpty()){
operationFailed.postValue("Failed to retrieve token")
return
}
//open a coroutine on the IO thread and run async operations
//Network calls and secured shared preferences
CoroutineScope(Dispatchers.IO).launch {
try {
//retrieve response from API call from for one off repayment
repository.oneOffPayment(productId, token)?.let {
oneOffPaymentResponse(it)
return@launch
}
}catch (exception: IOException){
val credential = repository.loadCredentials()
val loginResponse =
repository.loginUser(credential.first, credential.second)
val code = loginResponse?.session?.bearerToken?.let {
repository.saveAuthToken(it)
it
}
//retrieve response from API call from login api network request
val response = code?.let {
repository.oneOffPayment(productId, code) }
response?.let {
oneOffPaymentResponse(it)
return@launch
}
}catch (exception: IOException){
operationFailed.postValue(exception.message)
return@launch
}
operationFailed.postValue("Could not retrieve products")
}
}
private fun getProducts(response: ProductApiResponse){
response.let {
//update livedata with relevent data
totalPlanValue.postValue(it.totalPlanValue)
plans.postValue(it.productsRespons)
operationSuccess.postValue(true)
}
}
private fun oneOffPaymentResponse(response: PaymentResponse){
//null safety check to ensure bearer token exists
response.moneybox?.let {
getInvestorProducts()
operationSuccess.postValue(true)
}
}
}

View File

@@ -0,0 +1,21 @@
package com.example.minimoneybox.ui.user
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.minimoneybox.data.repository.MoneyBoxRepositoryImpl
/**
* Viewmodel factory for [UserAccountsViewModel]
* @repository injected into MainViewModel
*/
@Suppress("UNCHECKED_CAST")
class UserAccountsViewModelFactory(
private val repositoryImpl: MoneyBoxRepositoryImpl
): ViewModelProvider.NewInstanceFactory(){
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return UserAccountsViewModel(
repositoryImpl
) as T
}
}

View File

@@ -0,0 +1,49 @@
package com.example.minimoneybox.ui.user.recyclerview
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.minimoneybox.R
import com.example.minimoneybox.data.models.ProductsResponse
import com.example.minimoneybox.utils.toCurrency
import kotlinx.android.synthetic.main.products_list_item.view.*
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
/**
* [ProductsResponse] recycler view adapter
*/
class ProductsAdapter(
private val products: List<ProductsResponse>,
private val recyclerClickListener: RecyclerClickListener
): Adapter<ProductsAdapter.ProductsViewHolder>() {
class ProductsViewHolder(view: View) :
ViewHolder(view) {
var name = view.name_tv
var planValue = view.plan_val_tv
var moneyBoxVal = view.moneybox_val_tv
}
override fun onCreateViewHolder(p0: ViewGroup, p1: Int): ProductsViewHolder {
val view = LayoutInflater.from(p0.context)
.inflate(R.layout.products_list_item, p0, false)
return ProductsViewHolder(view)
}
override fun getItemCount(): Int = products.size
override fun onBindViewHolder(p0: ProductsViewHolder, p1: Int) {
p0.name.text = products[p1].product?.friendlyName
p0.planValue.text = products[p1].planValue?.toCurrency()
p0.moneyBoxVal.text = products[p1].moneybox?.toCurrency()
p0.itemView.setOnClickListener {
recyclerClickListener.onItemSelected(p1)
}
}
}

View File

@@ -0,0 +1,5 @@
package com.example.minimoneybox.ui.user.recyclerview
interface RecyclerClickListener {
fun onItemSelected(position: Int)
}

View File

@@ -0,0 +1,12 @@
package com.example.minimoneybox.utils
import java.text.NumberFormat
import java.util.*
fun Double.toCurrency(): String{
NumberFormat.getCurrencyInstance().apply {
maximumFractionDigits = 2
currency = Currency.getInstance("GBP")
return format(this@toCurrency)
}
}

View File

@@ -0,0 +1,27 @@
package com.example.minimoneybox.utils
import android.content.Context
import android.view.View
import android.widget.Toast
import androidx.fragment.app.FragmentTransaction
fun View.show(){
this.visibility = View.VISIBLE
}
fun View.hide(){
this.visibility = View.GONE
}
fun Context.displayToast(message: String){
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun FragmentTransaction.setAnimation() = apply {
setCustomAnimations(
android.R.anim.fade_in,
android.R.anim.fade_out,
android.R.anim.fade_in,
android.R.anim.fade_out
)
}

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1"/>
</vector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<solid android:color="#000000" />
<corners android:radius="4dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<corners android:radius="24dp" />
<solid android:color="@color/colorAccent" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<layer-list >
<item
android:bottom="1dp"
android:left="-2dp"
android:right="-2dp"
android:top="-2dp">
<shape android:shape="rectangle" >
<stroke
android:width="1dp"
android:color="?attr/colorControlActivated" />
<solid android:color="#00FFFFFF" />
<padding android:left="-2dp"
android:right="-2dp"
android:top="8dp"
android:bottom="8dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_focused="true">
<layer-list >
<item
android:bottom="1dp"
android:left="-2dp"
android:right="-2dp"
android:top="-2dp">
<shape android:shape="rectangle" >
<stroke
android:width="1dp"
android:color="@color/colorAccent" />
<solid android:color="#00FFFFFF" />
<padding android:left="-2dp"
android:right="-2dp"
android:top="8dp"
android:bottom="8dp" />
</shape>
</item>
</layer-list>
</item>
<item>
<layer-list >
<item
android:bottom="1dp"
android:left="-2dp"
android:right="-2dp"
android:top="-2dp">
<shape android:shape="rectangle" >
<stroke
android:width="1dp"
android:color="@color/light_grey" />
<solid android:color="#00FFFFFF" />
<padding android:left="-2dp"
android:right="-2dp"
android:top="8dp"
android:bottom="8dp" />
</shape>
</item>
</layer-list>
</item>
</selector>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -0,0 +1,317 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="164dp"
android:height="36dp"
android:viewportWidth="164"
android:viewportHeight="36">
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M163.440023,30.5804348 L163.317925,30.380087 L157.585227,21.9392609
C158.003175,21.3190435 158.363205,20.7802174 158.6661,20.3231739
C158.979953,19.8485217 159.265237,19.4235652 159.520781,19.0498696
C159.776324,18.675 160.011126,18.3341739 160.224796,18.0246522
C160.438858,17.7151304 160.660355,17.3942609 160.885765,17.0612609
C161.111175,16.7286522 161.357717,16.3690435 161.625392,15.9820435
C161.893067,15.5950435 162.206136,15.1403478 162.559123,14.6218696
C162.636216,14.5177826 162.704309,14.4046957 162.761444,14.2853478
C162.825623,14.1487826 162.8538,14.0556522 162.8538,13.9848261
C162.8538,13.793087 162.744616,13.6498696 162.545035,13.5837391
C162.38576,13.530913 162.179134,13.505087 161.928678,13.505087
L160.109743,13.505087 C159.803326,13.505087 159.568915,13.566913
159.393987,13.6925217 C159.233148,13.8075652 159.05235,13.9977391
158.83281,14.2818261 C158.558091,14.6993478 158.287677,15.1196087
158.020003,15.5422174 C157.751154,15.9636522 157.487393,16.3831304
157.225979,16.799087 L155.614061,19.3245652 L152.410575,14.3311304
C152.184382,14.0161304 151.995366,13.8075652 151.835309,13.6925217
C151.659599,13.566913 151.413839,13.505087 151.083551,13.505087
L149.263833,13.505087 C149.025117,13.505087 148.826318,13.5316957
148.674088,13.5845217 C148.480376,13.6514348 148.374323,13.7938696
148.374323,13.9848261 C148.374323,14.0666087 148.404065,14.166
148.468636,14.2974783 C148.525771,14.4105652 148.598168,14.5314783
148.683871,14.652 C149.029813,15.1642174 149.332708,15.6071739
149.594121,15.9824348 C149.856709,16.3561304 150.10012,16.707913
150.324748,17.0342609 C150.549767,17.3606087 150.767742,17.681087
150.97515,17.994913 C151.18295,18.3118696 151.418927,18.6566087
151.681514,19.0326522 C151.943319,19.4063478 152.232908,19.8332609
152.553805,20.3149565 C152.86257,20.7774783 153.230035,21.3186522
153.659723,21.9400435 C152.725993,23.3166522 151.796176,24.6772174
150.869098,26.0248696 C149.924019,27.3975652 148.975026,28.7812174
148.024077,30.1711304 C147.938765,30.2928261 147.866368,30.4129565
147.80845,30.5268261 C147.74427,30.654 147.712963,30.7573043
147.712963,30.8406522 C147.712963,30.9455217 147.754837,31.089913
147.951288,31.1896957 C148.078864,31.2526957 148.240095,31.284 148.442416,31.284
L150.529809,31.284 C150.792787,31.284 151.022502,31.2229565
151.212301,31.1012609 C151.394664,30.983087 151.575462,30.7968261
151.75352,30.5416957 L155.612887,24.5359565 L159.518433,30.6086087
C159.692969,30.8191304 159.868288,30.9850435 160.040476,31.1004783
C160.223231,31.2221739 160.444336,31.284 160.697923,31.284 L162.820536,31.284
C163.007204,31.284 163.16413,31.2566087 163.28701,31.2010435
C163.499897,31.107913 163.531987,30.9463043 163.531987,30.8578696
C163.532378,30.7733478 163.502637,30.6821739 163.440023,30.5804348
L163.440023,30.5804348 Z" />
<path
android:fillColor="#EB6A6C"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M142.416997,25.0301739 C141.621409,24.2354348 140.673199,23.598
139.600152,23.1354783 C138.524757,22.6721739 137.364051,22.4366087
136.152471,22.4366087 C134.929151,22.4366087 133.769228,22.6725652
132.704399,23.1354783 C131.641528,23.5987826 130.699579,24.2358261
129.905164,25.0301739 C129.109967,25.826087 128.475218,26.7730435
128.018919,27.8471739 C127.562229,28.9185652 127.330166,30.0752609
127.328992,31.2847826 L130.345419,31.2847826 L142.009615,31.2847826
L145.009606,31.2847826 C145.008041,30.0752609 144.774021,28.9185652
144.311852,27.8463913 C143.848509,26.7706957 143.210238,25.8245217
142.416997,25.0301739 L142.416997,25.0301739 Z" />
<path
android:fillColor="#5FBEBA"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M 136.169299 0.26608695 C 141.051665867 0.26608695 145.00960623 4.22369510386 145.00960623 9.10565217 C 145.00960623 13.9876092361 141.051665867 17.94521739 136.169299 17.94521739 C 131.286932133 17.94521739 127.32899177 13.9876092361 127.32899177 9.10565217 C 127.32899177 4.22369510386 131.286932133 0.26608695 136.169299 0.26608695 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M123.340092,15.9065217 C122.593029,15.0757826 121.701563,14.4101739
120.69113,13.9288696 C119.677567,13.4463913 118.577518,13.2018261
117.420334,13.2018261 C116.188404,13.2018261 115.095791,13.4694783
114.174975,13.9965652 C113.386039,14.4469565 112.715678,15.0245217
112.174459,15.7147826 L112.174459,10.1195217 C112.174459,9.80843478
112.075451,9.558 111.879782,9.37526087 C111.685287,9.19682609
111.401959,9.10565217 111.034102,9.10565217 L109.999798,9.10565217
C109.658943,9.10565217 109.374833,9.19526087 109.156075,9.36782609
C108.923621,9.54978261 108.807002,9.80334783 108.807002,10.119913
L108.807002,30.234913 C108.807002,30.560087 108.907967,30.8191304
109.109115,31.0069565 C109.307914,31.1920435 109.590068,31.2851739
109.945794,31.2851739 L111.016101,31.2851739 C111.357738,31.2851739
111.637153,31.1963478 111.846518,31.0206522 C112.06371,30.8398696
112.174067,30.5749565 112.174067,30.234913 L112.174067,29.1858261
C112.728201,29.8588696 113.418911,30.423913 114.231718,30.8664783
C115.175232,31.3810435 116.248279,31.6420435 117.419943,31.6420435
C118.578692,31.6420435 119.678741,31.4002174 120.690739,30.924
C121.701171,30.4485652 122.592246,29.786087 123.3397,28.9549565
C124.085589,28.1265652 124.679248,27.1416522 125.106588,26.0315217
C125.533537,24.9174783 125.749163,23.7091304 125.749163,22.4397391
C125.749163,21.1699565 125.533537,19.9588696 125.106588,18.8401304
C124.679639,17.7229565 124.085198,16.736087 123.340092,15.9065217
L123.340092,15.9065217 Z M121.94654,24.7691739 C121.656168,25.4938696
121.264831,26.1332609 120.785051,26.672087 C120.306446,27.2085652
119.744486,27.6421304 119.11365,27.9602609 C117.859806,28.5910435
116.407553,28.5906522 115.146273,27.9594783 C114.510351,27.6417391
113.941738,27.2077826 113.456871,26.6713043 C112.97083,26.1332609
112.581058,25.4934783 112.29773,24.7699565 C112.013619,24.0468261
111.87039,23.268913 111.87039,22.4577391 C111.87039,21.6465652
112.013619,20.8694348 112.29773,20.145913 C112.580275,19.4223913
112.97083,18.7802609 113.457653,18.2347826 C113.942129,17.6920435
114.510351,17.2592609 115.145099,16.9477826 C115.775544,16.6374783
116.445904,16.4801739 117.135049,16.4801739 C117.823411,16.4801739
118.488684,16.6374783 119.115215,16.9477826 C119.743312,17.2584783
120.30488,17.6920435 120.785051,18.2347826 C121.265613,18.7802609
121.656168,19.4223913 121.94654,20.147087 C122.235347,20.871
122.382098,21.648913 122.382098,22.4577391 C122.38249,23.2677391
122.235738,24.0444783 121.94654,24.7691739 L121.94654,24.7691739 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M104.579387,13.5054783 L103.116177,13.5054783 C102.855546,13.5054783
102.633658,13.5571304 102.456774,13.6576957 C102.276758,13.7633478
102.131572,13.9366957 102.022781,14.1836087 L97.0481022,26.8098261
C96.682202,25.9231304 96.3014309,24.9867391 95.8877875,23.9583913
C95.4537945,22.8756522 95.0201929,21.7886087 94.5869826,20.6941304
C94.1529897,19.6000435 93.7209534,18.509087 93.2928305,17.4212609
C92.8560982,16.3115217 92.4424548,15.2401304 92.0644231,14.2423043
C91.9826336,14.0126087 91.8480136,13.8306522 91.6644765,13.7015217
C91.479374,13.5708261 91.2574858,13.5058696 91.0038993,13.5058696
L89.486685,13.5058696 C88.6977492,13.5058696 88.5799567,13.9519565
88.5799567,14.2180435 C88.5799567,14.2751739 88.5889574,14.3694783
88.606959,14.4970435 C88.6284825,14.6336087 88.676617,14.7928696
88.7505797,14.9662174 L95.2941289,30.789 L93.7988295,34.5545217
C93.745999,34.7110435 93.7197794,34.8507391 93.7197794,34.9728261
C93.7197794,35.3136522 93.8872717,35.7206087 94.6812949,35.7206087
L96.1081103,35.7206087 C96.6191967,35.7206087 96.9804009,35.4823043
97.1827222,35.0107826 C98.3129041,32.3342609 98.9323908,30.789 99.696281,28.89
C99.9745218,28.197 100.291505,27.4069565 100.696148,26.4075652
C102.176576,22.746913 103.712575,19.0177826 105.260313,15.3258261
C105.411369,14.9857826 105.493942,14.7697826 105.518596,14.6476957
C105.545598,14.5248261 105.558121,14.4297391 105.558121,14.3596957
C105.558121,14.1088696 105.479071,13.9018696 105.320971,13.7437826
C105.163262,13.5856957 104.91398,13.5054783 104.579387,13.5054783
L104.579387,13.5054783 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M84.4192596,15.6357391 C83.6796323,14.8664348 82.8073417,14.2497391
81.8250854,13.8040435 C80.8412637,13.3567826 79.7862186,13.1313913
78.6900831,13.1313913 C77.5078534,13.1313913 76.3831503,13.379087
75.3476721,13.8666522 C74.3125852,14.3538261 73.3984216,15.024913
72.62827,15.8615217 C71.8592924,16.6969565 71.2433276,17.6896957
70.7991599,18.8111739 C70.3549922,19.9353913 70.1287993,21.1503913
70.1287993,22.4225217 C70.1287993,23.7056087 70.3549922,24.9233478
70.7991599,26.0413043 C71.2441103,27.1584783 71.8592924,28.1484783
72.62827,28.9835217 C73.3976389,29.8189565 74.3121939,30.4865217
75.3484548,30.9686087 C76.386281,31.4503043 77.5105928,31.6952609
78.6900831,31.6952609 C79.1694712,31.6952609 79.6825142,31.6467391
80.2135588,31.5493043 C80.744212,31.4534348 81.2701692,31.3196087
81.7749942,31.152913 C82.2876459,30.9842609 82.7799481,30.7772609
83.2413347,30.5373913 C83.7038952,30.2967391 84.115582,30.0349565
84.458002,29.763 C84.6117975,29.6604783 84.759723,29.5262609
84.8986477,29.3611304 C85.0504865,29.1807391 85.1271886,28.9823478
85.1271886,28.7718261 C85.1271886,28.6485652 85.067314,28.4904783
84.9475648,28.2893478 L84.2134163,27.0563478 C84.1394535,26.9366087
84.0310531,26.8462174 83.8901717,26.7875217 C83.615453,26.6716957
83.306688,26.6658261 82.9122201,26.8516957 C82.7122468,26.9448261
82.5400584,27.0496957 82.3870456,27.176087 C82.1725928,27.4022609
81.9158756,27.5936087 81.6219814,27.7454348 C81.3190864,27.9035217
80.9993639,28.035 80.6714233,28.1375217 C80.3430914,28.2423913
80.0022367,28.3183043 79.6598167,28.3652609 C79.3197446,28.4114348
78.9933694,28.4353043 78.6896918,28.4353043 C78.0455507,28.4353043
77.4299773,28.3183043 76.8601904,28.0878261 C76.2880554,27.8569565
75.7675769,27.5325652 75.3136258,27.1248261 C74.8573266,26.7143478
74.4722508,26.2146522 74.1666165,25.6374783 C73.884071,25.1072609
73.6856631,24.5175652 73.5737406,23.8824783 L84.3613417,23.8824783
C84.6857603,23.8824783 84.9945253,23.8633043 85.2802015,23.826913
C85.5772264,23.7893478 85.841379,23.7052174 86.0636585,23.5776522
C86.2921994,23.4477391 86.4780846,23.2622609 86.615444,23.0278696
C86.7520206,22.7926957 86.820896,22.4816087 86.820896,22.1004783
L86.820896,21.6907826 C86.820896,20.5125652 86.6044865,19.3903043
86.1771463,18.3545217 C85.7513714,17.321087 85.1596696,16.4073913
84.4192596,15.6357391 L84.4192596,15.6357391 Z M73.7130567,20.9793913
C73.8648955,20.3916522 74.0856097,19.8289565 74.3708945,19.305
C74.6847469,18.7313478 75.060822,18.2238261 75.4889449,17.7945652
C75.9147197,17.3688261 76.3984125,17.0272174 76.9290657,16.7795217
C77.9801974,16.2884348 79.217997,16.2876522 80.2812601,16.7795217
C80.8181748,17.028 81.3112596,17.3696087 81.749166,17.7973043
C82.1905943,18.2265652 82.568626,18.7333043 82.8738691,19.3046087
C83.1540665,19.8301304 83.3575618,20.3928261 83.4788764,20.9797826
L73.7130567,20.9797826 L73.7130567,20.9793913 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M64.6986044,15.3790435 C64.0642468,14.6966087 63.2991826,14.151913
62.4233699,13.7582609 C61.547166,13.3653913 60.5762584,13.167 59.5388235,13.167
C58.9901687,13.167 58.4559935,13.2499565 57.9523425,13.4146957
C57.4510395,13.5778696 56.9704775,13.7950435 56.5239617,14.0599565
C56.0770546,14.3252609 55.6645852,14.6320435 55.2967283,14.9693478
C55.0239663,15.2186087 54.7750758,15.471 54.5504482,15.7222174
L54.4518313,14.4520435 C54.4240463,14.1824348 54.3266034,13.9617391
54.1610677,13.7966087 C53.9927927,13.6271739 53.7677739,13.541087
53.4914898,13.541087 L52.1535079,13.541087 C51.8920947,13.541087
51.6682498,13.626 51.487452,13.7926957 C51.3039149,13.962913
51.2103853,14.1906522 51.2103853,14.4673043 L51.2103853,30.3597391
C51.2103853,30.9654783 51.5485006,31.2855652 52.1887283,31.2855652
L53.6523294,31.2855652 C53.9247001,31.2855652 54.148545,31.2065217
54.3176026,31.0519565 C54.489791,30.8950435 54.5774505,30.6622174
54.5774505,30.3593478 L54.5774505,20.5927826 C54.6114969,20.0332174
54.764901,19.4975217 55.032967,19.0017391 C55.3029897,18.5020435
55.6501058,18.0618261 56.0649232,17.6928261 C56.4785666,17.3257826
56.95365,17.0303478 57.4772591,16.8166957 C58.5440443,16.3846957
59.7247086,16.3972174 60.7218358,16.833913 C61.2337049,17.0581304
61.6896127,17.366087 62.0805585,17.7507391 C62.4703304,18.135
62.7818348,18.5908696 63.004897,19.1066087 C63.2275679,19.6207826
63.3406643,20.1733043 63.3406643,20.745 L63.3406643,30.2713043
C63.3406643,30.6027391 63.4024956,30.8453478 63.5292888,31.0155652
C63.6635175,31.1947826 63.9112339,31.2855652 64.2669595,31.2855652
L65.7289952,31.2855652 C66.0021486,31.2855652 66.2385162,31.1947826
66.4306628,31.0159565 C66.6267227,30.8343913 66.7261224,30.5718261
66.7261224,30.234913 L66.7261224,20.745 C66.7261224,19.6986522
66.5461073,18.7023913 66.1892078,17.7871304 C65.8342649,16.8718696
65.3325706,16.0614783 64.6986044,15.3790435 L64.6986044,15.3790435 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M45.1470068,15.8212174 C44.3103279,14.9861739 43.3147661,14.3154783
42.1865409,13.8294783 C41.0551851,13.343087 39.8361697,13.0957826
38.5627585,13.0957826 C37.2772158,13.0957826 36.0578091,13.343087
34.9389761,13.8294783 C33.8220997,14.3162609 32.832408,14.9861739
31.996903,15.8212174 C31.1610068,16.6566522 30.4941682,17.652913
30.0139975,18.7826087 C29.5330441,19.9123043 29.2884583,21.1304348
29.2884583,22.4045217 C29.2884583,23.6911304 29.5326527,24.9127826
30.0139975,26.0358261 C30.4937769,27.1588696 31.1613981,28.1508261
31.996903,28.9878261 C32.8316253,29.8224783 33.8217084,30.4892609
34.9401501,30.9693913 C36.0570264,31.4503043 37.2764331,31.6948696
38.5627585,31.6948696 C39.8365611,31.6948696 41.0559678,31.4506957
42.1857582,30.9693913 C43.3147661,30.4896522 44.3111106,29.8236522
45.1470068,28.9878261 C45.9813377,28.1535652 46.6516983,27.1608261
47.1385218,26.0366087 C47.6253453,24.9127826 47.872279,23.6911304
47.872279,22.4045217 C47.872279,21.1304348 47.6253453,19.9115217
47.1385218,18.7818261 C46.6509156,17.6509565 45.980555,16.6546957
45.1470068,15.8212174 L45.1470068,15.8212174 Z M44.2312778,24.7734783
C43.9076419,25.5146087 43.4591695,26.172 42.8972093,26.7284348
C42.3352491,27.284087 41.6746719,27.7309565 40.9326965,28.0549565
C40.1926779,28.3789565 39.3959154,28.5433043 38.5627585,28.5433043
C37.7303843,28.5433043 36.9359698,28.3789565 36.2022125,28.0549565
C35.4661073,27.7313478 34.8114002,27.284087 34.2560927,26.7284348
C33.6996112,26.172 33.2566175,25.515 32.9388517,24.7742609
C32.6210859,24.0362609 32.4598549,23.2391739 32.4598549,22.4045217
C32.4598549,21.5702609 32.6210859,20.7723913 32.9388517,20.0336087
C33.2566175,19.2940435 33.7003939,18.6331304 34.2560927,18.0700435
C34.8114002,17.5096957 35.4661073,17.0612609 36.2022125,16.7356957
C36.9359698,16.4128696 37.7303843,16.2481304 38.5627585,16.2481304
C39.3951327,16.2481304 40.1922866,16.4128696 40.9326965,16.7356957
C41.6746719,17.0612609 42.3356404,17.510087 42.8972093,18.0700435
C43.4583868,18.6327391 43.9080332,19.2940435 44.2312778,20.0343913
C44.5556963,20.7739565 44.7200579,21.5718261 44.7200579,22.4045217
C44.7196666,23.2376087 44.5556963,24.0346957 44.2312778,24.7734783
L44.2312778,24.7734783 Z" />
<path
android:fillColor="#546270"
android:fillType="evenOdd"
android:strokeWidth="1"
android:pathData="M24.0699768,15.4275652 C23.4716223,14.738087 22.7421697,14.184
21.9023601,13.7782174 C21.0602025,13.3728261 20.1127751,13.1666087
19.0859063,13.1666087 C18.4073276,13.1666087 17.7800141,13.2656087
17.2192279,13.4604783 C16.6603984,13.6545652 16.1442246,13.9253478
15.6836208,14.2646087 C15.2234082,14.6034783 14.8089821,15.0088696
14.4520826,15.4698261 C14.1495789,15.858 13.8729036,16.2786522
13.6267525,16.7212174 C13.3942982,16.3095652 13.1094047,15.9116087
12.7779421,15.5336087 C12.3854309,15.0867391 11.9291317,14.688
11.421176,14.3503043 C10.9132203,14.0118261 10.3426507,13.7383043
9.72746865,13.539913 C9.10993855,13.3403478 8.43488189,13.2382174
7.72147419,13.2382174 C7.15090456,13.2382174 6.6316001,13.3196087
6.17686628,13.4776957 C5.72721984,13.6365652 5.31396777,13.845913
4.94806749,14.103 C4.58529791,14.3561739 4.25266129,14.6516087
3.95994107,14.9803043 C3.7603591,15.2056957 3.56899522,15.4365652
3.39054545,15.6693913 L3.29505918,14.4532174 C3.26805691,14.1855652
3.17765802,13.9656522 3.02660186,13.802087 C2.86850164,13.6295217
2.63604734,13.5422609 2.33432637,13.5422609 L0.996344498,13.5422609
C0.722017124,13.5422609 0.49386754,13.6271739 0.319331151,13.7962174
C0.142838076,13.9656522 0.0532218585,14.1918261 0.0532218585,14.468087
L0.0532218585,30.3605217 C0.0532218585,30.9654783 0.385858474,31.2855652
1.01473735,31.2855652 L2.49555729,31.2855652 C2.76831932,31.2855652
2.99294686,31.2073043 3.16122186,31.0519565 C3.33380156,30.8958261
3.42067842,30.6622174 3.42067842,30.3601304 L3.42067842,20.6021739
C3.42067842,20.0426087 3.52594812,19.5045652 3.73218283,19.0060435
C3.93724352,18.5086957 4.22526769,18.0673043 4.58608058,17.6943913
C4.94532813,17.3234348 5.37462503,17.0276087 5.86066583,16.8155217
C6.34670662,16.605 6.8714898,16.4977826 7.4185792,16.4977826
C7.97858272,16.4977826 8.50258323,16.6073478 8.97492722,16.8245217
C9.44844523,17.040913 9.86639335,17.339087 10.2154661,17.7108261
C10.5641476,18.0813913 10.8392576,18.5223913 11.0345349,19.0216957
C11.2290295,19.5198261 11.3280378,20.0633478 11.3280378,20.6385652
L11.3280378,30.2701304 C11.3280378,30.603913 11.3933911,30.848087
11.5276197,31.017913 C11.6681098,31.1951739 11.9123042,31.2847826
12.2535502,31.2847826 L13.6803657,31.2847826 C14.3444649,31.2847826
14.6947117,30.9220435 14.6947117,30.2345217 L14.6947117,20.6921739
C14.6947117,20.0966087 14.8136782,19.5378261 15.0473065,19.0291304
C15.2825001,18.5184783 15.602614,18.072 15.9978645,17.6998696
C16.3931151,17.3277391 16.852545,17.0303478 17.36324,16.817087
C17.875109,16.6053913 18.412415,16.4985652 18.9606784,16.4985652
C19.5187253,16.4985652 20.0266809,16.6073478 20.468892,16.8233478
C20.911103,17.0389565 21.2922654,17.3347826 21.6002478,17.7049565
C21.9086215,18.0763043 22.1489025,18.516913 22.3144382,19.0158261
C22.4811478,19.515913 22.5656767,20.0629565 22.5656767,20.6389565
L22.5656767,30.2705217 C22.5656767,30.7338261 22.7546925,31.2851739
23.6516374,31.2851739 L24.9187872,31.2851739 C25.2154208,31.2851739
25.4631372,31.1951739 25.6556751,31.0159565 C25.8517351,30.8343913
25.9507434,30.5718261 25.9507434,30.234913 L25.9507434,20.6921739
C25.9507434,19.6943478 25.7859904,18.7297826 25.4611806,17.8270435
C25.1351967,16.9223478 24.6675487,16.1146957 24.0699768,15.4275652
L24.0699768,15.4275652 Z" />
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.user.AccountActivity" >
<ProgressBar
android:id="@+id/progress_circular_account"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible"
android:elevation="12dp"
android:layout_gravity="center"/>
</FrameLayout>

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
tools:context=".ui.login.LoginActivity">
<ImageView
android:id="@+id/img_logo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:src="@drawable/moneybox_logo"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginLeft="18dp"
android:layout_marginRight="18dp"
app:layout_constraintTop_toBottomOf="@+id/img_logo">
<EditText
android:id="@+id/et_email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:maxLines="1"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginLeft="18dp"
android:layout_marginRight="18dp"
app:layout_constraintTop_toBottomOf="@+id/til_email">
<EditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_password"
android:imeActionLabel="@string/action_sign_in_short"
android:inputType="textPassword"
android:maxLines="1"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginLeft="18dp"
android:layout_marginRight="18dp"
app:layout_constraintTop_toBottomOf="@+id/til_password">
<EditText
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_full_name"
android:inputType="textPersonName"
android:maxLines="1"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitEnd"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/btn_sign_in"
tools:layout_height="40dp"
tools:layout_width="40dp" />
<Button
android:id="@+id/btn_sign_in"
style="@style/Widget.Button.Colored.Rounded"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:text="@string/action_sign_in"
android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/til_name"
app:layout_constraintBottom_toBottomOf="parent"/>
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/light_grey"
android:id="@+id/useraccounts"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.user.AccountActivity">
<TextView
android:id="@+id/account_name_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_margin="12dp"
android:textColor="@color/dark_text"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/hello" />
<TextView
android:id="@+id/friendly_account_tv"
app:layout_constraintTop_toBottomOf="@id/account_name_tv"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:textColor="@color/dark_text"
android:layout_margin="12dp"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Total Plan: £7000" />
<TextView
android:id="@+id/plan_val_tv"
app:layout_constraintTop_toBottomOf="@id/friendly_account_tv"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_margin="12dp"
android:textColor="@color/dark_text"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/hello" />
<TextView
android:id="@+id/moneybox_val_tv"
app:layout_constraintTop_toBottomOf="@id/plan_val_tv"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:textColor="@color/dark_text"
android:layout_margin="12dp"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Total Plan: £7000" />
<Button
android:id="@+id/btn_add_20"
style="@style/Widget.Button.Colored.Rounded"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:text="@string/add_20"
android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/moneybox_val_tv"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/light_grey"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp"
android:padding="12dp"
app:cardCornerRadius="22dp"
tools:layout_height="150dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/name_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/vertical_spacing_text"
tools:text="Stocks &amp; Shares ISA"
android:textColor="@color/colorAccent"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/icon"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/plan_val_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/vertical_spacing_text"
tools:text="Plan Value: £4000"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/colorAccent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/icon"
app:layout_constraintTop_toBottomOf="@id/name_tv" />
<TextView
android:id="@+id/moneybox_val_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/vertical_spacing_text"
tools:text="Moneybox: £4000"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/icon"
app:layout_constraintTop_toBottomOf="@id/plan_val_tv" />
<TextView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_margin="12dp"
android:gravity="center"
android:text=">"
android:textColor="@color/colorPrimaryDark"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/light_grey"
android:id="@+id/useraccounts"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/user_name_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_margin="12dp"
android:textColor="@color/dark_text"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/hello" />
<TextView
android:id="@+id/total_plan_val_tv"
app:layout_constraintTop_toBottomOf="@id/user_name_tv"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:textColor="@color/dark_text"
android:layout_margin="12dp"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Total Plan: £7000" />
<LinearLayout
android:id="@+id/linear_layout"
app:layout_constraintTop_toBottomOf="@id/total_plan_val_tv"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:textStyle="bold"
android:textSize="24sp"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="12dp"
tools:text="Total Plan: £7000"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/products_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/products_list_item"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">@color/white</color>
<color name="colorPrimaryDark">#546270</color>
<color name="colorAccent">#49bfbd</color>
<color name="white">#ffffff</color>
<color name="light_grey">#dde0e2</color>
<color name="dark_text">#006179</color>
#006179
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="vertical_spacing_text">6dp</dimen>
</resources>

View File

@@ -0,0 +1,20 @@
<resources>
<string name="app_name">MiniMoneybox</string>
<!-- Strings related to login -->
<string name="prompt_email">Email</string>
<string name="prompt_password">Password</string>
<string name="prompt_full_name">Full Name (optional)</string>
<string name="action_sign_in">Sign in</string>
<string name="action_sign_in_short">Sign in</string>
<string name="error_incorrect_password">This password is incorrect</string>
<string name="email_address_error">Please enter a correct email address</string>
<string name="password_error">Please enter a correct password</string>
<string name="full_name_error">Please enter your full name</string>
<string name="input_valid">Input is valid!</string>
<string name="hello">Hello !</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="add_20">Add £20</string>
<string name="session_expired">Your session has expired. Please close the app and log in again.</string>
</resources>

View File

@@ -0,0 +1,26 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Widget.Button.Colored.Rounded" parent="Widget.AppCompat.Button.Borderless.Colored">
<item name="android:textSize">14sp</item>
<item name="android:layout_height">44dp</item>
<item name="android:background">@drawable/background_button_colored_rounded_medium</item>
<item name="android:textColor">@color/white</item>
</style>
<style name="Widget.Moneybox.EditText" parent="Widget.AppCompat.EditText">
<item name="android:background">@drawable/background_edit_text</item>
<item name="android:lineSpacingExtra">8sp</item>
<item name="android:textColor">@color/colorPrimaryDark</item>
</style>
</resources>

View File

@@ -0,0 +1,16 @@
package com.example.minimoneybox
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,9 @@
package com.example.minimoneybox
import androidx.lifecycle.LiveData
import com.example.minimoneybox.ui.OneTimeObserver
fun <T> LiveData<T>.observeOnce(onChangeHandler: (T) -> Unit) {
val observer = OneTimeObserver(handler = onChangeHandler)
observe(observer, observer)
}

View File

@@ -0,0 +1,160 @@
package com.example.minimoneybox.data.repository
import com.example.minimoneybox.data.network.LoginApi
import com.example.minimoneybox.data.network.UserAccountApi
import com.example.minimoneybox.data.network.response.LoginResponse
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import com.example.minimoneybox.data.prefs.KeystoreStorage
import com.google.gson.Gson
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import retrofit2.Response
import java.io.IOException
import kotlin.test.assertFailsWith
class RepositoryNetworkTest{
lateinit var repository: MoneyBoxRepository
@Mock
lateinit var api: LoginApi
@Mock
lateinit var securePrefs: KeystoreStorage
@Mock
lateinit var userAccountApi: UserAccountApi
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
repository = MoneyBoxRepositoryImpl(securePrefs, api, userAccountApi, Gson())
}
@Test
fun loginUser_positiveResponse() = runBlocking {
//GIVEN - Create the hashmap used in the function
val hash = HashMap<String, String>()
hash["Email"] = "email"
hash["Password"] = "password"
hash["Idfa"] = "ANYTHING"
//create a successful retrofit response
val mockLoginResponse = mock(LoginResponse::class.java)
val re = Response.success(mockLoginResponse)
//WHEN - loginApiRequest to return a successful response
Mockito.`when`(api.loginApiRequest(hash)).thenReturn(re)
//THEN - the unwrapped login response contains the correct values
val login = repository.loginUser("email","password")
assertNotNull(login)
assertEquals(login, mockLoginResponse)
}
@Test
fun getInvestmentProducts_positiveResponse() = runBlocking {
//GIVEN
val mockProductsResponse = mock(ProductApiResponse::class.java)
val re = Response.success(mockProductsResponse)
//WHEN
Mockito.`when`(userAccountApi.getProductsFromApi("Bearer ")).thenReturn(re)
//THEN
val products = repository.getProducts("")
assertNotNull(products)
assertEquals(products, mockProductsResponse)
}
@Test
fun pushOneOffPayment_positiveResponse() = runBlocking {
//GIVEN
val hash = HashMap<String, Int>()
hash["Amount"] = 20
hash["InvestorProductId"] = 1674
val mockPaymentsResponse = mock(PaymentResponse::class.java)
val re = Response.success(mockPaymentsResponse)
//WHEN
Mockito.`when`(userAccountApi.oneOffPaymentsFromApi(hash, "Bearer 133nhdk12l58dmHJBNmd=")).thenReturn(re)
//THEN
val products = repository.oneOffPayment(1674,"133nhdk12l58dmHJBNmd=")
assertNotNull(products)
assertEquals(products, mockPaymentsResponse)
}
@Test
fun loginUser_negativeResponse() = runBlocking {
//GIVEN
val hash = HashMap<String, String>()
hash["Email"] = "email"
hash["Password"] = "password"
hash["Idfa"] = "ANYTHING"
//mock retrofit error response
val mockBody = mock(ResponseBody::class.java)
val mockRaw = mock(okhttp3.Response::class.java)
val re = Response.error<String>(mockBody, mockRaw)
//WHEN
Mockito.`when`(api.loginApiRequest(hash)).then { re }
//THEN - assert exception is not null
val ioExceptionReturned= assertFailsWith<IOException> {
repository.loginUser("email","password")
}
assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message)
}
@Test
fun getInvestmentProducts_negativeResponse() = runBlocking {
//GIVEN
val mockBody = mock(ResponseBody::class.java)
val mockRaw = mock(okhttp3.Response::class.java)
val re = Response.error<String>(mockBody, mockRaw)
//WHEN
Mockito.`when`(userAccountApi.getProductsFromApi("Bearer ")).then { re }
//THEN
val ioExceptionReturned= assertFailsWith<IOException> {
repository.getProducts("")
}
assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message)
}
@Test
fun pushOneOffPayment_negativeResponse() = runBlocking {
//GIVEN
val hash = HashMap<String, Int>()
hash["Amount"] = 20
hash["InvestorProductId"] = 1674
val mockBody = mock(ResponseBody::class.java)
val mockRaw = mock(okhttp3.Response::class.java)
val re = Response.error<String>(mockBody, mockRaw)
//WHEN
Mockito.`when`(userAccountApi.oneOffPaymentsFromApi(hash, "Bearer ")).then { re }
//THEN
val ioExceptionReturned= assertFailsWith<IOException> {
repository.oneOffPayment(1674,"")
}
assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message)
}
}

View File

@@ -0,0 +1,56 @@
package com.example.minimoneybox.data.repository
import com.example.minimoneybox.data.network.LoginApi
import com.example.minimoneybox.data.network.UserAccountApi
import com.example.minimoneybox.data.prefs.KeystoreStorage
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
class RepositoryStorageTest {
lateinit var repository: MoneyBoxRepository
@Mock
lateinit var api: LoginApi
@Mock
lateinit var securePrefs: KeystoreStorage
@Mock
lateinit var userAccountApi: UserAccountApi
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
repository = MoneyBoxRepositoryImpl(securePrefs, api, userAccountApi, Gson())
}
@Test
fun saveAndRetrieve_PositiveResponse() {
//GIVEN
val token = "92RG9qYRq5sUUaoJuolORfZORb9G6n014X2I="
repository.saveAuthToken(token)
//WHEN
Mockito.`when`(securePrefs.loadTokenFromPrefs()).thenReturn(token)
//THEN
assertEquals(token, repository.loadAuthToken())
}
@Test
fun saveAndRetrieveCredentials_PositiveResponse() {
//GIVEN
val pair: Pair<String, String> = Pair("forename", "Surname")
repository.saveCredentials("forename", "Surname")
//WHEN
Mockito.`when`(securePrefs.loadCredentialsFromPrefs()).thenReturn(pair)
//THEN
assertEquals(pair, repository.loadCredentials())
}
}

View File

@@ -0,0 +1,21 @@
package com.example.minimoneybox.ui
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.Observer
class OneTimeObserver<T>(private val handler: (T) -> Unit) : Observer<T>, LifecycleOwner {
private val lifecycle = LifecycleRegistry(this)
init {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
override fun getLifecycle(): Lifecycle = lifecycle
override fun onChanged(t: T) {
handler(t)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
}

View File

@@ -0,0 +1,67 @@
package com.example.minimoneybox.ui.login
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.minimoneybox.data.models.SessionObject
import com.example.minimoneybox.data.network.response.LoginResponse
import com.example.minimoneybox.data.repository.MoneyBoxRepository
import com.example.minimoneybox.observeOnce
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import java.io.IOException
class LoginViewModelTest {
// Run tasks synchronously
@get:Rule
val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var viewModel: LoginViewModel
@Mock
lateinit var repository: MoneyBoxRepository
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = LoginViewModel(repository)
}
@Test
fun attemptLogin_validCredentials_successResponse() = runBlocking{
//GIVEN
val sessionObject = SessionObject()
sessionObject.bearerToken = "123456"
val loginResponse = LoginResponse(sessionObject)
//WHEN
Mockito.`when`(repository.loginUser("email","password"))
.thenReturn(loginResponse)
//THEN
viewModel.operationSuccess.observeOnce {
print(it.toString())
assertEquals(true, it)
}
}
@Test
fun attemptLogin_validCredentials_unsuccessfulResponse() = runBlocking{
//GIVEN
val errorMessage = "Could not login user"
//WHEN
Mockito.`when`(repository.loginUser("email","password"))
.thenAnswer { throw IOException() }
//THEN
viewModel.operationFailed.observeOnce {
assertEquals(errorMessage, it)
}
}
}

View File

@@ -0,0 +1,140 @@
package com.example.minimoneybox.ui.user
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.minimoneybox.data.network.response.PaymentResponse
import com.example.minimoneybox.data.network.response.ProductApiResponse
import com.example.minimoneybox.data.repository.MoneyBoxRepository
import com.example.minimoneybox.observeOnce
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import java.io.IOException
class UserAccountsViewModelTest{
// Run tasks synchronously
@get:Rule
val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var viewModel: UserAccountsViewModel
@Mock
lateinit var repository: MoneyBoxRepository
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = UserAccountsViewModel(repository)
}
@Test
fun getProducts_validCredentials_successResponse() = runBlocking{
//GIVEN
val token = "123456"
val product = mock(ProductApiResponse::class.java)
//WHEN
Mockito.`when`(repository.loadAuthToken()).thenReturn(token)
Mockito.`when`(repository.getProducts(token)).thenReturn(product)
//THEN
viewModel.getInvestorProducts()
viewModel.operationSuccess.observeOnce {
assertEquals(true, it)
}
}
@Test
fun getProducts_validCredentials_unsuccessfulResponse() = runBlocking{
//GIVEN
val token = "123456"
val errorMessage = "Could not retrieve products"
//WHEN
Mockito.`when`(repository.loadAuthToken()).thenReturn(token)
Mockito.`when`(repository.getProducts(token)).thenAnswer { throw IOException(errorMessage) }
//THEN
viewModel.getInvestorProducts()
viewModel.operationFailed.observeOnce {
assertEquals(errorMessage, it)
}
}
@Test
fun getProducts_invalidCredentials_unsuccessfulResponse() = runBlocking{
//GIVEN
val errorMessage = "Failed to retrieve token"
//WHEN
Mockito.`when`(repository.loadAuthToken()).thenReturn(null)
//THEN
viewModel.getInvestorProducts()
viewModel.operationFailed.observeOnce {
assertEquals(errorMessage, it)
}
}
@Test
fun oneOffPayment_validCredentials_successResponse() = runBlocking{
//GIVEN
val productId = 2020
val token = "123456"
val product = mock(PaymentResponse::class.java)
//WHEN
Mockito.`when`(repository.loadAuthToken()).thenReturn(token)
Mockito.`when`(repository.oneOffPayment(productId,token)).thenReturn(product)
//THEN
viewModel.oneOffPayment(2020)
viewModel.operationSuccess.observeOnce {
assertEquals(true, it)
}
}
@Test
fun oneOffPayment_validCredentials_unsuccessfulResponse() = runBlocking{
//GIVEN
val productId = 2020
val token = "123456"
val errorMessage = "Could not post payment"
//WHEN
Mockito.`when`(repository.loadAuthToken()).thenReturn(token)
Mockito.`when`(repository.oneOffPayment(productId,token)).thenAnswer { throw IOException(errorMessage)}
//THEN
viewModel.oneOffPayment(2020)
viewModel.operationSuccess.observeOnce {
assertEquals(false, it)
}
viewModel.operationFailed.observeOnce {
print(it)
assertEquals(errorMessage, it)
}
}
@Test
fun oneOffPayment_invalidCredentials_unsuccessfulResponse() = runBlocking{
//GIVEN
val errorMessage = "Failed to retrieve token"
val token = ""
val productId = 2020
//WHEN
Mockito.`when`(repository.oneOffPayment(productId, token)).thenReturn(null)
//THEN
viewModel.oneOffPayment(productId)
viewModel.operationFailed.observeOnce {
assertEquals(errorMessage, it)
}
}
}

28
build.gradle Normal file
View File

@@ -0,0 +1,28 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.61'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

17
gradle.properties Normal file
View File

@@ -0,0 +1,17 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.useAndroidX=true
android.enableJetifier=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Wed Mar 06 09:53:14 GMT 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip

172
gradlew vendored Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
include ':app'