├── common
├── .gitignore
├── src
│ └── main
│ │ └── java
│ │ └── common
│ │ ├── AppScope.kt
│ │ ├── ActivityScope.kt
│ │ ├── AppContext.kt
│ │ ├── ActivityContext.kt
│ │ ├── ActivityRetainedScope.kt
│ │ ├── ImmediateDispatcher.kt
│ │ ├── IoDispatcher.kt
│ │ └── MainDispatcher.kt
└── build.gradle
├── domain
├── .gitignore
├── src
│ ├── main
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ └── domain
│ │ │ ├── bill
│ │ │ ├── BillStatus.kt
│ │ │ ├── error
│ │ │ │ ├── BillInvalidDueDateException.kt
│ │ │ │ ├── BillsIsAlreadyPaidException.kt
│ │ │ │ ├── BillValidationException.kt
│ │ │ │ └── BillValidationErrorType.kt
│ │ │ ├── BillGateway.kt
│ │ │ ├── BillService.kt
│ │ │ ├── CreateBill.kt
│ │ │ ├── PayBill.kt
│ │ │ ├── Bill.kt
│ │ │ └── BillValidator.kt
│ │ │ ├── summary
│ │ │ ├── SummaryService.kt
│ │ │ ├── SummaryPreferences.kt
│ │ │ ├── Summary.kt
│ │ │ └── FetchSummary.kt
│ │ │ └── currency
│ │ │ └── CurrencyMachine.kt
│ └── test
│ │ └── java
│ │ └── app
│ │ └── boletinhos
│ │ └── domain
│ │ ├── summary
│ │ ├── FakeSummaryPreferences.kt
│ │ └── FakeSummaryService.kt
│ │ ├── bill
│ │ ├── FakeBillGateway.kt
│ │ ├── PayBillTest.kt
│ │ └── CreateBillTest.kt
│ │ └── currency
│ │ └── CurrencyMachineTest.kt
└── build.gradle
├── android-ui
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── drawable-hdpi
│ │ │ │ └── ic_barcode.png
│ │ │ ├── drawable-mdpi
│ │ │ │ └── ic_barcode.png
│ │ │ ├── drawable-xhdpi
│ │ │ │ └── ic_barcode.png
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── drawable-xxhdpi
│ │ │ │ └── ic_barcode.png
│ │ │ ├── drawable-xxxhdpi
│ │ │ │ └── ic_barcode.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── layout
│ │ │ │ ├── wip_view.xml
│ │ │ │ ├── summary_view_error.xml
│ │ │ │ ├── bill_add.xml
│ │ │ │ ├── error_view.xml
│ │ │ │ ├── summary_item_view.xml
│ │ │ │ ├── welcome_view.xml
│ │ │ │ └── summary_view.xml
│ │ │ ├── values
│ │ │ │ ├── attrs.xml
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── themes.xml
│ │ │ │ ├── styles.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── shapes.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── baseline.xml
│ │ │ ├── values-v23
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── font
│ │ │ │ ├── fira_sans.xml
│ │ │ │ ├── vidaloka.xml
│ │ │ │ ├── fira_sans_bold.xml
│ │ │ │ ├── fira_sans_light.xml
│ │ │ │ └── fira_sans_medium.xml
│ │ │ ├── drawable-anydpi-v23
│ │ │ │ ├── splash.xml
│ │ │ │ └── ic_barcode.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_arrow_back.xml
│ │ │ │ ├── splash.xml
│ │ │ │ ├── ic_add.xml
│ │ │ │ ├── ic_check.xml
│ │ │ │ ├── ic_hourglass.xml
│ │ │ │ ├── ic_calendar.xml
│ │ │ │ ├── ic_summary.xml
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_paid.xml
│ │ │ └── animator-v21
│ │ │ │ └── appbar_state_list_animator.xml
│ │ ├── java
│ │ │ └── app
│ │ │ │ └── boletinhos
│ │ │ │ ├── summary
│ │ │ │ ├── SummaryViewEvent.kt
│ │ │ │ ├── SummaryViewState.kt
│ │ │ │ ├── SummaryViewKey.kt
│ │ │ │ ├── SummaryDecoration.kt
│ │ │ │ ├── SummaryAdapter.kt
│ │ │ │ └── SummaryItemCardView.kt
│ │ │ │ ├── messaging
│ │ │ │ └── UiEvent.kt
│ │ │ │ ├── bill
│ │ │ │ └── add
│ │ │ │ │ ├── AddBillViewInput.kt
│ │ │ │ │ ├── ExceptionHandler.kt
│ │ │ │ │ ├── AddBillViewKey.kt
│ │ │ │ │ └── AddBillView.kt
│ │ │ │ ├── error
│ │ │ │ ├── ErrorViewModel.kt
│ │ │ │ └── ErrorView.kt
│ │ │ │ ├── wip
│ │ │ │ └── WipViewKey.kt
│ │ │ │ ├── welcome
│ │ │ │ ├── injection
│ │ │ │ │ └── WelcomeModule.kt
│ │ │ │ ├── WelcomeView.kt
│ │ │ │ ├── WelcomeViewModel.kt
│ │ │ │ └── WelcomeViewKey.kt
│ │ │ │ ├── crashcat
│ │ │ │ ├── injection
│ │ │ │ │ └── CrashlyticsModule.kt
│ │ │ │ └── CrashCat.kt
│ │ │ │ ├── main
│ │ │ │ ├── injection
│ │ │ │ │ ├── ActivityRetainedServicesFactory.kt
│ │ │ │ │ ├── ActivityRetainedService.kt
│ │ │ │ │ └── ActivityRetainedComponent.kt
│ │ │ │ └── MainActivity.kt
│ │ │ │ ├── application
│ │ │ │ ├── MainApplication.kt
│ │ │ │ └── injection
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ └── AppComponent.kt
│ │ │ │ └── theming
│ │ │ │ └── ThemeAwareDrawable.kt
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ ├── testutil
│ │ │ ├── TestActivity.kt
│ │ │ ├── TextInputMatcher.kt
│ │ │ ├── DateInputMatcher.kt
│ │ │ ├── CurrencyInputMatcher.kt
│ │ │ ├── RecyclerViewMatcher.kt
│ │ │ ├── TextInputLayoutMatcher.kt
│ │ │ └── FakeBillsFactory.kt
│ │ │ ├── runner
│ │ │ └── TestRunner.kt
│ │ │ ├── navigation
│ │ │ └── FakeView.kt
│ │ │ ├── application
│ │ │ ├── TestApplication.kt
│ │ │ └── injection
│ │ │ │ ├── TestDatabaseModule.kt
│ │ │ │ ├── TestAppComponent.kt
│ │ │ │ └── TestModule.kt
│ │ │ ├── WorkflowExampleTest.kt
│ │ │ ├── rule
│ │ │ └── UsesDatabaseRule.kt
│ │ │ ├── welcome
│ │ │ ├── WelcomeViewTest.kt
│ │ │ └── WitnessRobot.kt
│ │ │ ├── widget
│ │ │ ├── date
│ │ │ │ ├── DateInputTest.kt
│ │ │ │ └── FifteenthRobot.kt
│ │ │ └── currency
│ │ │ │ ├── CurrencyInputTest.kt
│ │ │ │ └── QuindimRobot.kt
│ │ │ └── lifecycle
│ │ │ └── ActivityRetainedCoroutineScopeTest.kt
│ ├── test
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ ├── lifecycle
│ │ │ └── ViewModelScope.kt
│ │ │ ├── testutil
│ │ │ └── TestKey.kt
│ │ │ ├── welcome
│ │ │ └── WelcomeViewModelTest.kt
│ │ │ └── summary
│ │ │ └── SummaryTestUtil.kt
│ └── debug
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
├── google-services.json
└── build.gradle
├── preferences
├── .gitignore
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ └── preferences
│ │ │ ├── injection
│ │ │ └── UserPreferencesModule.kt
│ │ │ └── UserPreferences.kt
│ └── test
│ │ └── java
│ │ └── app
│ │ └── boletinhos
│ │ └── preferences
│ │ ├── UserPreferencesTest.kt
│ │ └── FakePreferences.kt
├── build.gradle
└── proguard-rules.pro
├── testutil
├── .gitignore
├── build.gradle
└── src
│ └── main
│ └── java
│ └── testutil
│ └── MainCoroutineRule.kt
├── android-core
├── .gitignore
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ ├── res
│ │ │ ├── values
│ │ │ │ └── ids.xml
│ │ │ └── layout
│ │ │ │ ├── text_input_content.xml
│ │ │ │ └── modal_bottom_sheet.xml
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ ├── lifecycle
│ │ │ ├── LifecycleAwareCoroutineScope.kt
│ │ │ ├── injection
│ │ │ │ └── LifecycleCoroutineScopeModule.kt
│ │ │ └── ActivityRetainedCoroutineScope.kt
│ │ │ ├── widget
│ │ │ ├── recyclerview
│ │ │ │ ├── BindableViewHolder.kt
│ │ │ │ ├── ListAdapter.kt
│ │ │ │ └── VerticalMarginDecoration.kt
│ │ │ ├── text
│ │ │ │ └── TextInput.kt
│ │ │ ├── date
│ │ │ │ ├── DateInput.kt
│ │ │ │ └── DateTextWatcher.kt
│ │ │ └── currency
│ │ │ │ ├── CurrencyInput.kt
│ │ │ │ └── CurrencyTextWatcher.kt
│ │ │ ├── navigation
│ │ │ ├── ViewCoroutineScope.kt
│ │ │ ├── ViewKey.kt
│ │ │ └── StateChange.kt
│ │ │ ├── time
│ │ │ └── LocalDateParser.kt
│ │ │ └── ext
│ │ │ └── view
│ │ │ └── ViewExt.kt
│ └── test
│ │ └── java
│ │ └── app
│ │ └── boletinhos
│ │ └── lifecycle
│ │ ├── FakeCoroutineScopeClient.kt
│ │ └── LifecycleCoroutineScopeTest.kt
└── build.gradle
├── bills-service
├── .gitignore
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── app
│ │ │ └── boletinhos
│ │ │ ├── bill
│ │ │ ├── InDatabaseBillGateway.kt
│ │ │ ├── BillEntity.kt
│ │ │ ├── injection
│ │ │ │ └── BillServiceModule.kt
│ │ │ └── InDatabaseBillService.kt
│ │ │ ├── typeconverter
│ │ │ ├── BillStatusTypeConverter.kt
│ │ │ └── LocalDateTypeConverter.kt
│ │ │ ├── database
│ │ │ ├── injection
│ │ │ │ └── AppDatabaseModule.kt
│ │ │ └── AppDatabase.kt
│ │ │ └── summary
│ │ │ ├── UserSummaryPreferences.kt
│ │ │ ├── injection
│ │ │ └── SummaryServiceModule.kt
│ │ │ └── InDatabaseSummaryService.kt
│ └── test
│ │ └── java
│ │ └── app
│ │ └── boletinhos
│ │ ├── testutil
│ │ ├── CoroutineTest.kt
│ │ └── AppDatabaseTest.kt
│ │ ├── summary
│ │ ├── UserSummaryPreferencesTest.kt
│ │ ├── InDatabaseSummaryServiceTest.kt
│ │ └── FakePreferences.kt
│ │ ├── typeconverter
│ │ ├── LocalDateTypeConverterTest.kt
│ │ └── BillStatusTypeConverterTest.kt
│ │ ├── bill
│ │ ├── InDatabaseBillGatewayTest.kt
│ │ └── InDatabaseBillServiceTest.kt
│ │ └── fakes
│ │ ├── SummaryFactory.kt
│ │ └── BillsFactory.kt
├── build.gradle
└── schemas
│ ├── app.boletinhos.database.AppDatabase
│ └── 1.json
│ └── app.boletinhos.storage.database.AppDatabase
│ └── 1.json
├── .github
├── branding
│ └── cover.png
└── workflows
│ └── code_analysis.yml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── detekt.gradle
├── settings.gradle
├── LICENSE
├── gradle.properties
├── .gitignore
├── gradlew.bat
└── jacoco.gradle
/common/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/domain/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/android-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/preferences/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/testutil/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/android-core/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/bills-service/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/android-core/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/preferences/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/branding/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/.github/branding/cover.png
--------------------------------------------------------------------------------
/bills-service/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | /
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/common/src/main/java/common/AppScope.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope @Retention annotation class AppScope
6 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-hdpi/ic_barcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/drawable-hdpi/ic_barcode.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-mdpi/ic_barcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/drawable-mdpi/ic_barcode.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-xhdpi/ic_barcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/drawable-xhdpi/ic_barcode.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/common/src/main/java/common/ActivityScope.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope @Retention annotation class ActivityScope
6 |
--------------------------------------------------------------------------------
/common/src/main/java/common/AppContext.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier @Retention annotation class AppContext
6 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-xxhdpi/ic_barcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/drawable-xxhdpi/ic_barcode.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-xxxhdpi/ic_barcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/drawable-xxxhdpi/ic_barcode.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/common/src/main/java/common/ActivityContext.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier @Retention annotation class ActivityContext
6 |
--------------------------------------------------------------------------------
/detekt.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'io.gitlab.arturbosch.detekt'
2 |
3 | detekt {
4 | config = files("$rootDir/config/detekt/detekt.yml")
5 | autoCorrect = true
6 | }
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcszc/Boletinhos/HEAD/android-ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/common/src/main/java/common/ActivityRetainedScope.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope @Retention annotation class ActivityRetainedScope
6 |
--------------------------------------------------------------------------------
/common/src/main/java/common/ImmediateDispatcher.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier @Retention annotation class ImmediateDispatcher
6 |
--------------------------------------------------------------------------------
/common/src/main/java/common/IoDispatcher.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier @MustBeDocumented @Retention annotation class IoDispatcher
6 |
--------------------------------------------------------------------------------
/common/src/main/java/common/MainDispatcher.kt:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Qualifier @MustBeDocumented @Retention annotation class MainDispatcher
6 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/BillStatus.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | enum class BillStatus {
4 | UNPAID,
5 | PAID,
6 | OVERDUE
7 | }
8 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':common'
2 | include ':android-ui'
3 | include ':android-core'
4 | include ':domain'
5 | include ':bills-service'
6 | include ':preferences'
7 | include ':testutil'
8 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/error/BillInvalidDueDateException.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill.error
2 |
3 | object BillInvalidDueDateException : IllegalStateException()
4 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/error/BillsIsAlreadyPaidException.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill.error
2 |
3 | object BillsIsAlreadyPaidException : IllegalStateException()
4 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/BillGateway.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | interface BillGateway {
4 | suspend fun create(bill: Bill)
5 | suspend fun pay(bill: Bill)
6 | }
7 |
--------------------------------------------------------------------------------
/android-core/src/main/res/values/ids.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/common/build.gradle:
--------------------------------------------------------------------------------
1 | import config.DI
2 | import config.Kotlinx
3 |
4 | apply from: "$rootDir/build-system/kotlin.gradle"
5 |
6 | dependencies {
7 | implementation(DI.javax)
8 | implementation(Kotlinx.Coroutines.core)
9 | }
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/error/BillValidationException.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill.error
2 |
3 | data class BillValidationException(val errors: List) : RuntimeException()
4 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryViewEvent.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | sealed class SummaryViewEvent {
4 | object FetchData : SummaryViewEvent()
5 | object OnClickInAddBill : SummaryViewEvent()
6 | }
7 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/wip_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/messaging/UiEvent.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.messaging
2 |
3 | import androidx.annotation.StringRes
4 |
5 | sealed class UiEvent {
6 | data class ResourceMessage(@StringRes val messageRes: Int) : UiEvent()
7 | }
8 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/summary/SummaryService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface SummaryService {
6 | suspend fun hasSummary(): Boolean
7 | fun getSummaries(): Flow>
8 | }
9 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Apr 17 00:06:33 BRT 2020
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
7 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/domain/build.gradle:
--------------------------------------------------------------------------------
1 | import config.DI
2 | import config.Kotlinx
3 |
4 | apply from: "$rootDir/build-system/kotlin.gradle"
5 | apply plugin: 'kotlin-kapt'
6 |
7 | dependencies {
8 | implementation(Kotlinx.Coroutines.core)
9 | implementation(DI.dagger)
10 | kapt(DI.compiler)
11 | }
--------------------------------------------------------------------------------
/testutil/build.gradle:
--------------------------------------------------------------------------------
1 | import config.Kotlinx
2 | import config.Testing
3 |
4 | apply from: "$rootDir/build-system/kotlin.gradle"
5 |
6 | dependencies {
7 | implementation(Kotlinx.Coroutines.core)
8 | implementation(Kotlinx.Coroutines.test)
9 | implementation(Testing.junit)
10 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/bill/add/AddBillViewInput.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill.add
2 |
3 | import java.time.LocalDate
4 |
5 | data class AddBillViewInput(
6 | val value: Long,
7 | val name: String,
8 | val description: String,
9 | val dueDate: LocalDate?
10 | )
11 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values-v23/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/BillService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface BillService {
6 | suspend fun getById(id: Long): Bill
7 | fun getAll(): Flow>
8 | fun getByStatus(status: BillStatus): Flow>
9 | }
10 |
--------------------------------------------------------------------------------
/android-core/src/main/res/layout/text_input_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/lifecycle/LifecycleAwareCoroutineScope.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.CoroutineScope
5 |
6 | interface LifecycleAwareCoroutineScope : CoroutineScope {
7 | val io: CoroutineDispatcher
8 | val main: CoroutineDispatcher
9 | }
10 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/error/ErrorViewModel.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.error
2 |
3 | import android.os.Parcelable
4 | import androidx.annotation.StringRes
5 | import kotlinx.android.parcel.Parcelize
6 |
7 | @Parcelize
8 | data class ErrorViewModel(
9 | @StringRes val titleRes: Int,
10 | @StringRes val messageRes: Int
11 | ) : Parcelable
12 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/wip/WipViewKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.wip
2 |
3 | import app.boletinhos.navigation.ViewKey
4 | import kotlinx.android.parcel.Parcelize
5 | import app.boletinhos.R.layout as Layouts
6 |
7 | @Parcelize
8 | data class WipViewKey(val title: String = "") : ViewKey {
9 | override fun layout() = Layouts.wip_view
10 | }
11 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/bill/add/ExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill.add
2 |
3 | import kotlinx.coroutines.CoroutineExceptionHandler
4 |
5 | inline fun addBillExceptionHandler(
6 | crossinline handler: (AddBillViewError) -> Unit
7 | ) = CoroutineExceptionHandler { _, throwable ->
8 | handler(AddBillViewError(throwable))
9 | }
10 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/summary/SummaryPreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | interface SummaryPreferences {
4 | fun actualSummaryId(): Long
5 | fun actualSummary(id: Long)
6 |
7 | companion object {
8 | const val NO_SUMMARY: Long = -1
9 | const val ACTUAL_SUMMARY_ID = "actual_summary_id"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/summary_view_error.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/preferences/build.gradle:
--------------------------------------------------------------------------------
1 | import config.DI
2 | import config.Kotlinx
3 |
4 | apply plugin: 'com.android.library'
5 | apply from: "$rootDir/build-system/android.gradle"
6 |
7 | apply plugin: 'kotlin-kapt'
8 |
9 | dependencies {
10 | api project(":common")
11 |
12 | implementation(DI.dagger)
13 | kapt(DI.compiler)
14 |
15 | testImplementation(Kotlinx.Coroutines.test)
16 | }
--------------------------------------------------------------------------------
/android-ui/src/main/res/font/fira_sans.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/font/vidaloka.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-anydpi-v23/splash.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
11 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_arrow_back.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/font/fira_sans_bold.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/bill_add.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/font/fira_sans_light.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/font/fira_sans_medium.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/summary/FakeSummaryPreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | class FakeSummaryPreferences : SummaryPreferences {
4 | private var summaryId: Long? = null
5 |
6 | override fun actualSummaryId(): Long {
7 | return summaryId ?: SummaryPreferences.NO_SUMMARY
8 | }
9 |
10 | override fun actualSummary(id: Long) {
11 | summaryId = id
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 8dp
5 | 16dp
6 |
7 | @dimen/app_margin_2x
8 |
9 |
10 | 0.3
11 | 0.16
12 |
13 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/welcome/injection/WelcomeModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome.injection
2 |
3 | import app.boletinhos.bill.add.AddBillViewModel
4 | import app.boletinhos.welcome.WelcomeViewModel
5 | import dagger.Binds
6 | import dagger.Module
7 |
8 | @Module
9 | abstract class WelcomeModule {
10 | @Binds abstract fun provideOnBillCreatedListener(
11 | impl: WelcomeViewModel
12 | ): AddBillViewModel.OnBillCreatedListener
13 | }
14 |
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/summary/FakeSummaryService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.flowOf
5 |
6 | class FakeSummaryService(private val db: List) : SummaryService {
7 | override suspend fun hasSummary(): Boolean {
8 | return db.isNotEmpty()
9 | }
10 |
11 | override fun getSummaries(): Flow> {
12 | return flowOf(db)
13 | }
14 | }
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/recyclerview/BindableViewHolder.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.recyclerview
2 |
3 | import android.view.View
4 | import androidx.recyclerview.widget.RecyclerView
5 | import androidx.viewbinding.ViewBinding
6 |
7 | abstract class BindableViewHolder : RecyclerView.ViewHolder {
8 | constructor(view: View) : super(view)
9 | constructor(viewBinding: ViewBinding) : super(viewBinding.root)
10 | abstract fun bind(model: T)
11 | }
12 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/crashcat/injection/CrashlyticsModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.crashcat.injection
2 |
3 | import com.google.firebase.crashlytics.FirebaseCrashlytics
4 | import common.AppScope
5 | import dagger.Module
6 | import dagger.Provides
7 |
8 | @Module
9 | object CrashlyticsModule {
10 | @Provides
11 | @AppScope
12 | internal fun provideCrashlytics(): FirebaseCrashlytics {
13 | return FirebaseCrashlytics.getInstance()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/splash.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | -
8 |
12 |
13 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryViewState.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.os.Parcelable
4 | import app.boletinhos.error.ErrorViewModel
5 | import kotlinx.android.parcel.Parcelize
6 |
7 | @Parcelize
8 | data class SummaryViewState(
9 | val isLoading: Boolean = false,
10 | val isActionAndSummaryVisible: Boolean = false,
11 | val summary: List? = null,
12 | val error: ErrorViewModel? = null
13 | ) : Parcelable
14 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/TestActivity.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.app.Activity
4 | import android.os.Bundle
5 | import android.widget.FrameLayout
6 | import android.R.id as AndroidIds
7 |
8 | class TestActivity : Activity() {
9 | lateinit var rootView: FrameLayout
10 | private set
11 |
12 | override fun onCreate(savedInstanceState: Bundle?) {
13 | super.onCreate(savedInstanceState)
14 | rootView = findViewById(AndroidIds.content)
15 | }
16 | }
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/bill/InDatabaseBillGateway.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.Update
6 | import app.boletinhos.domain.bill.Bill
7 | import app.boletinhos.domain.bill.BillGateway
8 |
9 | @Dao internal interface InDatabaseBillGateway : BillGateway {
10 | @Insert(entity = BillEntity::class)
11 | override suspend fun create(bill: Bill)
12 |
13 | @Update(entity = BillEntity::class)
14 | override suspend fun pay(bill: Bill)
15 | }
16 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/lifecycle/injection/LifecycleCoroutineScopeModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle.injection
2 |
3 | import app.boletinhos.lifecycle.ActivityRetainedCoroutineScope
4 | import app.boletinhos.lifecycle.LifecycleAwareCoroutineScope
5 | import kotlinx.coroutines.CoroutineScope
6 |
7 | @dagger.Module
8 | object LifecycleCoroutineScopeModule {
9 | @dagger.Provides
10 | internal fun provideCoroutineScope(impl: ActivityRetainedCoroutineScope): LifecycleAwareCoroutineScope {
11 | return impl
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/android-ui/src/test/java/app/boletinhos/lifecycle/ViewModelScope.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.test.TestCoroutineDispatcher
5 | import kotlin.coroutines.CoroutineContext
6 |
7 | val viewModelScope = object : LifecycleAwareCoroutineScope {
8 | override val coroutineContext: CoroutineContext = TestCoroutineDispatcher()
9 | override val io: CoroutineDispatcher = TestCoroutineDispatcher()
10 | override val main: CoroutineDispatcher = TestCoroutineDispatcher()
11 | }
12 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/bill/BillEntity.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 | import app.boletinhos.domain.bill.BillStatus
6 | import java.time.LocalDate
7 |
8 | @Entity(tableName = "bills")
9 | data class BillEntity(
10 | @PrimaryKey(autoGenerate = true)
11 | var id: Long = 0,
12 | val name: String,
13 | val description: String,
14 | val value: Long,
15 | val paymentDate: LocalDate?,
16 | val dueDate: LocalDate,
17 | val status: BillStatus
18 | )
19 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/runner/TestRunner.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.runner
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.test.runner.AndroidJUnitRunner
6 | import app.boletinhos.application.TestApplication
7 |
8 | class TestRunner : AndroidJUnitRunner() {
9 | override fun newApplication(
10 | cl: ClassLoader?,
11 | className: String?,
12 | context: Context?
13 | ): Application {
14 | return super.newApplication(cl, TestApplication::class.java.name, context)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/CreateBill.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import javax.inject.Inject
4 |
5 | class CreateBill @Inject constructor(
6 | private val gateway: BillGateway,
7 | private val validator: BillValidator
8 | ) {
9 | suspend operator fun invoke(bill: Bill) {
10 | validator.validate(bill)
11 |
12 | val status = if (bill.isOverdue()) BillStatus.OVERDUE else bill.status
13 | val newBill = bill.copy(status = status)
14 |
15 | newBill.id = bill.id
16 | gateway.create(newBill)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/typeconverter/BillStatusTypeConverter.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.typeconverter
2 |
3 | import androidx.room.TypeConverter
4 | import app.boletinhos.domain.bill.BillStatus
5 |
6 | object BillStatusTypeConverter {
7 | @TypeConverter
8 | @JvmStatic
9 | fun toStatus(value: String?): BillStatus? {
10 | if (value == null) return null
11 | return BillStatus.valueOf(value)
12 | }
13 |
14 | @TypeConverter
15 | @JvmStatic
16 | fun fromStatus(status: BillStatus?): String? {
17 | return status?.name
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/recyclerview/ListAdapter.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.recyclerview
2 |
3 | import androidx.recyclerview.widget.RecyclerView
4 |
5 | abstract class ListAdapter : RecyclerView.Adapter>() {
6 | var items: List = emptyList()
7 | set(value) {
8 | field = value
9 | notifyDataSetChanged()
10 | }
11 |
12 | override fun getItemCount() = items.size
13 |
14 | override fun onBindViewHolder(holder: BindableViewHolder, position: Int) {
15 | holder.bind(items[position])
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/code_analysis.yml:
--------------------------------------------------------------------------------
1 | name: Static Code Analysis
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - 'main'
7 | paths-ignore:
8 | - '*.md'
9 |
10 | jobs:
11 | code_analysis:
12 | name: Detekt
13 | runs-on: ubuntu-18.04
14 | if: "!contains(github.event.head_commit.message, '[skip-ci]')"
15 |
16 | steps:
17 | - name: Checkout repo
18 | uses: actions/checkout@v2
19 |
20 | - name: Set up JDK 1.8
21 | uses: actions/setup-java@v1
22 | with:
23 | java-version: 1.8
24 |
25 | - name: Run DeteKt check
26 | run: bash ./gradlew detekt
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/navigation/FakeView.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.navigation
2 |
3 | import android.content.Context
4 | import android.widget.FrameLayout
5 | import android.widget.TextView
6 | import kotlinx.coroutines.launch
7 |
8 | class FakeView(context: Context, private val textContent: String) : FrameLayout(context) {
9 | override fun onAttachedToWindow() {
10 | super.onAttachedToWindow()
11 |
12 | val textView = TextView(context)
13 | addView(textView)
14 |
15 | viewScope.launch {
16 | textView.text = textContent
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/application/TestApplication.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application
2 |
3 | import app.boletinhos.application.injection.AppComponent
4 | import app.boletinhos.application.injection.DaggerTestAppComponent
5 |
6 | class TestApplication : MainApplication() {
7 | private lateinit var appComponent: AppComponent
8 |
9 | override fun onCreate() {
10 | super.onCreate()
11 | appComponent = DaggerTestAppComponent.factory()
12 | .create(this)
13 | }
14 |
15 | override fun appComponent(): AppComponent {
16 | return appComponent
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/PayBill.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import app.boletinhos.domain.bill.error.BillsIsAlreadyPaidException
4 | import java.time.LocalDate
5 | import javax.inject.Inject
6 |
7 | class PayBill @Inject constructor(private val gateway: BillGateway) {
8 | suspend operator fun invoke(bill: Bill) {
9 | if (bill.isPaid()) throw BillsIsAlreadyPaidException
10 |
11 | val billToPay = bill.copy(
12 | status = BillStatus.PAID,
13 | paymentDate = LocalDate.now()
14 | )
15 |
16 | billToPay.id = bill.id
17 | gateway.pay(billToPay)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/android-ui/src/test/java/app/boletinhos/testutil/TestKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.os.Parcelable
4 | import com.zhuinden.simplestack.navigator.DefaultViewKey
5 | import com.zhuinden.simplestack.navigator.ViewChangeHandler
6 | import com.zhuinden.simplestack.navigator.changehandlers.NoOpViewChangeHandler
7 | import kotlinx.android.parcel.Parcelize
8 |
9 | @Parcelize
10 | open class TestKey : DefaultViewKey, Parcelable {
11 | override fun layout(): Int = 0
12 |
13 | override fun viewChangeHandler(): ViewChangeHandler {
14 | return NoOpViewChangeHandler()
15 | }
16 |
17 | override fun describeContents(): Int = 0
18 | }
19 |
--------------------------------------------------------------------------------
/preferences/src/main/java/app/boletinhos/preferences/injection/UserPreferencesModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.preferences.injection
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import common.AppContext
6 | import common.AppScope
7 | import dagger.Module
8 | import dagger.Provides
9 |
10 | @Module
11 | object UserPreferencesModule {
12 | private const val NAME = "app_prefs"
13 |
14 | @Provides
15 | @AppScope
16 | internal fun provideSharedPreferences(
17 | @AppContext context: Context
18 | ): SharedPreferences {
19 | return context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/WorkflowExampleTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos
2 |
3 | import androidx.test.core.app.ApplicationProvider
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import app.boletinhos.application.MainApplication
6 | import assertk.assertThat
7 | import assertk.assertions.isEqualTo
8 | import org.junit.Test
9 | import org.junit.runner.RunWith
10 |
11 | @RunWith(AndroidJUnit4::class)
12 | class WorkflowExampleTest {
13 | @Test fun testIsRunningBoletinhoApp() {
14 | val context = ApplicationProvider.getApplicationContext()
15 | assertThat(context.packageName).isEqualTo("app.boletinhos")
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/database/injection/AppDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.database.injection
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import app.boletinhos.database.AppDatabase
6 | import common.AppContext
7 | import common.AppScope
8 | import dagger.Module
9 | import dagger.Provides
10 |
11 | @Module
12 | object AppDatabaseModule {
13 | @Provides
14 | @AppScope
15 | internal fun provideAppDatabase(
16 | @AppContext context: Context
17 | ): AppDatabase {
18 | return Room
19 | .databaseBuilder(context, AppDatabase::class.java, AppDatabase.DATABASE_NAME)
20 | .build()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/navigation/ViewCoroutineScope.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.navigation
2 |
3 | import android.view.View
4 | import android.view.ViewGroup
5 | import app.boletinhos.core.R.id as Ids
6 | import kotlinx.coroutines.CoroutineScope
7 |
8 | private tailrec fun View.findTopMostParentWithCoroutineScope(): CoroutineScope {
9 | val scope = getTag(Ids.view_coroutine_scope) as CoroutineScope?
10 | return scope ?: checkNotNull(parent as ViewGroup) {
11 | "ViewCoroutineScope wasn't been set for this view tree hierarchy."
12 | }.findTopMostParentWithCoroutineScope()
13 | }
14 |
15 | val View.viewScope: CoroutineScope get() = findTopMostParentWithCoroutineScope()
16 |
--------------------------------------------------------------------------------
/android-core/src/test/java/app/boletinhos/lifecycle/FakeCoroutineScopeClient.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.async
5 | import kotlinx.coroutines.delay
6 | import kotlinx.coroutines.launch
7 |
8 | internal class FakeCoroutineScopeClient(
9 | coroutineScope: CoroutineScope
10 | ) : CoroutineScope by coroutineScope {
11 | fun job1() = launch { delay(100) }
12 |
13 | fun job2() = launch { delay(200) }
14 |
15 | fun job3() = launch { delay(300) }
16 |
17 | fun job4Async() = async { delay(999) }
18 |
19 | fun job5Async() = async { throw IllegalStateException("An expected error occurred. Bye.") }
20 | }
21 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/main/injection/ActivityRetainedServicesFactory.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.main.injection
2 |
3 | import com.zhuinden.simplestack.Backstack
4 | import com.zhuinden.simplestack.GlobalServices
5 | import com.zhuinden.simplestackextensions.servicesktx.add
6 | import javax.inject.Inject
7 |
8 | class ActivityRetainedServicesFactory @Inject constructor(
9 | private val service: ActivityRetainedService
10 | ) : GlobalServices.Factory {
11 | override fun create(backstack: Backstack): GlobalServices {
12 | service.createComponent(backstack)
13 |
14 | return GlobalServices.builder()
15 | .add(service)
16 | .build()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/bill/injection/BillServiceModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill.injection
2 |
3 | import app.boletinhos.database.AppDatabase
4 | import app.boletinhos.domain.bill.BillGateway
5 | import app.boletinhos.domain.bill.BillService
6 | import common.AppScope
7 | import dagger.Module
8 | import dagger.Provides
9 |
10 | @Module
11 | object BillServiceModule {
12 | @Provides
13 | @AppScope
14 | internal fun provideBillService(
15 | database: AppDatabase
16 | ): BillService = database.billService()
17 |
18 | @Provides
19 | @AppScope
20 | internal fun provideBillGateway(
21 | database: AppDatabase
22 | ): BillGateway = database.billGateway()
23 | }
24 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/time/LocalDateParser.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.time
2 |
3 | import app.boletinhos.widget.date.DateInput
4 | import java.time.DateTimeException
5 | import java.time.LocalDate
6 | import java.time.format.DateTimeFormatter
7 |
8 | val String.dateOrNull: LocalDate?
9 | get() = parseTextAsLocalDateInternal()
10 |
11 | private val defaultFormatter = DateTimeFormatter.ofPattern("d/MM/yyyy")
12 |
13 | private fun String.parseTextAsLocalDateInternal(): LocalDate? {
14 | if (isEmpty() || length < DateInput.MAX_INPUT_SIZE) return null
15 |
16 | return try {
17 | LocalDate.parse(this, defaultFormatter)
18 | } catch (e: DateTimeException) {
19 | null
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/preferences/src/main/java/app/boletinhos/preferences/UserPreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.preferences
2 |
3 | import android.content.SharedPreferences
4 | import androidx.core.content.edit
5 | import common.AppScope
6 | import javax.inject.Inject
7 |
8 | @AppScope
9 | class UserPreferences @Inject constructor(
10 | private val prefs: SharedPreferences
11 | ) {
12 | var isCrashReportEnabled: Boolean get() = prefs.getBoolean(CRASH_REPORTING_ENABLED, false)
13 | set(value) {
14 | prefs.edit {
15 | putBoolean(CRASH_REPORTING_ENABLED, value)
16 | }
17 | }
18 |
19 | companion object {
20 | private const val CRASH_REPORTING_ENABLED = "crash_reporting_enabled"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/TextInputMatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.view.View
4 | import androidx.test.espresso.matcher.BoundedMatcher
5 | import app.boletinhos.widget.text.TextInput
6 | import org.hamcrest.Description
7 | import org.hamcrest.Matcher
8 |
9 | fun textInputHasTextValue(text: String): Matcher {
10 | return object : BoundedMatcher(TextInput::class.java) {
11 | override fun matchesSafely(item: TextInput): Boolean {
12 | return text == item.value.trim()
13 | }
14 |
15 | override fun describeTo(description: Description?) {
16 | description?.appendText("has text $text in TextInput")
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/bill/InDatabaseBillService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Query
5 | import app.boletinhos.domain.bill.Bill
6 | import app.boletinhos.domain.bill.BillService
7 | import app.boletinhos.domain.bill.BillStatus
8 | import kotlinx.coroutines.flow.Flow
9 |
10 | @Dao internal interface InDatabaseBillService : BillService {
11 | @Query("SELECT * FROM bills WHERE id = :id")
12 | override suspend fun getById(id: Long): Bill
13 |
14 | @Query("SELECT * FROM bills")
15 | override fun getAll(): Flow>
16 |
17 | @Query("SELECT * FROM bills WHERE status = :status")
18 | override fun getByStatus(status: BillStatus): Flow>
19 | }
20 |
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/bill/FakeBillGateway.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | class FakeBillGateway : BillGateway {
4 | private val _bills = mutableMapOf()
5 | val bills: Map = _bills
6 |
7 | override suspend fun create(bill: Bill) {
8 | if (bills.keys.contains(bill.id)) {
9 | throw IllegalStateException("Bill already exists.")
10 | }
11 |
12 | _bills += bill.id to bill
13 | }
14 |
15 | override suspend fun pay(bill: Bill) {
16 | if (!bills.keys.contains(bill.id)) {
17 | throw NullPointerException("This bill does not exists. We cant mark it as paid.")
18 | }
19 |
20 | _bills[bill.id] = bill
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/welcome/WelcomeView.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.widget.LinearLayout
6 | import app.boletinhos.databinding.WelcomeViewBinding
7 | import app.boletinhos.ext.view.service
8 |
9 | class WelcomeView(context: Context, attrs: AttributeSet? = null): LinearLayout(context, attrs) {
10 | private val binding by lazy { WelcomeViewBinding.bind(this) }
11 | private val viewModel by service()
12 |
13 | override fun onAttachedToWindow() {
14 | super.onAttachedToWindow()
15 | binding.actionAddBill.setOnClickListener {
16 | viewModel.onAddBillClick()
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_add.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
14 |
15 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/testutil/CoroutineTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import kotlinx.coroutines.cancelAndJoin
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.collect
6 | import kotlinx.coroutines.launch
7 | import org.junit.Rule
8 | import testutil.MainCoroutineRule
9 | import testutil.runBlocking
10 |
11 | abstract class CoroutineTest {
12 | @get:Rule val mainCoroutineRule = MainCoroutineRule()
13 |
14 | fun Flow.test(block: (actual: T) -> Unit) {
15 | mainCoroutineRule.runBlocking { scope ->
16 | scope.launch {
17 | collect { actual ->
18 | block(actual)
19 | }
20 | }.cancelAndJoin()
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/DateInputMatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.view.View
4 | import androidx.test.espresso.matcher.BoundedMatcher
5 | import app.boletinhos.widget.date.DateInput
6 | import org.hamcrest.Description
7 | import org.hamcrest.Matcher
8 | import java.time.LocalDate
9 |
10 | fun dateInputHasDate(date: LocalDate?) : Matcher {
11 | return object : BoundedMatcher(DateInput::class.java) {
12 | override fun matchesSafely(item: DateInput): Boolean {
13 | return date == item.date
14 | }
15 |
16 | override fun describeTo(description: Description?) {
17 | description?.appendText("date is $date in DateInput")
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/CurrencyInputMatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.view.View
4 | import androidx.test.espresso.matcher.BoundedMatcher
5 | import app.boletinhos.widget.currency.CurrencyInput
6 | import org.hamcrest.Description
7 | import org.hamcrest.Matcher
8 |
9 | fun currencyInputHasRawValue(value: Long) : Matcher {
10 | return object : BoundedMatcher(CurrencyInput::class.java) {
11 | override fun matchesSafely(item: CurrencyInput): Boolean {
12 | return value == item.rawValue
13 | }
14 |
15 | override fun describeTo(description: Description?) {
16 | description?.appendText("has value $value in CurrencyInput")
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/navigation/ViewKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.navigation
2 |
3 | import android.os.Parcelable
4 | import com.zhuinden.simplestack.navigator.DefaultViewKey
5 | import com.zhuinden.simplestack.navigator.ViewChangeHandler
6 | import com.zhuinden.simplestack.navigator.changehandlers.FadeViewChangeHandler
7 |
8 | @SuppressWarnings("MagicNumber")
9 | interface ViewKey : DefaultViewKey, Parcelable {
10 | override fun viewChangeHandler(): ViewChangeHandler {
11 | return FadeViewChangeHandler()
12 | }
13 |
14 | interface ModalBottomSheet : ViewKey {
15 | val dimAmount: Float get() = 0.8f
16 |
17 | override fun viewChangeHandler(): ViewChangeHandler {
18 | return ModalBottomSheetChangeHandler()
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/summary/UserSummaryPreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.content.SharedPreferences
4 | import androidx.core.content.edit
5 | import app.boletinhos.domain.summary.SummaryPreferences
6 | import app.boletinhos.domain.summary.SummaryPreferences.Companion.ACTUAL_SUMMARY_ID
7 | import javax.inject.Inject
8 |
9 | @common.AppScope
10 | internal class UserSummaryPreferences @Inject constructor(
11 | private val prefs: SharedPreferences
12 | ) : SummaryPreferences {
13 | override fun actualSummaryId(): Long {
14 | return prefs.getLong(ACTUAL_SUMMARY_ID, -1)
15 | }
16 |
17 | override fun actualSummary(id: Long) {
18 | prefs.edit {
19 | putLong(ACTUAL_SUMMARY_ID, id)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_check.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
14 |
15 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/summary/injection/SummaryServiceModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary.injection
2 |
3 | import app.boletinhos.database.AppDatabase
4 | import app.boletinhos.domain.summary.SummaryPreferences
5 | import app.boletinhos.domain.summary.SummaryService
6 | import app.boletinhos.summary.UserSummaryPreferences
7 | import common.AppScope
8 | import dagger.Module
9 | import dagger.Provides
10 |
11 | @Module
12 | object SummaryServiceModule {
13 | @Provides
14 | @AppScope
15 | internal fun provideSummaryService(
16 | database: AppDatabase
17 | ): SummaryService = database.summaryService()
18 |
19 | @Provides
20 | internal fun provideSummaryPreferences(
21 | userSummaryPreferences: UserSummaryPreferences
22 | ): SummaryPreferences = userSummaryPreferences
23 | }
24 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/welcome/WelcomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import app.boletinhos.bill.add.AddBillViewKey
4 | import app.boletinhos.bill.add.AddBillViewModel
5 | import app.boletinhos.summary.SummaryViewKey
6 | import com.zhuinden.simplestack.Backstack
7 | import com.zhuinden.simplestack.History
8 | import com.zhuinden.simplestack.StateChange
9 | import javax.inject.Inject
10 |
11 | class WelcomeViewModel @Inject constructor(
12 | private val backStack: Backstack
13 | ) : AddBillViewModel.OnBillCreatedListener {
14 | override fun onBillCreated() {
15 | backStack.setHistory(
16 | History.single(SummaryViewKey()),
17 | StateChange.REPLACE
18 | )
19 | }
20 |
21 | fun onAddBillClick() {
22 | backStack.goTo(AddBillViewKey())
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/android-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/preferences/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/navigation/StateChange.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.navigation
2 |
3 | import app.boletinhos.navigation.ViewKey.ModalBottomSheet
4 | import com.zhuinden.simplestack.StateChange
5 |
6 | internal val StateChange.newKey get() = topNewKey()
7 |
8 | internal val StateChange.previousKey get() = topPreviousKey()
9 |
10 | internal val StateChange.isGoingBackward get() = direction == StateChange.BACKWARD
11 |
12 | internal val StateChange.isGoingForward get() = direction in StateChange.REPLACE..StateChange.FORWARD
13 |
14 | internal val StateChange.isNavigatingFromBottomSheetToView
15 | get() = newKey !is ModalBottomSheet && previousKey is ModalBottomSheet
16 |
17 | internal val StateChange.isNavigatingFromViewToBottomSheet
18 | get() = isGoingBackward && (previousKey !is ModalBottomSheet && newKey is ModalBottomSheet)
19 |
20 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/recyclerview/VerticalMarginDecoration.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.recyclerview
2 |
3 | import android.graphics.Rect
4 | import android.view.View
5 | import androidx.annotation.Dimension
6 | import androidx.recyclerview.widget.RecyclerView
7 |
8 | open class VerticalMarginDecoration(
9 | @Dimension private val margin: Int
10 | ) : RecyclerView.ItemDecoration() {
11 | override fun getItemOffsets(
12 | outRect: Rect,
13 | view: View,
14 | parent: RecyclerView,
15 | state: RecyclerView.State
16 | ) {
17 | val viewPosition = parent.getChildLayoutPosition(view)
18 |
19 | val top = if(viewPosition == 0) margin else 0
20 | val bottom = margin
21 |
22 | val start = margin
23 | val end = margin
24 |
25 | outRect.set(start, top, end, bottom)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/error/ErrorView.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.error
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.widget.LinearLayout
6 | import app.boletinhos.databinding.ErrorViewBinding
7 | import app.boletinhos.ext.view.inflater
8 |
9 | class ErrorView(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) {
10 | init {
11 | orientation = VERTICAL
12 | ErrorViewBinding.inflate(inflater, this)
13 | }
14 |
15 | private val viewBinding = ErrorViewBinding.bind(this)
16 |
17 | fun bindWith(model: ErrorViewModel, onTryAgain: () -> Unit) = viewBinding.run {
18 | textErrorTitle.setText(model.titleRes)
19 | textErrorMessage.setText(model.messageRes)
20 | actionTryAgain.setOnClickListener {
21 | onTryAgain()
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/crashcat/CrashCat.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.crashcat
2 |
3 | import app.boletinhos.preferences.UserPreferences
4 | import com.google.firebase.crashlytics.FirebaseCrashlytics
5 | import javax.inject.Inject
6 |
7 | /*
8 | * CrashCat is utility class to configure crashlytics
9 | * crashs collection based on user preferences.
10 | *
11 | * 'CrashCat' class name is based on `DataDog`. If they're a dog, we're a cat.
12 | * This class doesn't have much responsibilities, but in the future it can be a wrap
13 | * over Crashlytics and any other tool for analytics.
14 | */
15 | class CrashCat @Inject constructor(
16 | private val userPreferences: UserPreferences,
17 | private val crashlytics: FirebaseCrashlytics
18 | ) {
19 | fun configure() {
20 | crashlytics.setCrashlyticsCollectionEnabled(userPreferences.isCrashReportEnabled)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/bill/add/AddBillViewKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill.add
2 |
3 | import app.boletinhos.main.injection.activityRetainedComponent
4 | import app.boletinhos.navigation.ViewKey
5 | import com.zhuinden.simplestack.ServiceBinder
6 | import com.zhuinden.simplestackextensions.services.DefaultServiceProvider.HasServices
7 | import com.zhuinden.simplestackextensions.servicesktx.add
8 | import kotlinx.android.parcel.Parcelize
9 | import app.boletinhos.R.layout as Layouts
10 |
11 | @Parcelize
12 | class AddBillViewKey : ViewKey, HasServices {
13 | override fun layout(): Int = Layouts.bill_add
14 |
15 | override fun getScopeTag(): String = javaClass.name
16 |
17 | override fun bindServices(serviceBinder: ServiceBinder) {
18 | val component = serviceBinder.activityRetainedComponent
19 | serviceBinder.add(component.addBillViewModel())
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/ext/view/ViewExt.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.ext.view
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import androidx.annotation.StringRes
6 | import com.zhuinden.simplestackextensions.navigatorktx.backstack
7 | import com.zhuinden.simplestackextensions.servicesktx.lookup
8 |
9 | inline fun View.service(serviceTag: String = T::class.java.name): Lazy {
10 | return lazy(LazyThreadSafetyMode.NONE) {
11 | return@lazy backstack.lookup(serviceTag)
12 | }
13 | }
14 |
15 | val View.inflater: LayoutInflater get() = LayoutInflater.from(context)
16 |
17 | fun View.getString(@StringRes resId: Int): String {
18 | return context.getString(resId)
19 | }
20 |
21 | @Suppress("SpreadOperator")
22 | fun View.getString(@StringRes resId: Int, vararg value: Any): String {
23 | return context.getString(resId, *value)
24 | }
25 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/welcome/WelcomeViewKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import app.boletinhos.main.injection.activityRetainedComponent
4 | import app.boletinhos.navigation.ViewKey
5 | import com.zhuinden.simplestack.ServiceBinder
6 | import com.zhuinden.simplestackextensions.services.DefaultServiceProvider
7 | import com.zhuinden.simplestackextensions.servicesktx.add
8 | import kotlinx.android.parcel.Parcelize
9 | import app.boletinhos.R.layout as Layouts
10 |
11 | @Parcelize
12 | class WelcomeViewKey : ViewKey, DefaultServiceProvider.HasServices {
13 | override fun layout(): Int = Layouts.welcome_view
14 |
15 | override fun getScopeTag() = javaClass.name
16 |
17 | override fun bindServices(serviceBinder: ServiceBinder) {
18 | val component = serviceBinder.activityRetainedComponent
19 | serviceBinder.add(component.welcomeViewModel())
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/lifecycle/ActivityRetainedCoroutineScope.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import common.ActivityRetainedScope
4 | import common.IoDispatcher
5 | import kotlinx.coroutines.CoroutineDispatcher
6 | import kotlinx.coroutines.SupervisorJob
7 | import javax.inject.Inject
8 | import kotlin.coroutines.CoroutineContext
9 |
10 | @ActivityRetainedScope
11 | internal class ActivityRetainedCoroutineScope @Inject constructor(
12 | @common.ImmediateDispatcher dispatcher: CoroutineDispatcher,
13 | @common.MainDispatcher mainDispatcher: CoroutineDispatcher,
14 | @common.IoDispatcher ioDispatcher: CoroutineDispatcher
15 | ): LifecycleAwareCoroutineScope {
16 | override val coroutineContext: CoroutineContext = SupervisorJob() + dispatcher
17 |
18 | override val io: CoroutineDispatcher = ioDispatcher
19 | override val main: CoroutineDispatcher = mainDispatcher
20 | }
21 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryViewKey.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import app.boletinhos.R
4 | import app.boletinhos.main.injection.activityRetainedComponent
5 | import app.boletinhos.navigation.ViewKey
6 | import com.zhuinden.simplestack.ServiceBinder
7 | import com.zhuinden.simplestackextensions.services.DefaultServiceProvider
8 | import com.zhuinden.simplestackextensions.servicesktx.add
9 | import kotlinx.android.parcel.Parcelize
10 |
11 | @Parcelize
12 | class SummaryViewKey : ViewKey, DefaultServiceProvider.HasServices {
13 | override fun layout() = R.layout.summary_view
14 |
15 | override fun getScopeTag() = javaClass.name
16 |
17 | override fun bindServices(serviceBinder: ServiceBinder) {
18 | val activityRetainedComponent = serviceBinder.activityRetainedComponent
19 | serviceBinder.add(activityRetainedComponent.summaryViewModel())
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/typeconverter/LocalDateTypeConverter.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.typeconverter
2 |
3 | import androidx.room.TypeConverter
4 | import java.time.LocalDate
5 | import java.time.Month
6 | import java.time.format.DateTimeFormatter
7 |
8 | object LocalDateTypeConverter {
9 | private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
10 |
11 | @TypeConverter
12 | @JvmStatic
13 | fun toLocalDateTime(value: String?): LocalDate? {
14 | if (value == null) return null
15 | return formatter.parse(value, LocalDate::from)
16 | }
17 |
18 | @TypeConverter
19 | @JvmStatic
20 | fun fromLocalDateTime(date: LocalDate?) = date?.format(formatter)
21 |
22 | @TypeConverter
23 | @JvmStatic
24 | fun toMonth(value: String?): Month? {
25 | val month = value?.toIntOrNull() ?: return null
26 | return Month.of(month)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/Bill.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import java.time.LocalDate
4 |
5 | data class Bill(
6 | val name: String,
7 | val description: String,
8 | val value: Long,
9 | val paymentDate: LocalDate?,
10 | val dueDate: LocalDate,
11 | val status: BillStatus
12 | ) {
13 | var id: Long = 0L
14 |
15 | fun isOverdue(): Boolean {
16 | if (isPaid()) return false
17 | return dueDate.isBefore(LocalDate.now())
18 | }
19 |
20 | fun isPaid() = paymentDate != null
21 |
22 | companion object {
23 | const val MINIMUM_VALUE = 10_00L /* 10 */
24 | const val MAXIMUM_VALUE = 250_000_00L /* 250k */
25 | const val MINIMUM_NAME_COUNT = 5
26 | const val MAXIMUM_NAME_COUNT = 48
27 | const val MINIMUM_DESCRIPTION_COUNT = 8
28 | const val MAXIMUM_DESCRIPTION_COUNT = 148
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/currency/CurrencyMachineTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.currency
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isEqualTo
5 | import org.junit.Test
6 | import java.util.Locale
7 |
8 | class CurrencyMachineTest {
9 | @Test fun `should format raw value and keep currency symbol`() {
10 | val expected = "$250,000.00"
11 | val rawValue = 250_000_00L
12 | val actual = CurrencyMachine.formatFromRawValue(rawValue = rawValue, locale = Locale.US)
13 |
14 | assertThat(actual).isEqualTo(expected)
15 | }
16 |
17 | @Test fun `should format from raw value and remove currency symbol`() {
18 | val expected = "250.000,00" /* ptbr */
19 | val rawValue = 250_000_00L
20 | val actual = CurrencyMachine.formatFromRawValueAndRemoveSymbol(rawValue = rawValue, locale = Locale("pt", "Br"))
21 | assertThat(actual.trim()).isEqualTo(expected)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/rule/UsesDatabaseRule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.rule
2 |
3 | import androidx.test.core.app.ApplicationProvider
4 | import app.boletinhos.application.TestApplication
5 | import app.boletinhos.application.injection.TestAppComponent
6 | import app.boletinhos.database.AppDatabase
7 | import org.junit.rules.TestWatcher
8 | import org.junit.runner.Description
9 | import javax.inject.Inject
10 |
11 | class UsesDatabaseRule : TestWatcher() {
12 | @Inject lateinit var database: AppDatabase
13 |
14 | override fun starting(description: Description?) {
15 | super.starting(description)
16 | val component = (ApplicationProvider.getApplicationContext()
17 | .appComponent() as TestAppComponent)
18 |
19 | component.inject(this)
20 | }
21 |
22 | override fun finished(description: Description?) {
23 | super.finished(description)
24 | database.clearAllTables()
25 | }
26 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/main/injection/ActivityRetainedService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.main.injection
2 |
3 | import android.app.Application
4 | import app.boletinhos.application.MainApplication
5 | import com.zhuinden.simplestack.Backstack
6 | import com.zhuinden.simplestack.ScopedServices
7 | import kotlinx.coroutines.cancel
8 | import javax.inject.Inject
9 |
10 | class ActivityRetainedService @Inject constructor(
11 | private val application: Application
12 | ) : ScopedServices.Registered {
13 | lateinit var component: ActivityRetainedComponent
14 | private set
15 |
16 | fun createComponent(backstack: Backstack) {
17 | component = (application as MainApplication).appComponent()
18 | .activityRetainedComponentFactory()
19 | .create(backstack)
20 | }
21 |
22 | override fun onServiceRegistered() = Unit
23 |
24 | override fun onServiceUnregistered() {
25 | component.coroutineScope().cancel()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/text/TextInput.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.text
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import app.boletinhos.core.databinding.TextInputContentBinding
6 | import app.boletinhos.ext.view.inflater
7 | import com.google.android.material.textfield.TextInputLayout
8 |
9 | open class TextInput(
10 | context: Context,
11 | attrs: AttributeSet? = null
12 | ) : TextInputLayout(context, attrs) {
13 | internal lateinit var inputBinding: TextInputContentBinding
14 | private set
15 |
16 | open var value: String
17 | get() = inputBinding.input.text?.toString().orEmpty()
18 | set(value) = setTextInternal(value)
19 |
20 | override fun onAttachedToWindow() {
21 | super.onAttachedToWindow()
22 | inputBinding = TextInputContentBinding.inflate(inflater, this, true)
23 | }
24 |
25 | private fun setTextInternal(value: String) {
26 | inputBinding.input.setText(value)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/error/BillValidationErrorType.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill.error
2 |
3 | import app.boletinhos.domain.bill.Bill.Companion.MAXIMUM_DESCRIPTION_COUNT
4 | import app.boletinhos.domain.bill.Bill.Companion.MAXIMUM_NAME_COUNT
5 | import app.boletinhos.domain.bill.Bill.Companion.MAXIMUM_VALUE
6 | import app.boletinhos.domain.bill.Bill.Companion.MINIMUM_DESCRIPTION_COUNT
7 | import app.boletinhos.domain.bill.Bill.Companion.MINIMUM_NAME_COUNT
8 | import app.boletinhos.domain.bill.Bill.Companion.MINIMUM_VALUE
9 |
10 | enum class BillValidationErrorType(val rawValue: Long) {
11 | VALUE_MIN_REQUIRED(rawValue = MINIMUM_VALUE),
12 | VALUE_MAX_EXCEEDED(rawValue = MAXIMUM_VALUE),
13 | NAME_MIN_REQUIRED(rawValue = MINIMUM_NAME_COUNT.toLong()),
14 | NAME_MAX_EXCEEDED(rawValue = MAXIMUM_NAME_COUNT.toLong()),
15 | DESCRIPTION_MIN_REQUIRED(rawValue = MINIMUM_DESCRIPTION_COUNT.toLong()),
16 | DESCRIPTION_MAX_EXCEEDED(rawValue = MAXIMUM_DESCRIPTION_COUNT.toLong())
17 | }
18 |
--------------------------------------------------------------------------------
/android-core/src/main/res/layout/modal_bottom_sheet.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
14 |
15 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/RecyclerViewMatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.view.View
4 | import androidx.recyclerview.widget.RecyclerView
5 | import androidx.test.espresso.matcher.BoundedMatcher
6 | import org.hamcrest.Description
7 | import org.hamcrest.Matcher
8 |
9 | fun atPositionOnView(
10 | position: Int,
11 | target: Int,
12 | matcher: Matcher
13 | ): Matcher? {
14 | return object : BoundedMatcher(RecyclerView::class.java) {
15 | override fun describeTo(description: Description) {
16 | description.appendText("has view id $matcher at position $position")
17 | }
18 |
19 | override fun matchesSafely(recyclerView: RecyclerView): Boolean {
20 | val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
21 | val targetView: View = viewHolder!!.itemView.findViewById(target)
22 | return matcher.matches(targetView)
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/application/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application
2 |
3 | import android.app.Application
4 | import androidx.appcompat.app.AppCompatDelegate
5 | import app.boletinhos.application.injection.AppComponent
6 | import app.boletinhos.application.injection.DaggerAppComponent
7 | import app.boletinhos.crashcat.CrashCat
8 | import javax.inject.Inject
9 |
10 | open class MainApplication : Application() {
11 | private lateinit var component: AppComponent
12 |
13 | private fun injector(): AppComponent {
14 | return DaggerAppComponent.factory()
15 | .create(this)
16 | .also { component -> component.inject(this) }
17 | }
18 |
19 | @Inject lateinit var crashCat: CrashCat
20 |
21 | override fun onCreate() {
22 | super.onCreate()
23 | AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
24 |
25 | component = injector()
26 | crashCat.configure()
27 | }
28 |
29 | open fun appComponent() = component
30 | }
31 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/summary/InDatabaseSummaryService.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Query
5 | import app.boletinhos.domain.summary.Summary
6 | import app.boletinhos.domain.summary.SummaryService
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | @Dao internal interface InDatabaseSummaryService : SummaryService {
10 | @Query(
11 | """
12 | SELECT
13 | strftime('%m', dueDate) AS month,
14 | strftime('%Y', dueDate) AS year,
15 | SUM(value) AS totalValue,
16 | SUM(status = 'PAID') AS paids,
17 | SUM(status = 'UNPAID') AS unpaids,
18 | SUM(status = 'OVERDUE') AS overdue
19 | FROM bills
20 | GROUP BY strftime('%m-%Y', dueDate)
21 | ORDER BY dueDate DESC
22 | """
23 | )
24 | override fun getSummaries(): Flow>
25 |
26 | @Query("SELECT EXISTS(SELECT * FROM bills)")
27 | override suspend fun hasSummary(): Boolean
28 | }
29 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_hourglass.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
12 |
18 |
19 |
23 |
24 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/error_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
18 |
19 |
27 |
--------------------------------------------------------------------------------
/bills-service/src/main/java/app/boletinhos/database/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.database
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.room.TypeConverters
6 | import app.boletinhos.bill.BillEntity
7 | import app.boletinhos.bill.InDatabaseBillGateway
8 | import app.boletinhos.bill.InDatabaseBillService
9 | import app.boletinhos.summary.InDatabaseSummaryService
10 | import app.boletinhos.typeconverter.BillStatusTypeConverter
11 | import app.boletinhos.typeconverter.LocalDateTypeConverter
12 |
13 | @Database(entities = [BillEntity::class], version = 1)
14 | @TypeConverters(value = [LocalDateTypeConverter::class, BillStatusTypeConverter::class])
15 | abstract class AppDatabase : RoomDatabase() {
16 | internal abstract fun billService(): InDatabaseBillService
17 | internal abstract fun billGateway(): InDatabaseBillGateway
18 | internal abstract fun summaryService(): InDatabaseSummaryService
19 |
20 | companion object {
21 | const val DATABASE_NAME = "boletinhos-db"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/application/injection/TestDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application.injection
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import app.boletinhos.database.AppDatabase
6 | import common.AppContext
7 | import common.AppScope
8 | import common.MainDispatcher
9 | import dagger.Module
10 | import dagger.Provides
11 | import kotlinx.coroutines.CoroutineDispatcher
12 | import kotlinx.coroutines.asExecutor
13 |
14 | @Module
15 | object TestDatabaseModule {
16 | @Provides
17 | @AppScope
18 | internal fun provideAppDatabase(
19 | @AppContext context: Context,
20 | @MainDispatcher coroutineDispatcher: CoroutineDispatcher
21 | ): AppDatabase {
22 | return Room
23 | .inMemoryDatabaseBuilder(context, AppDatabase::class.java)
24 | .setQueryExecutor(coroutineDispatcher.asExecutor())
25 | .setTransactionExecutor(coroutineDispatcher.asExecutor())
26 | .allowMainThreadQueries()
27 | .build()
28 | }
29 | }
--------------------------------------------------------------------------------
/android-core/build.gradle:
--------------------------------------------------------------------------------
1 | import config.Androidx
2 | import config.DI
3 | import config.Kotlinx
4 | import config.SimpleStack
5 | import config.Testing
6 | import config.UI
7 |
8 | apply plugin: 'com.android.library'
9 | apply from: "$rootDir/build-system/android.gradle"
10 |
11 | apply plugin: 'kotlin-kapt'
12 |
13 | android {
14 | kotlinOptions {
15 | // ...
16 | freeCompilerArgs += [
17 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
18 | "-Xuse-experimental=kotlinx.coroutines.ObsoleteCoroutinesApi"
19 | ]
20 | }
21 | }
22 |
23 | repositories {
24 | maven { url "https://jitpack.io" }
25 | }
26 |
27 | dependencies {
28 | api(project(":common"), project(":domain"))
29 |
30 | api(Kotlinx.Coroutines.android)
31 | api(SimpleStack.navigator, SimpleStack.servicesKtx)
32 |
33 | implementation(UI.material)
34 | implementation(Androidx.lifeCycle)
35 |
36 | implementation(DI.dagger)
37 | kapt(DI.compiler)
38 |
39 | testImplementation(Kotlinx.Coroutines.test, Testing.mockK)
40 | }
--------------------------------------------------------------------------------
/bills-service/build.gradle:
--------------------------------------------------------------------------------
1 | import config.Androidx
2 | import config.DI
3 | import config.Kotlinx
4 | import config.Testing
5 |
6 | apply plugin: 'com.android.library'
7 | apply from: "$rootDir/build-system/android.gradle"
8 |
9 | apply plugin: 'kotlin-kapt'
10 |
11 | android {
12 | kotlinOptions {
13 | // ...
14 | freeCompilerArgs += [
15 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
16 | "-Xuse-experimental=kotlinx.coroutines.ObsoleteCoroutinesApi"
17 | ]
18 | }
19 | }
20 |
21 | dependencies {
22 | api(
23 | project(":domain"),
24 | project(":common")
25 | )
26 |
27 | api(Kotlinx.Coroutines.android)
28 |
29 | implementation(Androidx.Room.core, DI.dagger)
30 | kapt(Androidx.Room.compiler, DI.compiler)
31 |
32 | testImplementation(
33 | Androidx.Room.testing,
34 | Kotlinx.Coroutines.test,
35 | Testing.robolectric
36 | )
37 | }
38 |
39 | kapt {
40 | arguments {
41 | arg("room.schemaLocation", "$projectDir/schemas")
42 | }
43 | }
--------------------------------------------------------------------------------
/android-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
23 |
24 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/summary/UserSummaryPreferencesTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import app.boletinhos.domain.summary.Summary
4 | import app.boletinhos.domain.summary.SummaryPreferences
5 | import assertk.assertThat
6 | import assertk.assertions.isEqualTo
7 | import org.junit.After
8 | import org.junit.Test
9 | import java.time.Month
10 |
11 | class UserSummaryPreferencesTest {
12 | private val preferences = FakePreferences
13 | private val summaryPreferences = UserSummaryPreferences(preferences)
14 |
15 | @After fun tearDown() {
16 | preferences.edit().clear().apply()
17 | }
18 |
19 | @Test fun `should return NO_SUMMARY id if there is no summary marked as actual`() {
20 | assertThat(summaryPreferences.actualSummaryId()).isEqualTo(SummaryPreferences.NO_SUMMARY)
21 | }
22 |
23 | @Test fun `should return actual summary ID`() {
24 | val id = Summary.idFrom(month = Month.FEBRUARY.value, year = 2019)
25 | summaryPreferences.actualSummary(id)
26 | assertThat(summaryPreferences.actualSummaryId()).isEqualTo(id)
27 | }
28 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/application/injection/TestAppComponent.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application.injection
2 |
3 | import android.app.Application
4 | import app.boletinhos.bill.add.AddBillViewTest
5 | import app.boletinhos.bill.injection.BillServiceModule
6 | import app.boletinhos.rule.UsesDatabaseRule
7 | import app.boletinhos.summary.SummaryViewTest
8 | import app.boletinhos.summary.injection.SummaryServiceModule
9 | import app.boletinhos.welcome.WelcomeViewTest
10 | import common.AppScope
11 | import dagger.BindsInstance
12 | import dagger.Component
13 |
14 | @AppScope
15 | @Component(modules = [
16 | TestModule::class,
17 | TestDatabaseModule::class,
18 | SummaryServiceModule::class,
19 | BillServiceModule::class
20 | ])
21 | interface TestAppComponent : AppComponent {
22 | fun inject(test: SummaryViewTest)
23 | fun inject(rule: UsesDatabaseRule)
24 | fun inject(test: WelcomeViewTest)
25 | fun inject(test: AddBillViewTest)
26 |
27 | @Component.Factory interface Factory {
28 | fun create(@BindsInstance app: Application): TestAppComponent
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/animator-v21/appbar_state_list_animator.xml:
--------------------------------------------------------------------------------
1 |
3 | -
7 |
8 |
13 |
14 |
15 |
16 | -
17 |
18 |
23 |
24 |
25 |
26 | -
27 |
28 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable-anydpi-v23/ic_barcode.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
13 |
14 |
17 |
18 |
21 |
22 |
25 |
26 |
29 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_calendar.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
12 |
18 |
19 |
23 |
24 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_summary.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
12 |
18 |
19 |
23 |
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Lucas
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/theming/ThemeAwareDrawable.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.theming
2 |
3 | import android.content.Context
4 | import android.graphics.drawable.Drawable
5 | import android.os.Build
6 | import android.util.TypedValue
7 | import androidx.annotation.AttrRes
8 | import androidx.annotation.ColorInt
9 | import androidx.annotation.DrawableRes
10 | import androidx.core.content.ContextCompat
11 | import androidx.core.graphics.drawable.DrawableCompat
12 | import app.boletinhos.R.attr as Attrs
13 |
14 | @ColorInt
15 | internal fun Context.getThemeAwareColor(@AttrRes colorAttr: Int): Int {
16 | val typedValue = TypedValue()
17 | theme.resolveAttribute(colorAttr, typedValue, true)
18 | return typedValue.data
19 | }
20 |
21 | fun Context.createThemeAwareDrawable(@DrawableRes drawableRes: Int): Drawable? {
22 | val drawable = ContextCompat.getDrawable(this, drawableRes)
23 |
24 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP || drawable == null) return drawable
25 |
26 | val wrapped = DrawableCompat.wrap(drawable)
27 | DrawableCompat.setTint(wrapped, getThemeAwareColor(Attrs.colorOnSurface))
28 |
29 | return wrapped
30 | }
31 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/application/injection/AppModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application.injection
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import app.boletinhos.main.injection.ActivityRetainedComponent
6 | import common.AppContext
7 | import common.ImmediateDispatcher
8 | import common.IoDispatcher
9 | import common.MainDispatcher
10 | import dagger.Module
11 | import dagger.Provides
12 | import kotlinx.coroutines.CoroutineDispatcher
13 | import kotlinx.coroutines.Dispatchers
14 |
15 | @Module(subcomponents = [ActivityRetainedComponent::class])
16 | object AppModule {
17 | @Provides
18 | @AppContext
19 | internal fun provideContext(app: Application): Context = app
20 |
21 | /* rework -> multibinding */
22 | @Provides
23 | @ImmediateDispatcher
24 | internal fun provideImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
25 |
26 | @Provides
27 | @MainDispatcher
28 | internal fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
29 |
30 | @Provides
31 | @IoDispatcher
32 | internal fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
33 | }
34 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryDecoration.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.graphics.Rect
4 | import android.view.View
5 | import androidx.annotation.Dimension
6 | import androidx.recyclerview.widget.RecyclerView
7 | import app.boletinhos.widget.recyclerview.VerticalMarginDecoration
8 | import app.boletinhos.R.dimen as Dimens
9 |
10 | class SummaryDecoration(
11 | @Dimension private val margin: Int
12 | ) : VerticalMarginDecoration(margin) {
13 | override fun getItemOffsets(
14 | outRect: Rect,
15 | view: View,
16 | parent: RecyclerView,
17 | state: RecyclerView.State
18 | ) {
19 | super.getItemOffsets(outRect, view, parent, state)
20 |
21 | val viewPosition = parent.getChildLayoutPosition(view)
22 | if (viewPosition == 0) return
23 |
24 | val isEven = viewPosition % 2 == 1
25 |
26 | outRect.left = if (isEven) outRect.left else outRect.left / 2
27 | outRect.right = if (isEven) outRect.right / 2 else outRect.right
28 | }
29 | }
30 |
31 | fun SummaryView.summaryDecoration() = SummaryDecoration(
32 | resources.getDimensionPixelSize(Dimens.app_margin_2x)
33 | )
34 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
11 |
12 |
16 |
17 |
21 |
22 |
26 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/summary/Summary.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | import app.boletinhos.domain.currency.CurrencyMachine
4 | import java.io.Serializable
5 | import java.text.NumberFormat
6 | import java.time.Month
7 | import java.time.YearMonth
8 | import java.time.format.DateTimeFormatter
9 | import java.util.Locale
10 |
11 | data class Summary(
12 | val month: Month,
13 | val year: Int,
14 | val totalValue: Long,
15 | val paids: Int,
16 | val unpaids: Int,
17 | val overdue: Int
18 | ) : Serializable {
19 | /* one day: Move Summary to Room/SQLDelight */
20 | fun id(): Long = idFrom(month.value, year)
21 |
22 | fun displayName(locale: Locale = Locale.getDefault()): String {
23 | return YearMonth.of(year, month)
24 | .format(DateTimeFormatter.ofPattern("MMMM uuuu", locale))
25 | }
26 |
27 | fun formattedTotalValue(locale: Locale = Locale.getDefault()): String {
28 | return CurrencyMachine.formatFromRawValue(rawValue = totalValue, locale = locale)
29 | }
30 |
31 | companion object {
32 | fun idFrom(month: Int, year: Int): Long {
33 | return (month + year).toLong()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/testutil/src/main/java/testutil/MainCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | package testutil
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.ExperimentalCoroutinesApi
6 | import kotlinx.coroutines.test.TestCoroutineDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.runBlockingTest
9 | import kotlinx.coroutines.test.setMain
10 | import org.junit.rules.TestWatcher
11 | import org.junit.runner.Description
12 |
13 | class MainCoroutineRule(
14 | val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
15 | ): TestWatcher() {
16 | val testScope = CoroutineScope(testDispatcher)
17 |
18 | override fun starting(description: Description?) {
19 | super.starting(description)
20 | Dispatchers.setMain(testDispatcher)
21 | }
22 |
23 | override fun finished(description: Description?) {
24 | super.finished(description)
25 | Dispatchers.resetMain()
26 | testDispatcher.cleanupTestCoroutines()
27 | }
28 | }
29 |
30 | @ExperimentalCoroutinesApi
31 | fun MainCoroutineRule.runBlocking(block: suspend (CoroutineScope) -> Unit) = this.testDispatcher.runBlockingTest {
32 | block(this)
33 | }
34 |
--------------------------------------------------------------------------------
/android-ui/google-services.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_info": {
3 | "project_number": "76224829760",
4 | "firebase_url": "https://boletinhos-5b614.firebaseio.com",
5 | "project_id": "boletinhos-5b614",
6 | "storage_bucket": "boletinhos-5b614.appspot.com"
7 | },
8 | "client": [
9 | {
10 | "client_info": {
11 | "mobilesdk_app_id": "1:76224829760:android:68cb3200fdc22c6d2a42a5",
12 | "android_client_info": {
13 | "package_name": "app.boletinhos"
14 | }
15 | },
16 | "oauth_client": [
17 | {
18 | "client_id": "76224829760-tbe5ugl0atddg72r5bp9kph41o67eivj.apps.googleusercontent.com",
19 | "client_type": 3
20 | }
21 | ],
22 | "api_key": [
23 | {
24 | "current_key": "AIzaSyCd_4WeeWW2qT1BnQl8Y4ArdjCJEFOjSjA"
25 | }
26 | ],
27 | "services": {
28 | "appinvite_service": {
29 | "other_platform_oauth_client": [
30 | {
31 | "client_id": "76224829760-tbe5ugl0atddg72r5bp9kph41o67eivj.apps.googleusercontent.com",
32 | "client_type": 3
33 | }
34 | ]
35 | }
36 | }
37 | }
38 | ],
39 | "configuration_version": "1"
40 | }
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/typeconverter/LocalDateTypeConverterTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.typeconverter
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isEqualTo
5 | import org.junit.Test
6 | import java.time.LocalDate
7 | import java.time.Month
8 |
9 | class LocalDateTypeConverterTest {
10 | @Test fun `should parse to LocalDate from a given valid date`() {
11 | val expected = LocalDate.of(2020, Month.JANUARY, 30)
12 |
13 | val date = "2020-01-30"
14 |
15 | val actual = LocalDateTypeConverter.toLocalDateTime(date)
16 |
17 | assertThat(actual).isEqualTo(expected)
18 | }
19 |
20 | @Test fun `should format to text from a given LocalDate`() {
21 | val expected = "2020-01-30"
22 |
23 | val localDate = LocalDate.of(2020, Month.JANUARY, 30)
24 |
25 | val actual = LocalDateTypeConverter.fromLocalDateTime(localDate)
26 |
27 | assertThat(actual).isEqualTo(expected)
28 | }
29 |
30 | @Test fun `should convert to Month from a given integer`() {
31 | val expected = Month.DECEMBER
32 |
33 | val actual = LocalDateTypeConverter.toMonth(12.toString())
34 |
35 | assertThat(actual).isEqualTo(expected)
36 | }
37 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
--------------------------------------------------------------------------------
/preferences/src/test/java/app/boletinhos/preferences/UserPreferencesTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.preferences
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isFalse
5 | import assertk.assertions.isTrue
6 | import org.junit.After
7 | import org.junit.Before
8 | import org.junit.Test
9 |
10 | class UserPreferencesTest {
11 | private val preferences = FakePreferences
12 | private lateinit var userPreferences: UserPreferences
13 |
14 | @Before fun setUp() {
15 | userPreferences = UserPreferences(preferences)
16 | }
17 |
18 | @After fun tearDown() {
19 | preferences.edit().clear().apply()
20 | }
21 |
22 | @Test fun `should crash reports be disabled by default`() {
23 | assertThat(userPreferences.isCrashReportEnabled).isFalse()
24 | }
25 |
26 | @Test fun `should enable crash reports`() {
27 | userPreferences.isCrashReportEnabled = true
28 | assertThat(userPreferences.isCrashReportEnabled).isTrue()
29 | }
30 |
31 | @Test fun `should disable crash reports`() {
32 | userPreferences.isCrashReportEnabled = true
33 | userPreferences.isCrashReportEnabled = false
34 | assertThat(userPreferences.isCrashReportEnabled).isFalse()
35 | }
36 | }
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 4dp
7 |
8 |
11 |
12 |
15 |
16 |
17 |
21 |
22 |
23 |
27 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/TextInputLayoutMatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import android.view.View
4 | import androidx.test.espresso.matcher.BoundedMatcher
5 | import com.google.android.material.textfield.TextInputLayout
6 | import org.hamcrest.Description
7 | import org.hamcrest.Matcher
8 |
9 | fun inputLayoutHasPrefix(prefix: String): Matcher {
10 | return object : BoundedMatcher(TextInputLayout::class.java) {
11 | override fun matchesSafely(item: TextInputLayout): Boolean {
12 | return prefix == item.prefixText?.toString().orEmpty()
13 | }
14 |
15 | override fun describeTo(description: Description?) {
16 | description?.appendText("has prefix $prefix in TextInputLayout")
17 | }
18 | }
19 | }
20 |
21 | fun inputLayoutHasError(error: String?): Matcher {
22 | return object : BoundedMatcher(TextInputLayout::class.java) {
23 | override fun matchesSafely(item: TextInputLayout?): Boolean {
24 | return error == item?.error
25 | }
26 |
27 | override fun describeTo(description: Description?) {
28 | description?.appendText("has error $error in TextInputLayout")
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryAdapter.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.view.ContextThemeWrapper
4 | import android.view.ViewGroup
5 | import app.boletinhos.summary.SummaryItemCardView.Model
6 | import app.boletinhos.widget.recyclerview.BindableViewHolder
7 | import app.boletinhos.widget.recyclerview.ListAdapter
8 |
9 | class SummaryAdapter(
10 | private val onItemClick: (Model.Kind) -> Unit = {}
11 | ) : ListAdapter() {
12 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindableViewHolder {
13 | val themeRes = Model.Kind.values()
14 | .first { it.viewType == viewType }
15 | .themeRes
16 |
17 | val view = SummaryItemCardView(ContextThemeWrapper(parent.context, themeRes))
18 | return ViewHolder(view)
19 | }
20 |
21 | override fun getItemViewType(position: Int): Int = items[position].kind.viewType
22 |
23 | private inner class ViewHolder(
24 | private val view: SummaryItemCardView
25 | ) : BindableViewHolder(view) {
26 | override fun bind(model: Model) {
27 | view.bind(model)
28 |
29 | view.setOnClickListener {
30 | onItemClick(model.kind)
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
14 |
15 |
18 |
19 |
22 |
23 |
26 |
27 |
30 |
31 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/typeconverter/BillStatusTypeConverterTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.typeconverter
2 |
3 | import app.boletinhos.domain.bill.BillStatus
4 | import assertk.assertThat
5 | import assertk.assertions.isEqualTo
6 | import org.junit.Test
7 |
8 | class BillStatusTypeConverterTest {
9 | @Test fun `should convert to status from a given integer`() {
10 | // @given a bill status
11 | val expected = BillStatus.UNPAID
12 |
13 | // @and its expected value
14 | val code = BillStatus.UNPAID.name
15 |
16 | // @when converting to a status from a given code
17 | val actual = BillStatusTypeConverter.toStatus(code)
18 |
19 | // @then the converted result should be `UNPAID`
20 | assertThat(actual).isEqualTo(expected)
21 | }
22 |
23 | @Test fun `should convert to int from a given status`() {
24 | // @given a bill status code value
25 | val expected = BillStatus.UNPAID.name
26 |
27 | // @and its bill status
28 | val status = BillStatus.UNPAID
29 |
30 | // @when converting to a integer from a given status
31 | val actual = BillStatusTypeConverter.fromStatus(status)
32 |
33 | // @then the converted result should be `0`
34 | assertThat(actual).isEqualTo(expected)
35 | }
36 | }
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/date/DateInput.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.date
2 |
3 | import android.content.Context
4 | import android.text.InputFilter.LengthFilter
5 | import android.text.InputType
6 | import android.util.AttributeSet
7 | import app.boletinhos.time.dateOrNull
8 | import app.boletinhos.widget.text.TextInput
9 | import java.time.LocalDate
10 |
11 | class DateInput(
12 | context: Context,
13 | attrs: AttributeSet? = null
14 | ) : TextInput(context, attrs) {
15 | private val textWatcher = DateTextWatcher(this)
16 |
17 | internal val input get() = inputBinding.input
18 |
19 | val date: LocalDate? get() = value.dateOrNull
20 |
21 | override fun onAttachedToWindow() {
22 | super.onAttachedToWindow()
23 | configureTextInput()
24 | }
25 |
26 | override fun onDetachedFromWindow() {
27 | super.onDetachedFromWindow()
28 | input.removeTextChangedListener(textWatcher)
29 | }
30 |
31 | private fun configureTextInput() {
32 | input.addTextChangedListener(textWatcher)
33 | input.inputType = InputType.TYPE_CLASS_DATETIME
34 | input.maxLines = 1
35 | input.filters = arrayOf(LengthFilter(MAX_INPUT_SIZE))
36 | }
37 |
38 | companion object {
39 | const val MAX_INPUT_SIZE = 10
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/android-ui/src/test/java/app/boletinhos/welcome/WelcomeViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import app.boletinhos.bill.add.AddBillViewKey
4 | import app.boletinhos.navigation.ViewKey
5 | import app.boletinhos.summary.SummaryViewKey
6 | import assertk.assertThat
7 | import assertk.assertions.isInstanceOf
8 | import com.zhuinden.simplestack.Backstack
9 | import com.zhuinden.simplestack.History
10 | import org.junit.Before
11 | import org.junit.Test
12 |
13 | class WelcomeViewModelTest {
14 | private val backstack: Backstack = Backstack()
15 | private val viewModel: WelcomeViewModel = WelcomeViewModel(backstack)
16 |
17 | @Before fun setUp() {
18 | backstack.setScopedServices { }
19 | backstack.setup(History.single(WelcomeViewKey()))
20 | backstack.setStateChanger { _, completionCallback ->
21 | completionCallback.stateChangeComplete()
22 | }
23 | }
24 |
25 | @Test fun `should open add bill screen on add bill click`() {
26 | viewModel.onAddBillClick()
27 | assertThat(backstack.top()).isInstanceOf(AddBillViewKey::class.java)
28 | }
29 |
30 | @Test fun `should open summary after creating first bill`() {
31 | viewModel.onBillCreated()
32 | assertThat(backstack.top()).isInstanceOf(SummaryViewKey::class)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #333745
5 |
6 |
7 | #9370DB
8 | #269370DB
9 |
10 |
11 | #F49D37
12 | #26F49D37
13 |
14 |
15 | #FE5F55
16 | #26FE5F55
17 |
18 |
19 | @color/branding
20 | @color/branding
21 |
22 | #F9FCFE
23 | @android:color/white
24 |
25 | #B00020
26 |
27 |
28 | @android:color/white
29 |
30 | @color/branding
31 | #9e9e9e
32 |
33 | @android:color/white
34 |
35 | @android:color/white
36 |
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/application/injection/AppComponent.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application.injection
2 |
3 | import android.app.Application
4 | import app.boletinhos.application.MainApplication
5 | import app.boletinhos.bill.injection.BillServiceModule
6 | import app.boletinhos.crashcat.injection.CrashlyticsModule
7 | import app.boletinhos.database.injection.AppDatabaseModule
8 | import app.boletinhos.main.MainActivity
9 | import app.boletinhos.main.injection.ActivityRetainedComponent
10 | import app.boletinhos.preferences.injection.UserPreferencesModule
11 | import app.boletinhos.summary.injection.SummaryServiceModule
12 | import common.AppScope
13 | import dagger.BindsInstance
14 | import dagger.Component
15 |
16 | @AppScope
17 | @Component(
18 | modules = [
19 | AppModule::class,
20 | UserPreferencesModule::class,
21 | CrashlyticsModule::class,
22 | AppDatabaseModule::class,
23 | SummaryServiceModule::class,
24 | BillServiceModule::class
25 | ]
26 | )
27 | interface AppComponent {
28 | fun inject(app: MainApplication)
29 | fun inject(activity: MainActivity)
30 |
31 | fun activityRetainedComponentFactory(): ActivityRetainedComponent.Factory
32 |
33 | @Component.Factory
34 | interface Factory {
35 | fun create(@BindsInstance app: Application): AppComponent
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/shapes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 4dp
4 | 12dp
5 | 16dp
6 |
7 |
11 |
12 |
16 |
17 |
21 |
22 |
28 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/bill/InDatabaseBillGatewayTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill
2 |
3 | import app.boletinhos.domain.bill.BillStatus
4 | import app.boletinhos.fakes.BillsFactory
5 | import app.boletinhos.testutil.AppDatabaseTest
6 | import assertk.assertThat
7 | import assertk.assertions.isEqualTo
8 | import kotlinx.coroutines.test.runBlockingTest
9 | import org.junit.Test
10 | import java.time.LocalDate
11 |
12 | class InDatabaseBillGatewayTest : AppDatabaseTest() {
13 | @Test fun `should create new bill`() = runBlockingTest {
14 | val expected = BillsFactory
15 | .pick()
16 | .first()
17 |
18 | billGateway.create(expected)
19 |
20 | assertThat(billService.getById(id = 1)).isEqualTo(expected)
21 | }
22 |
23 | @Test fun `should mark a given bill as paid`() = runBlockingTest {
24 | val bill = BillsFactory
25 | .pick()
26 | .first()
27 | .copy(dueDate = LocalDate.now().minusMonths(1), status = BillStatus.OVERDUE)
28 |
29 | billGateway.create(bill)
30 | billGateway.create(bill.copy(name = "Legal"))
31 |
32 | val billToPay = billService.getById(id = 1)
33 | .copy(paymentDate = LocalDate.now(), status = BillStatus.PAID)
34 | .also { it.id = 1 }
35 |
36 | billGateway.pay(billToPay)
37 |
38 | assertThat(billService.getById(id = 1)).isEqualTo(billToPay)
39 | }
40 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/main/injection/ActivityRetainedComponent.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.main.injection
2 |
3 | import app.boletinhos.bill.add.AddBillViewModel
4 | import app.boletinhos.lifecycle.LifecycleAwareCoroutineScope
5 | import app.boletinhos.lifecycle.injection.LifecycleCoroutineScopeModule
6 | import app.boletinhos.summary.SummaryViewModel
7 | import app.boletinhos.welcome.WelcomeViewModel
8 | import app.boletinhos.welcome.injection.WelcomeModule
9 | import com.zhuinden.simplestack.Backstack
10 | import com.zhuinden.simplestack.ServiceBinder
11 | import com.zhuinden.simplestackextensions.servicesktx.lookup
12 | import common.ActivityRetainedScope
13 | import dagger.BindsInstance
14 | import dagger.Subcomponent
15 |
16 | @ActivityRetainedScope
17 | @Subcomponent(modules = [LifecycleCoroutineScopeModule::class, WelcomeModule::class])
18 | interface ActivityRetainedComponent {
19 | fun coroutineScope(): LifecycleAwareCoroutineScope
20 |
21 | fun summaryViewModel(): SummaryViewModel
22 | fun welcomeViewModel(): WelcomeViewModel
23 | fun addBillViewModel(): AddBillViewModel
24 |
25 | @Subcomponent.Factory
26 | interface Factory {
27 | fun create(@BindsInstance backstack: Backstack): ActivityRetainedComponent
28 | }
29 | }
30 |
31 | val Backstack.activityRetainedComponent get() = lookup().component
32 | val ServiceBinder.activityRetainedComponent get() = lookup().component
33 |
--------------------------------------------------------------------------------
/android-ui/src/debug/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
32 |
33 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.aar
4 | *.ap_
5 | *.aab
6 |
7 | # DS Store
8 | .DS_STORE
9 |
10 | # Files for the ART/Dalvik VM
11 | *.dex
12 |
13 | # Java class files
14 | *.class
15 |
16 | # Generated files
17 | bin/
18 | gen/
19 | out/
20 | # Uncomment the following line in case you need and you don't have the release build type files in your app
21 | # release/
22 |
23 | # Gradle files
24 | .gradle/
25 | build/
26 |
27 | # Local configuration file (sdk path, etc)
28 | local.properties
29 |
30 | # Proguard folder generated by Eclipse
31 | proguard/
32 |
33 | # Log Files
34 | *.log
35 |
36 | # Android Studio Navigation editor temp files
37 | .navigation/
38 |
39 | # Android Studio captures folder
40 | captures/
41 |
42 | # IntelliJ
43 | *.iml
44 | .idea/
45 |
46 | # Keystore files
47 | # Uncomment the following lines if you do not want to check your keystore files in.
48 | #*.jks
49 | #*.keystore
50 |
51 | # External native build folder generated in Android Studio 2.2 and later
52 | .externalNativeBuild
53 | .cxx/
54 |
55 | # Google Services (e.g. APIs or Firebase)
56 | # google-services.json
57 |
58 | # Freeline
59 | freeline.py
60 | freeline/
61 | freeline_project_description.json
62 |
63 | # fastlane
64 | fastlane/report.xml
65 | fastlane/Preview.html
66 | fastlane/screenshots
67 | fastlane/test_output
68 | fastlane/readme.md
69 |
70 | # Version control
71 | vcs.xml
72 |
73 | # lint
74 | lint/intermediates/
75 | lint/generated/
76 | lint/outputs/
77 | lint/tmp/
78 | # lint/reports/
79 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/bill/InDatabaseBillServiceTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill
2 |
3 | import app.boletinhos.domain.bill.BillStatus.OVERDUE
4 | import app.boletinhos.domain.bill.BillStatus.PAID
5 | import app.boletinhos.fakes.BillsFactory
6 | import app.boletinhos.testutil.AppDatabaseTest
7 | import assertk.assertThat
8 | import assertk.assertions.isEqualTo
9 | import kotlinx.coroutines.test.runBlockingTest
10 | import org.junit.Test
11 | import java.time.LocalDate
12 |
13 | class InDatabaseBillServiceTest : AppDatabaseTest() {
14 | @Test fun `should get bills by its status`() = runBlockingTest {
15 | val expected = BillsFactory.paids
16 |
17 | (expected + BillsFactory.overdue).forEach {
18 | billGateway.create(it)
19 | }
20 |
21 | billService.getByStatus(PAID).test { actual ->
22 | assertThat(actual).isEqualTo(expected)
23 | }
24 | }
25 |
26 | @Test fun `should update a given bill`() = runBlockingTest {
27 | val bill = BillsFactory
28 | .pick()
29 | .first()
30 | .copy(dueDate = LocalDate.now().minusMonths(1), status = OVERDUE)
31 |
32 | billGateway.create(bill)
33 |
34 | val updated = billService.getById(id = 1)
35 | .copy(description = "My new description", paymentDate = LocalDate.now())
36 | .also { it.id = 1 }
37 |
38 | billGateway.pay(updated)
39 |
40 | assertThat(billService.getById(id = 1)).isEqualTo(updated)
41 | }
42 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/application/injection/TestModule.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.application.injection
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import android.content.SharedPreferences
6 | import com.google.firebase.crashlytics.FirebaseCrashlytics
7 | import common.AppContext
8 | import common.AppScope
9 | import common.ImmediateDispatcher
10 | import common.IoDispatcher
11 | import common.MainDispatcher
12 | import dagger.Module
13 | import dagger.Provides
14 | import kotlinx.coroutines.CoroutineDispatcher
15 | import kotlinx.coroutines.Dispatchers
16 |
17 | @Module
18 | object TestModule {
19 | @Provides
20 | @AppContext
21 | internal fun provideContext(app: Application): Context = app
22 |
23 | @Provides
24 | @ImmediateDispatcher
25 | internal fun provideImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main
26 |
27 | @Provides
28 | @MainDispatcher
29 | internal fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
30 |
31 | @Provides
32 | @IoDispatcher
33 | internal fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.Main
34 |
35 | @Provides
36 | @AppScope
37 | internal fun provideSharedPreferences(
38 | @AppContext context: Context
39 | ): SharedPreferences {
40 | return context.getSharedPreferences("test._.test", Context.MODE_PRIVATE)
41 | }
42 |
43 | @Provides
44 | @AppScope
45 | internal fun provideCrashlytics(): FirebaseCrashlytics {
46 | return FirebaseCrashlytics.getInstance()
47 | }
48 | }
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/date/DateTextWatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.date
2 |
3 | import android.text.Editable
4 | import android.text.TextWatcher
5 | import java.io.File
6 |
7 | @Suppress("MagicNumber")
8 | class DateTextWatcher(
9 | private val dateInput: DateInput
10 | ) : TextWatcher {
11 | override fun afterTextChanged(s: Editable?) = Unit
12 |
13 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
14 |
15 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
16 | val textInput = dateInput.input
17 | textInput.removeTextChangedListener(this)
18 |
19 | val currentText = textInput.text?.toString().orEmpty()
20 | val newText = currentText
21 | .take(DateInput.MAX_INPUT_SIZE)
22 | .putOrRemoveDividerIfNeeded(start, before)
23 |
24 | textInput.setText(newText)
25 | textInput.setSelection(newText.length)
26 | textInput.addTextChangedListener(this)
27 | }
28 |
29 | private val divider = File.separator
30 | private val dayDividerPosition = 2
31 | private val monthDividerPosition = 5
32 |
33 | private fun String.putOrRemoveDividerIfNeeded(start: Int, before: Int): String {
34 | if (length == dayDividerPosition || length == monthDividerPosition) {
35 | val position = length
36 | val isTyping = before <= position && start < position
37 | return if (isTyping) this + divider else dropLast(1)
38 | }
39 |
40 | return this
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/summary/FetchSummary.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.summary
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.emptyFlow
5 | import kotlinx.coroutines.flow.flatMapLatest
6 | import kotlinx.coroutines.flow.flow
7 | import kotlinx.coroutines.flow.flowOf
8 | import javax.inject.Inject
9 |
10 | class FetchSummary @Inject constructor(
11 | private val preferences: SummaryPreferences,
12 | private val service: SummaryService
13 | ) {
14 | operator fun invoke(): Flow {
15 | return checkIfHasSummary().thenFetchSummaryIfTrue()
16 | }
17 |
18 | private fun checkIfHasSummary() = flow { emit(service.hasSummary()) }
19 |
20 | private fun Flow.thenFetchSummaryIfTrue(): Flow {
21 | return flatMapLatest { hasSummary ->
22 | if (!hasSummary) emptyFlow()
23 | else fetchActualSummaryOrReturnEmpty()
24 | }
25 | }
26 |
27 | // one day: move this to service layer (fetchSummaryById -> Room/SQLDelight)
28 | private fun fetchActualSummaryOrReturnEmpty(): Flow {
29 | val id = preferences.actualSummaryId()
30 |
31 | return service.getSummaries().flatMapLatest { summaries ->
32 | if (summaries.isEmpty()) return@flatMapLatest emptyFlow()
33 |
34 | val summary = summaries
35 | .firstOrNull { summary -> summary.id() == id }
36 | ?: summaries.first()
37 |
38 | preferences.actualSummary(id = summary.id())
39 | flowOf(summary)
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/currency/CurrencyMachine.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.currency
2 |
3 | import java.math.BigDecimal
4 | import java.text.DecimalFormat
5 | import java.text.NumberFormat
6 | import java.util.Locale
7 |
8 | object CurrencyMachine {
9 | private const val CURRENCY_BASE_VALUE = 100L
10 |
11 | fun formatFromRawValue(
12 | rawValue: Long,
13 | locale: Locale = Locale.getDefault()
14 | ): String {
15 | val numberFormatter = NumberFormat.getCurrencyInstance(locale)
16 | return numberFormatter.format(transformFromRawValueToBigDecimal(rawValue))
17 | }
18 |
19 | fun formatFromRawValueAndRemoveSymbol(
20 | rawValue: Long,
21 | locale: Locale = Locale.getDefault()
22 | ): String {
23 | val formatter = NumberFormat.getCurrencyInstance(locale) as DecimalFormat
24 |
25 | formatter.decimalFormatSymbols = formatter
26 | .decimalFormatSymbols.apply { currencySymbol = "" }
27 |
28 | return formatter.format(transformFromRawValueToBigDecimal(rawValue))
29 | }
30 |
31 | fun currencySymbol(locale: Locale): String {
32 | return numberFormatter(locale).currency?.symbol.orEmpty()
33 | }
34 |
35 | private fun numberFormatter(locale: Locale = Locale.getDefault()): NumberFormat {
36 | return NumberFormat.getCurrencyInstance(locale)
37 | }
38 |
39 | private fun transformFromRawValueToBigDecimal(rawValue: Long): BigDecimal {
40 | return BigDecimal(rawValue)
41 | .setScale(2, BigDecimal.ROUND_FLOOR)
42 | .divide(BigDecimal(CURRENCY_BASE_VALUE), BigDecimal.ROUND_FLOOR)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/bill/PayBillTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import app.boletinhos.domain.bill.error.BillsIsAlreadyPaidException
4 | import assertk.assertThat
5 | import assertk.assertions.isEqualTo
6 | import assertk.assertions.isFailure
7 | import assertk.assertions.isTrue
8 | import kotlinx.coroutines.runBlocking
9 | import org.junit.Test
10 | import java.time.LocalDate
11 |
12 | class PayBillTest {
13 | private val gateway = FakeBillGateway()
14 | private val validator = BillValidator()
15 | private val createBill = CreateBill(gateway, validator)
16 | private val payBill = PayBill(gateway)
17 |
18 | private val fakeBill = Bill(
19 | name = "Personal expense",
20 | description = "Food Category",
21 | value = 250_00L,
22 | paymentDate = null,
23 | dueDate = LocalDate.now(),
24 | status = BillStatus.UNPAID
25 | )
26 |
27 | @Test fun `should mark bill as paid`() = runBlocking {
28 | val bill = fakeBill
29 | bill.id = 100
30 |
31 | createBill(bill)
32 | payBill(bill)
33 |
34 | val actual = gateway.bills.getValue(100)
35 |
36 | assertThat(actual.status).isEqualTo(BillStatus.PAID)
37 | assertThat(actual.isPaid()).isTrue()
38 | }
39 |
40 | @Test fun `should throw exception if try to pay a paid bill`() = runBlocking {
41 | val bill = fakeBill.copy(paymentDate = LocalDate.now())
42 | bill.id = 100
43 |
44 | assertThat {
45 | createBill(bill)
46 | payBill(bill)
47 | payBill(bill)
48 | }.isFailure().isEqualTo(BillsIsAlreadyPaidException)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/currency/CurrencyInput.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.currency
2 |
3 | import android.content.Context
4 | import android.text.InputFilter.LengthFilter
5 | import android.text.InputType
6 | import android.util.AttributeSet
7 | import app.boletinhos.domain.currency.CurrencyMachine
8 | import app.boletinhos.widget.text.TextInput
9 | import java.util.Locale
10 |
11 | class CurrencyInput @JvmOverloads constructor(
12 | context: Context,
13 | attrs: AttributeSet? = null,
14 | val locale: Locale = Locale.getDefault()
15 | ) : TextInput(context, attrs) {
16 | private val textWatcher = CurrencyTextWatcher(this)
17 |
18 | internal val input get() = inputBinding.input
19 |
20 | var rawValue: Long = 0
21 | internal set
22 |
23 | override fun onAttachedToWindow() {
24 | super.onAttachedToWindow()
25 | configureCurrencyPrefix()
26 | configureTextInput()
27 | }
28 |
29 | override fun onDetachedFromWindow() {
30 | super.onDetachedFromWindow()
31 | detachCurrencyTextWatcher()
32 | }
33 |
34 | private fun configureTextInput() {
35 | input.addTextChangedListener(textWatcher)
36 | input.inputType = InputType.TYPE_CLASS_NUMBER
37 | input.maxLines = 1
38 | input.filters = arrayOf(LengthFilter(MAX_INPUT_SIZE))
39 | }
40 |
41 | private fun configureCurrencyPrefix() {
42 | prefixText = CurrencyMachine.currencySymbol(locale)
43 | }
44 |
45 | private fun detachCurrencyTextWatcher() {
46 | input.removeTextChangedListener(textWatcher)
47 | }
48 |
49 | companion object {
50 | const val MAX_INPUT_SIZE = 16
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/testutil/AppDatabaseTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import androidx.room.Room
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import app.boletinhos.database.AppDatabase
6 | import app.boletinhos.domain.bill.BillGateway
7 | import app.boletinhos.domain.bill.BillService
8 | import app.boletinhos.domain.summary.SummaryService
9 | import kotlinx.coroutines.asExecutor
10 | import org.junit.After
11 | import org.junit.Before
12 | import org.junit.runner.RunWith
13 | import org.robolectric.RobolectricTestRunner
14 | import org.robolectric.annotation.Config
15 |
16 | @RunWith(RobolectricTestRunner::class)
17 | @Config(manifest = Config.NONE, sdk = [23])
18 | abstract class AppDatabaseTest : CoroutineTest() {
19 | private lateinit var appDatabase: AppDatabase
20 |
21 | internal lateinit var billService: BillService
22 | internal lateinit var summaryService: SummaryService
23 | internal lateinit var billGateway: BillGateway
24 |
25 | @Before fun setUp() {
26 | val context = InstrumentationRegistry.getInstrumentation().context
27 |
28 | appDatabase = Room
29 | .inMemoryDatabaseBuilder(context, AppDatabase::class.java)
30 | .setQueryExecutor(mainCoroutineRule.testDispatcher.asExecutor())
31 | .setTransactionExecutor(mainCoroutineRule.testDispatcher.asExecutor())
32 | .allowMainThreadQueries()
33 | .build()
34 |
35 | billService = appDatabase.billService()
36 | summaryService = appDatabase.summaryService()
37 | billGateway = appDatabase.billGateway()
38 | }
39 |
40 | @After fun tearDown() {
41 | appDatabase.clearAllTables()
42 | appDatabase.close()
43 | }
44 | }
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/summary_item_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
23 |
24 |
34 |
35 |
44 |
45 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/welcome_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
15 |
16 |
22 |
23 |
29 |
30 |
35 |
36 |
43 |
--------------------------------------------------------------------------------
/android-core/src/main/java/app/boletinhos/widget/currency/CurrencyTextWatcher.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.currency
2 |
3 | import android.text.Editable
4 | import android.text.TextWatcher
5 | import app.boletinhos.domain.currency.CurrencyMachine
6 |
7 | @Suppress("ForbiddenComment")
8 | class CurrencyTextWatcher(
9 | private val currencyInput: CurrencyInput,
10 | private val currencyPattern: Regex = "[^0-9]".toRegex()
11 | ) : TextWatcher {
12 | private var lastInput: String = ""
13 |
14 | override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
15 |
16 | override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
17 |
18 | override fun afterTextChanged(editable: Editable) {
19 | val textInput = currencyInput.input
20 | textInput.removeTextChangedListener(this)
21 |
22 | val currentText = editable.toString()
23 |
24 | if (currentText.isBlank()) {
25 | currencyInput.rawValue = 0
26 | textInput.setText("")
27 | reattachWatcher()
28 | return
29 | }
30 |
31 | val rawText = currentText.replace(currencyPattern, "")
32 | currencyInput.rawValue = rawText.toLong()
33 |
34 | val formattedValue = runCatching {
35 | CurrencyMachine.formatFromRawValueAndRemoveSymbol(
36 | rawText.toLong(),
37 | currencyInput.locale
38 | )
39 | }.getOrDefault(lastInput)
40 |
41 | lastInput = formattedValue
42 |
43 | textInput.setText(formattedValue)
44 | textInput.setSelection(formattedValue.length)
45 |
46 | reattachWatcher()
47 | }
48 |
49 | // Todo: adding a text-watcher is expensive. Improve it later.
50 | private fun reattachWatcher() = currencyInput.input.addTextChangedListener(this)
51 | }
52 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/testutil/FakeBillsFactory.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.testutil
2 |
3 | import app.boletinhos.domain.bill.Bill
4 | import app.boletinhos.domain.bill.BillGateway
5 | import app.boletinhos.domain.bill.BillService
6 | import app.boletinhos.domain.bill.BillStatus
7 | import app.boletinhos.domain.summary.Summary
8 | import kotlinx.coroutines.runBlocking
9 | import java.time.LocalDate
10 | import javax.inject.Inject
11 |
12 | class FakeBillsFactory @Inject constructor(
13 | private val billGateway: BillGateway,
14 | private val billService: BillService
15 | ) {
16 | fun createFakeSummary(): Summary {
17 | val bill = Bill(
18 | name = "Some Bill name",
19 | description = "Some bill description",
20 | value = 99_90,
21 | paymentDate = null,
22 | dueDate = LocalDate.now(),
23 | status = BillStatus.UNPAID
24 | )
25 |
26 | val bill2 = bill.copy(value = 50_00)
27 | val bill3 = bill.copy(value = 150_00)
28 | val bill4 = bill.copy(paymentDate = LocalDate.now(), status = BillStatus.PAID)
29 |
30 | val bills = listOf(bill, bill2, bill3, bill4)
31 |
32 | runBlocking {
33 | bills.forEach { billGateway.create(it) }
34 | }
35 |
36 | return Summary(
37 | month = LocalDate.now().month,
38 | year = LocalDate.now().year,
39 | totalValue = bills.fold(0L) { totalValue, actualBill -> totalValue + actualBill.value },
40 | paids = bills.count { it.status == BillStatus.PAID },
41 | unpaids = bills.count { it.status == BillStatus.UNPAID },
42 | overdue = bills.count { it.status == BillStatus.OVERDUE }
43 | )
44 | }
45 |
46 | fun getRecentCreatedBill(): Bill {
47 | return runBlocking { billService.getById(1) }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/android-ui/build.gradle:
--------------------------------------------------------------------------------
1 | import config.Androidx
2 | import config.AppConfig
3 | import config.DI
4 | import config.Firebase
5 | import config.Kotlinx
6 | import config.LeakCanary
7 | import config.SimpleStack
8 | import config.Testing
9 | import config.UI
10 |
11 | apply plugin: 'com.android.application'
12 | apply from: "$rootDir/build-system/android.gradle"
13 |
14 | apply plugin: 'kotlin-kapt'
15 | apply plugin: 'com.google.firebase.crashlytics'
16 | apply plugin: 'com.google.gms.google-services'
17 |
18 | android {
19 | defaultConfig {
20 | applicationId AppConfig.id
21 | }
22 |
23 | kotlinOptions {
24 | // ...
25 | freeCompilerArgs += [
26 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
27 | "-Xuse-experimental=kotlinx.coroutines.ObsoleteCoroutinesApi"
28 | ]
29 | }
30 |
31 | packagingOptions {
32 | exclude 'META-INF/AL2.0'
33 | exclude("META-INF/LGPL2.1")
34 | }
35 | }
36 |
37 | repositories {
38 | maven { url "https://jitpack.io" }
39 | }
40 |
41 | dependencies {
42 | implementation(
43 | project(":domain"),
44 | project(":preferences"),
45 | project(":android-core"),
46 | project(":bills-service")
47 | )
48 |
49 | implementation(UI.material)
50 |
51 | implementation(DI.dagger, Androidx.Room.core)
52 | kapt(DI.compiler, Androidx.Room.compiler)
53 |
54 | implementation(Firebase.Crashlytics.core)
55 | implementation(SimpleStack.core, SimpleStack.services)
56 |
57 | debugImplementation(LeakCanary.android)
58 |
59 | testImplementation(Kotlinx.Coroutines.test, Testing.robolectric, Testing.mockK)
60 |
61 | androidTestImplementation(Androidx.Testing.coreKtx, Androidx.Testing.espresso)
62 | androidTestImplementation(project(":testutil"), Kotlinx.Coroutines.test)
63 |
64 | kaptAndroidTest(DI.compiler)
65 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/welcome/WelcomeViewTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import androidx.test.core.app.ApplicationProvider
4 | import androidx.test.ext.junit.rules.ActivityScenarioRule
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import androidx.test.filters.MediumTest
7 | import app.boletinhos.application.TestApplication
8 | import app.boletinhos.application.injection.TestAppComponent
9 | import app.boletinhos.main.MainActivity
10 | import org.junit.Before
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 | import javax.inject.Inject
15 |
16 | @RunWith(AndroidJUnit4::class)
17 | @MediumTest
18 | class WelcomeViewTest {
19 | @get:Rule
20 | val activityRule = ActivityScenarioRule(MainActivity::class.java)
21 |
22 | @Inject
23 | lateinit var witnessRobot: WitnessRobot
24 |
25 | @Before
26 | fun setUp() {
27 | val testComponent = ApplicationProvider.getApplicationContext()
28 | .appComponent() as TestAppComponent
29 |
30 | testComponent.inject(this)
31 | }
32 |
33 | @Test fun shouldShowWelcomeContent(): Unit = with(witnessRobot) {
34 | launchWelcome(withScenario = activityRule.scenario)
35 | checkIfTitleAndMessageIsShown()
36 | }
37 |
38 | @Test fun shouldNavigateToAddBillScreen(): Unit = with(witnessRobot) {
39 | launchWelcome(withScenario = activityRule.scenario)
40 | tapOnAddBillAction()
41 | checkIfNavigatedToAddBillScreen(withScenario = activityRule.scenario)
42 | }
43 |
44 | @Test fun shouldNavigateToSummaryAfterCreatingFirstBill(): Unit = with(witnessRobot) {
45 | launchWelcome(withScenario = activityRule.scenario)
46 | simulateBillCreated(withScenario = activityRule.scenario)
47 | checkIfNavigatedToSummary(withScenario = activityRule.scenario)
48 | }
49 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/main/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.main
2 |
3 | import android.os.Bundle
4 | import android.view.ViewGroup
5 | import androidx.appcompat.app.AppCompatActivity
6 | import app.boletinhos.application.MainApplication
7 | import app.boletinhos.application.injection.AppComponent
8 | import app.boletinhos.main.injection.ActivityRetainedServicesFactory
9 | import app.boletinhos.navigation.ModalBottomSheetViewStateChanger
10 | import app.boletinhos.summary.SummaryViewKey
11 | import com.zhuinden.simplestack.History
12 | import com.zhuinden.simplestack.navigator.Navigator
13 | import com.zhuinden.simplestackextensions.services.DefaultServiceProvider
14 | import javax.inject.Inject
15 | import android.R.id as AndroidIds
16 | import app.boletinhos.R.style as Styles
17 |
18 | class MainActivity : AppCompatActivity() {
19 | @Inject lateinit var activityRetainedServicesFactory: ActivityRetainedServicesFactory
20 |
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | appComponent().inject(this)
23 |
24 | setTheme(Styles.App)
25 | super.onCreate(savedInstanceState)
26 |
27 | val root = findViewById(AndroidIds.content)
28 | val stateChanger = ModalBottomSheetViewStateChanger(this, root, windowManager)
29 | lifecycle.addObserver(stateChanger)
30 |
31 | Navigator.configure()
32 | .setStateChanger(stateChanger)
33 | .setScopedServices(DefaultServiceProvider())
34 | .setGlobalServices(activityRetainedServicesFactory)
35 | .install(this, root, History.single(SummaryViewKey()))
36 | }
37 |
38 | override fun onBackPressed() {
39 | if (!Navigator.onBackPressed(this)) {
40 | super.onBackPressed()
41 | }
42 | }
43 |
44 | private fun appComponent(): AppComponent {
45 | return (application as MainApplication).appComponent()
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/android-ui/src/test/java/app/boletinhos/summary/SummaryTestUtil.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import app.boletinhos.domain.summary.Summary
4 | import java.time.Month
5 | import app.boletinhos.R.drawable as Drawables
6 | import app.boletinhos.R.string as Texts
7 |
8 | internal fun createSummaries() = listOf(
9 | Summary(
10 | month = Month.JANUARY,
11 | year = 2019,
12 | totalValue = 1900,
13 | paids = 0,
14 | unpaids = 0,
15 | overdue = 0
16 | ),
17 | Summary(
18 | month = Month.FEBRUARY,
19 | year = 2019,
20 | totalValue = 1900,
21 | paids = 0,
22 | unpaids = 0,
23 | overdue = 0
24 | )
25 | ).sortedByDescending { it.id() }
26 |
27 | internal fun createItemsFromSummary(summary: Summary) = listOf(
28 | SummaryItemCardView.Model(
29 | iconRes = Drawables.ic_summary,
30 | titleRes = Texts.text_month_summary,
31 | titleArg = summary.displayName(),
32 | descriptionRes = Texts.text_bills,
33 | textValue = summary.formattedTotalValue(),
34 | kind = SummaryItemCardView.Model.Kind.MONTH_SUMMARY
35 | ),
36 | SummaryItemCardView.Model(
37 | iconRes = Drawables.ic_paid,
38 | titleRes = Texts.text_bills_paids,
39 | descriptionRes = Texts.text_bills,
40 | textValue = summary.paids.toString(),
41 | kind = SummaryItemCardView.Model.Kind.PAIDS
42 | ),
43 |
44 | SummaryItemCardView.Model(
45 | iconRes = Drawables.ic_hourglass,
46 | titleRes = Texts.text_bills_unpaids,
47 | descriptionRes = Texts.text_bills,
48 | textValue = summary.unpaids.toString(),
49 | kind = SummaryItemCardView.Model.Kind.UNPAIDS
50 | ),
51 |
52 | SummaryItemCardView.Model(
53 | iconRes = Drawables.ic_calendar,
54 | titleRes = Texts.text_bills_overdue,
55 | descriptionRes = Texts.text_bills,
56 | textValue = summary.overdue.toString(),
57 | kind = SummaryItemCardView.Model.Kind.OVERDUE
58 | )
59 | )
60 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/fakes/SummaryFactory.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.fakes
2 |
3 | import app.boletinhos.domain.bill.BillStatus
4 | import app.boletinhos.domain.summary.Summary
5 | import java.time.LocalDate
6 | import java.time.Month
7 |
8 | object SummaryFactory {
9 | private val defaultBills = BillsFactory.unpaids + BillsFactory.paids + BillsFactory.overdue
10 |
11 | private val augustBills =
12 | defaultBills.map { it.copy(dueDate = LocalDate.of(2020, Month.AUGUST, 8)) }
13 | private val novemberBills =
14 | defaultBills.map { it.copy(dueDate = LocalDate.of(2020, Month.NOVEMBER, 8)) }
15 | private val decemberBills =
16 | defaultBills.map { it.copy(dueDate = LocalDate.of(2020, Month.DECEMBER, 8)) }
17 |
18 | val bills = augustBills + novemberBills + decemberBills
19 |
20 | val august = Summary(
21 | month = Month.AUGUST,
22 | year = 2020,
23 | totalValue = augustBills.map { it.value }.sum(),
24 | paids = augustBills.filter { it.status == BillStatus.PAID }.count(),
25 | unpaids = augustBills.filter { it.status == BillStatus.UNPAID }.count(),
26 | overdue = augustBills.filter { it.status == BillStatus.OVERDUE }.count()
27 | )
28 |
29 | val november = Summary(
30 | month = Month.NOVEMBER,
31 | year = 2020,
32 | totalValue = novemberBills.map { it.value }.sum(),
33 | paids = novemberBills.filter { it.status == BillStatus.PAID }.count(),
34 | unpaids = novemberBills.filter { it.status == BillStatus.UNPAID }.count(),
35 | overdue = novemberBills.filter { it.status == BillStatus.OVERDUE }.count()
36 | )
37 |
38 | val december = Summary(
39 | month = Month.DECEMBER,
40 | year = 2020,
41 | totalValue = decemberBills.map { it.value }.sum(),
42 | paids = decemberBills.filter { it.status == BillStatus.PAID }.count(),
43 | unpaids = decemberBills.filter { it.status == BillStatus.UNPAID }.count(),
44 | overdue = decemberBills.filter { it.status == BillStatus.OVERDUE }.count()
45 | )
46 | }
--------------------------------------------------------------------------------
/android-core/src/test/java/app/boletinhos/lifecycle/LifecycleCoroutineScopeTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isNotNull
5 | import assertk.assertions.isTrue
6 | import kotlinx.coroutines.cancel
7 | import kotlinx.coroutines.runBlocking
8 | import kotlinx.coroutines.test.TestCoroutineDispatcher
9 | import org.junit.Test
10 |
11 | class LifecycleCoroutineScopeTest {
12 | private val dispatcher = TestCoroutineDispatcher()
13 | private val lifecycleScope = ActivityRetainedCoroutineScope(dispatcher, dispatcher, dispatcher)
14 |
15 | @Test fun `jobs must be cancelled when lifecycleScope gets cancelled`() {
16 | val client = FakeCoroutineScopeClient(lifecycleScope)
17 |
18 | val job1 = client.job1()
19 | val job2 = client.job2()
20 | val job3 = client.job3()
21 |
22 | lifecycleScope.cancel()
23 |
24 | assertThat(job1.isCancelled).isTrue()
25 | assertThat(job2.isCancelled).isTrue()
26 | assertThat(job3.isCancelled).isTrue()
27 | }
28 |
29 | @Test fun `jobs must be cancelled if they got launched after lifecycleScope gets cancelled`() {
30 | val client = FakeCoroutineScopeClient(lifecycleScope)
31 |
32 | lifecycleScope.cancel()
33 |
34 | val job1 = client.job1()
35 | val job2 = client.job2()
36 | val job3 = client.job3()
37 |
38 | assertThat(job1.isCancelled).isTrue()
39 | assertThat(job2.isCancelled).isTrue()
40 | assertThat(job3.isCancelled).isTrue()
41 | }
42 |
43 | @Suppress("IMPLICIT_NOTHING_AS_TYPE_PARAMETER")
44 | @Test fun `jobs completed exceptionally should not cancel the entire scope`() {
45 | val client = FakeCoroutineScopeClient(lifecycleScope)
46 |
47 | val success = client.job4Async()
48 | val failure = client.job5Async()
49 |
50 | runBlocking {
51 | try {
52 | failure.await()
53 | } catch (e: Exception) {}
54 |
55 | assertThat(failure.getCompletionExceptionOrNull()).isNotNull()
56 | assertThat(success.isActive).isTrue()
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/domain/src/main/java/app/boletinhos/domain/bill/BillValidator.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import app.boletinhos.domain.bill.error.BillValidationErrorType
4 | import app.boletinhos.domain.bill.error.BillValidationException
5 | import javax.inject.Inject
6 |
7 | class BillValidator @Inject constructor() {
8 | fun validate(bill: Bill) = bill.run {
9 | val errors = mutableListOf()
10 |
11 | errors += validateValue()
12 | errors += validateName()
13 | errors += validateDescription()
14 |
15 | val outputErrors = errors.filterNotNull()
16 | if (outputErrors.isNotEmpty()) throw BillValidationException(errors = outputErrors)
17 | }
18 |
19 | private fun Bill.validateValue(): BillValidationErrorType? {
20 | val minValue = Bill.MINIMUM_VALUE
21 | val maxValue = Bill.MAXIMUM_VALUE
22 |
23 | val isValueValid = value in minValue..maxValue
24 | if (isValueValid) return null
25 |
26 | return if (value < minValue) {
27 | BillValidationErrorType.VALUE_MIN_REQUIRED
28 | } else BillValidationErrorType.VALUE_MAX_EXCEEDED
29 | }
30 |
31 | private fun Bill.validateName(): BillValidationErrorType? {
32 | val minCount = Bill.MINIMUM_NAME_COUNT
33 | val maxCount = Bill.MAXIMUM_NAME_COUNT
34 |
35 | val isNameValid = name.count() in minCount..maxCount
36 | if (isNameValid) return null
37 |
38 | return if (name.count() < minCount) {
39 | BillValidationErrorType.NAME_MIN_REQUIRED
40 | } else BillValidationErrorType.NAME_MAX_EXCEEDED
41 | }
42 |
43 | private fun Bill.validateDescription(): BillValidationErrorType? {
44 | val minCount = Bill.MINIMUM_DESCRIPTION_COUNT
45 | val maxCount = Bill.MAXIMUM_DESCRIPTION_COUNT
46 |
47 | val isDescriptionValid = description.count() in minCount..maxCount
48 | if (isDescriptionValid) return null
49 |
50 | return if (description.count() < minCount) {
51 | BillValidationErrorType.DESCRIPTION_MIN_REQUIRED
52 | } else BillValidationErrorType.DESCRIPTION_MAX_EXCEEDED
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/drawable/ic_paid.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
12 |
13 |
19 |
20 |
24 |
25 |
29 |
30 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Boletinhos
3 |
4 | Your %s summary
5 |
6 | In bills
7 | Paids
8 | Unpaids
9 | Overdue
10 | Try again
11 | Oh, we\'ve made a mistake. 😪
12 | Something really bad happened and we didn\'t correctly handled it. Try again later.
13 | Ooh, looks like it is your first time here… 😏
14 | What do you think about start adding your first bill? 🚀
15 | Add bill
16 |
17 | Name should have at least %s characters.
18 | Name should not be longer than %s characters.
19 |
20 | Description should have at least %s characters.
21 | Description should not be longer than %s characters.
22 |
23 | Value cannot be lower than %s.
24 | Value cannot be higher than %s.
25 |
26 | Invalid date. Valid format is dd/mm/yyyy. Please check.
27 |
28 | Oh, your bill cannot be created at this moment. We\'re sorry :(
29 |
30 | Bill created successfully! Yay 🎉
31 |
32 | Bill value
33 | Bill name
34 | Bill description
35 | Bill due date
36 |
37 |
38 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/widget/date/DateInputTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.date
2 |
3 | import androidx.test.ext.junit.rules.ActivityScenarioRule
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.SmallTest
6 | import app.boletinhos.testutil.TestActivity
7 | import org.junit.Ignore
8 | import org.junit.Rule
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 | import java.time.LocalDate
12 | import java.time.Month
13 |
14 | @RunWith(AndroidJUnit4::class)
15 | @SmallTest
16 | class DateInputTest {
17 | @get:Rule
18 | val activityRule = ActivityScenarioRule(TestActivity::class.java)
19 |
20 | private val robot: FifteenthRobot by lazy {
21 | FifteenthRobot(activityRule.scenario)
22 | }
23 |
24 | @Test fun shouldFormatTypedDateInDefaultPattern(): Unit = with(robot) {
25 | launchApp()
26 | type(text = "12122021")
27 | hasText(text = "12/12/2021")
28 | }
29 |
30 | @Test fun shouldPutDividerInCorrectPosition(): Unit = with(robot) {
31 | launchApp()
32 | type(text = "12122")
33 | hasText(text = "12/12/2")
34 | }
35 |
36 | @Test fun shouldRemoveDividerIfNeeded(): Unit = with(robot) {
37 | launchApp()
38 | type(text = "12122021")
39 | hasText(text = "12/12/2021")
40 | repeat(5) { backspace() }
41 | hasText(text = "12/1")
42 | }
43 |
44 | @Test
45 | fun shouldParseDateInDefaultFormat(): Unit = with(robot) {
46 | launchApp()
47 | type(text = "12122021")
48 | hasDate(date = LocalDate.of(2021, Month.DECEMBER, 12))
49 | }
50 |
51 | @Test
52 | fun shouldFailToParseDateIfTypedInInvalidFormat(): Unit = with(robot) {
53 | launchApp()
54 | type(text = "120")
55 | hasDate(date = null)
56 | repeat(3) { backspace() }
57 | type(text = "12/13/2021")
58 | hasDate(date = null)
59 | }
60 |
61 | @Test
62 | @Ignore(value = "Will fail. Not implemented yet.")
63 | fun shouldNotBeAbleToTypeInvalidCharacters(): Unit = with(robot) {
64 | launchApp()
65 | type(text = "21//////12.,----2021")
66 | hasText(text = "21/12/2021")
67 | hasDate(date = LocalDate.of(2021, Month.DECEMBER, 21))
68 | }
69 | }
--------------------------------------------------------------------------------
/bills-service/schemas/app.boletinhos.database.AppDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "68df36b652b7fcbce7edd03339bfce54",
6 | "entities": [
7 | {
8 | "tableName": "bills",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `value` INTEGER NOT NULL, `paymentDate` TEXT, `dueDate` TEXT NOT NULL, `status` TEXT NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "name",
19 | "columnName": "name",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "description",
25 | "columnName": "description",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "value",
31 | "columnName": "value",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "paymentDate",
37 | "columnName": "paymentDate",
38 | "affinity": "TEXT",
39 | "notNull": false
40 | },
41 | {
42 | "fieldPath": "dueDate",
43 | "columnName": "dueDate",
44 | "affinity": "TEXT",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "status",
49 | "columnName": "status",
50 | "affinity": "TEXT",
51 | "notNull": true
52 | }
53 | ],
54 | "primaryKey": {
55 | "columnNames": [
56 | "id"
57 | ],
58 | "autoGenerate": true
59 | },
60 | "indices": [],
61 | "foreignKeys": []
62 | }
63 | ],
64 | "views": [],
65 | "setupQueries": [
66 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
67 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '68df36b652b7fcbce7edd03339bfce54')"
68 | ]
69 | }
70 | }
--------------------------------------------------------------------------------
/bills-service/schemas/app.boletinhos.storage.database.AppDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "68df36b652b7fcbce7edd03339bfce54",
6 | "entities": [
7 | {
8 | "tableName": "bills",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `value` INTEGER NOT NULL, `paymentDate` TEXT, `dueDate` TEXT NOT NULL, `status` TEXT NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "name",
19 | "columnName": "name",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "description",
25 | "columnName": "description",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "value",
31 | "columnName": "value",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "paymentDate",
37 | "columnName": "paymentDate",
38 | "affinity": "TEXT",
39 | "notNull": false
40 | },
41 | {
42 | "fieldPath": "dueDate",
43 | "columnName": "dueDate",
44 | "affinity": "TEXT",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "status",
49 | "columnName": "status",
50 | "affinity": "TEXT",
51 | "notNull": true
52 | }
53 | ],
54 | "primaryKey": {
55 | "columnNames": [
56 | "id"
57 | ],
58 | "autoGenerate": true
59 | },
60 | "indices": [],
61 | "foreignKeys": []
62 | }
63 | ],
64 | "views": [],
65 | "setupQueries": [
66 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
67 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '68df36b652b7fcbce7edd03339bfce54')"
68 | ]
69 | }
70 | }
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/summary/InDatabaseSummaryServiceTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import app.boletinhos.fakes.SummaryFactory
4 | import app.boletinhos.testutil.AppDatabaseTest
5 | import assertk.assertAll
6 | import assertk.assertThat
7 | import assertk.assertions.*
8 | import kotlinx.coroutines.test.runBlockingTest
9 | import org.junit.Test
10 |
11 | class InDatabaseSummaryServiceTest : AppDatabaseTest() {
12 | @Test fun `should get summaries for a given batch of bills`() = runBlockingTest {
13 | val bills = SummaryFactory.bills
14 |
15 | bills.forEach { billGateway.create(it) }
16 |
17 | summaryService.getSummaries().test { summaries ->
18 | assertAll {
19 | assertThat(summaries).hasSize(3)
20 | assertThat(summaries).containsAll(
21 | SummaryFactory.august,
22 | SummaryFactory.december,
23 | SummaryFactory.november
24 | )
25 | }
26 | }
27 | }
28 |
29 | @Test fun `should check if summaries are correctly ordered by its due date`() = runBlockingTest {
30 | val bills = SummaryFactory.bills
31 |
32 | bills.forEach { billGateway.create(it) }
33 |
34 | summaryService.getSummaries().test { summaries ->
35 | assertAll {
36 | assertThat(summaries).hasSize(3)
37 | assertThat(summaries).containsExactly(
38 | SummaryFactory.december,
39 | SummaryFactory.november,
40 | SummaryFactory.august
41 | )
42 | }
43 | }
44 | }
45 |
46 | @Test fun `should have no summary if there's no data in the data source`() = runBlockingTest {
47 | summaryService.getSummaries().test { summaries ->
48 | assertThat(summaries).isEmpty()
49 | }
50 | }
51 |
52 | @Test fun `should be false when there is no bill to create a summary`() = runBlockingTest {
53 | assertThat(summaryService.hasSummary()).isFalse()
54 | }
55 |
56 | @Test fun `should be true when there is bill to create a summary`() = runBlockingTest {
57 | val bills = SummaryFactory.bills
58 | bills.forEach { billGateway.create(it) }
59 |
60 | assertThat(summaryService.hasSummary()).isTrue()
61 | }
62 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/summary/SummaryItemCardView.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.content.Context
4 | import android.os.Parcelable
5 | import android.util.AttributeSet
6 | import android.view.LayoutInflater
7 | import androidx.annotation.DrawableRes
8 | import androidx.annotation.StringRes
9 | import androidx.annotation.StyleRes
10 | import app.boletinhos.databinding.SummaryItemViewBinding
11 | import app.boletinhos.theming.createThemeAwareDrawable
12 | import com.google.android.material.card.MaterialCardView
13 | import kotlinx.android.parcel.Parcelize
14 | import app.boletinhos.R.layout as Layouts
15 | import app.boletinhos.R.style as Styles
16 |
17 | class SummaryItemCardView(
18 | context: Context,
19 | attrs: AttributeSet? = null
20 | ): MaterialCardView(context, attrs) {
21 | private val binding = SummaryItemViewBinding.inflate(LayoutInflater.from(context), this, true)
22 |
23 | fun bind(model: Model) = binding.run {
24 | val icon = context.createThemeAwareDrawable(model.iconRes)
25 |
26 | textItemTitle.apply {
27 | model.titleArg?.let { arg ->
28 | val titleText = resources.getString(model.titleRes, arg)
29 | text = titleText
30 | } ?: setText(model.titleRes)
31 |
32 | setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
33 | }
34 |
35 | textItemDescription.setText(model.descriptionRes)
36 | textItemValue.text = model.textValue
37 | }
38 |
39 | @Parcelize
40 | data class Model(
41 | @DrawableRes val iconRes: Int,
42 | @StringRes val titleRes: Int,
43 | @StringRes val descriptionRes: Int,
44 | val titleArg: String? = null,
45 | val textValue: String,
46 | val kind: Kind
47 | ) : Parcelable {
48 | enum class Kind(val viewType: Int, @StyleRes val themeRes: Int) {
49 | MONTH_SUMMARY(Layouts.summary_item_view + 1, Styles.App) {
50 | override val isFullSpan: Boolean = true
51 | },
52 |
53 | UNPAIDS(Layouts.summary_item_view + 2, Styles.App_Bills_Unpaid),
54 |
55 | PAIDS(Layouts.summary_item_view + 3, Styles.App_Bills_Paid),
56 |
57 | OVERDUE(Layouts.summary_item_view + 4, Styles.App_Bills_Overdue);
58 |
59 | open val isFullSpan: Boolean = false
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/fakes/BillsFactory.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.fakes
2 |
3 | import app.boletinhos.domain.bill.Bill
4 | import app.boletinhos.domain.bill.BillStatus.OVERDUE
5 | import app.boletinhos.domain.bill.BillStatus.PAID
6 | import app.boletinhos.domain.bill.BillStatus.UNPAID
7 | import java.time.LocalDate
8 |
9 | object BillsFactory {
10 | private val now get() = LocalDate.now()
11 |
12 | val main = Bill(
13 | name = "House Electricity Bill",
14 | description = "Electricity",
15 | value = 250_00L /* 250 currency */,
16 | dueDate = now.plusMonths(1),
17 | paymentDate = null,
18 | status = UNPAID
19 | )
20 |
21 | val bill2 = main.copy(
22 | name = "Unity 3D",
23 | description = "Subscriptions",
24 | value = 99_00L
25 | )
26 |
27 | val bill3 = main.copy(
28 | name = "PluralSight",
29 | description = "Subscriptions",
30 | value = 59_00L
31 | )
32 |
33 | val bill4 = main.copy(
34 | name = "Caster.io",
35 | description = "Subscriptions",
36 | value = 29_00L
37 | )
38 |
39 | val bill5 = main.copy(
40 | name = "Spotify",
41 | description = "Subscriptions",
42 | value = 8_00L
43 | )
44 |
45 | val bill6 = main.copy(
46 | name = "Netflix",
47 | description = "Subscriptions",
48 | value = 49_90L
49 | )
50 |
51 | val bill7 = main.copy(
52 | name = "Cute Cats NGO",
53 | description = "Donations",
54 | value = 990_00L,
55 | dueDate = now.plusMonths(2)
56 | )
57 |
58 | val bill8 = main.copy(
59 | name = "Angry Cats NGO",
60 | description = "Donations",
61 | value = 990_00L,
62 | dueDate = now.plusMonths(2)
63 | )
64 |
65 | val bill9 = main.copy(
66 | name = "Dogs for a living NGO",
67 | description = "Donations",
68 | value = 990_00L,
69 | dueDate = now.plusMonths(2)
70 | )
71 |
72 | val unpaids = listOf(
73 | bill2,
74 | bill3,
75 | bill4,
76 | bill5,
77 | bill6,
78 | bill7,
79 | bill8,
80 | bill9
81 | )
82 |
83 | val paids = unpaids.map { it.copy(status = PAID) }
84 | val overdue = paids.map { it.copy(status = OVERDUE) }
85 |
86 | fun pick(quantity: Int = 1) = unpaids.shuffled().take(quantity)
87 | }
--------------------------------------------------------------------------------
/domain/src/test/java/app/boletinhos/domain/bill/CreateBillTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.domain.bill
2 |
3 | import app.boletinhos.domain.bill.error.BillValidationErrorType
4 | import app.boletinhos.domain.bill.error.BillValidationException
5 | import assertk.assertThat
6 | import assertk.assertions.contains
7 | import assertk.assertions.isEqualTo
8 | import assertk.assertions.isFailure
9 | import kotlinx.coroutines.runBlocking
10 | import org.junit.Test
11 | import java.time.LocalDate
12 |
13 | class CreateBillTest {
14 | private val gateway = FakeBillGateway()
15 | private val validator = BillValidator()
16 | private val createBill = CreateBill(gateway, validator)
17 |
18 | private val fakeBill = Bill(
19 | name = "Personal expense",
20 | description = "Food Category",
21 | value = 250_00L,
22 | paymentDate = null,
23 | dueDate = LocalDate.now(),
24 | status = BillStatus.UNPAID
25 | )
26 |
27 | @Test fun `should create bill`() = runBlocking {
28 | val bill = fakeBill
29 | bill.id = 100
30 |
31 | createBill(bill)
32 |
33 | assertThat(gateway.bills).contains(key = 100, value = bill)
34 | }
35 |
36 | @Test fun `should throw bill has invalid value exception`() = runBlocking {
37 | val bill = fakeBill.copy(value = 1_00L)
38 | bill.id = 100
39 |
40 | val expectedError = BillValidationException(
41 | errors = listOf(BillValidationErrorType.VALUE_MIN_REQUIRED)
42 | )
43 |
44 | assertThat { createBill(bill) }.isFailure().isEqualTo(expectedError)
45 | }
46 |
47 | @Test fun `should throw bill has invalid name exception`() = runBlocking {
48 | val bill = fakeBill.copy(name = "P")
49 | bill.id = 100
50 |
51 | val expectedError = BillValidationException(
52 | errors = listOf(BillValidationErrorType.NAME_MIN_REQUIRED)
53 | )
54 |
55 | assertThat { createBill(bill) }.isFailure().isEqualTo(expectedError)
56 | }
57 |
58 | @Test fun `should throw bill has invalid description exception`() = runBlocking {
59 | val bill = fakeBill.copy(description = "F")
60 | bill.id = 100
61 |
62 | val expectedError = BillValidationException(
63 | errors = listOf(BillValidationErrorType.DESCRIPTION_MIN_REQUIRED)
64 | )
65 |
66 | assertThat { createBill(bill) }.isFailure().isEqualTo(expectedError)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/android-ui/src/main/res/layout/summary_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
20 |
21 |
22 |
23 |
35 |
36 |
41 |
42 |
51 |
52 |
61 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/widget/date/FifteenthRobot.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.date
2 |
3 | import android.view.Gravity
4 | import android.view.KeyEvent
5 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT
6 | import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
7 | import android.widget.FrameLayout
8 | import androidx.test.core.app.ActivityScenario
9 | import androidx.test.espresso.Espresso
10 | import androidx.test.espresso.action.ViewActions
11 | import androidx.test.espresso.assertion.ViewAssertions
12 | import androidx.test.espresso.matcher.ViewMatchers
13 | import app.boletinhos.R
14 | import app.boletinhos.testutil.TestActivity
15 | import app.boletinhos.testutil.dateInputHasDate
16 | import app.boletinhos.testutil.textInputHasTextValue
17 | import org.hamcrest.Matchers
18 | import java.time.LocalDate
19 |
20 | class FifteenthRobot(private val scenario: ActivityScenario) {
21 | private lateinit var dateInput: DateInput
22 |
23 | fun launchApp() = apply {
24 | launchActivityAndShowDateInput(scenario)
25 | }
26 |
27 | private fun launchActivityAndShowDateInput(
28 | withScenario: ActivityScenario
29 | ) = apply {
30 | withScenario.onActivity { activity ->
31 | dateInput = activity.createDateInputView()
32 | activity.rootView.addView(dateInput)
33 | }
34 | }
35 |
36 | private fun TestActivity.createDateInputView(): DateInput {
37 | return DateInput(context = rootView.context).apply {
38 | id = VIEW_ID
39 | layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
40 | gravity = Gravity.CENTER
41 | }
42 | }
43 | }
44 |
45 | private fun view() = Espresso.onView(Matchers.allOf(ViewMatchers.withId(VIEW_ID)))
46 |
47 | private fun inputView() = Espresso.onView(Matchers.allOf(ViewMatchers.withId(INPUT_VIEW_ID)))
48 |
49 | fun hasText(text: String) = apply {
50 | view().check(ViewAssertions.matches(textInputHasTextValue(text)))
51 | }
52 |
53 | fun hasDate(date: LocalDate?) = apply {
54 | view().check(ViewAssertions.matches(dateInputHasDate(date)))
55 | }
56 |
57 | fun type(text: String) = apply {
58 | inputView().perform(ViewActions.typeText(text))
59 | }
60 |
61 | fun backspace() = apply {
62 | inputView().perform(ViewActions.pressKey(KeyEvent.KEYCODE_DEL))
63 | }
64 |
65 | private companion object {
66 | private const val VIEW_ID = 15
67 | private const val INPUT_VIEW_ID = R.id.input
68 | }
69 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/widget/currency/CurrencyInputTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.currency
2 |
3 | import androidx.test.ext.junit.rules.ActivityScenarioRule
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.SmallTest
6 | import app.boletinhos.testutil.TestActivity
7 | import org.junit.Rule
8 | import org.junit.Test
9 | import org.junit.runner.RunWith
10 | import java.util.Locale
11 |
12 | @RunWith(AndroidJUnit4::class)
13 | @SmallTest
14 | class CurrencyInputTest {
15 | @get:Rule
16 | val activityRule = ActivityScenarioRule(TestActivity::class.java)
17 |
18 | private val brLocale = Locale("pt", "Br")
19 | private val robot: QuindimRobot by lazy {
20 | QuindimRobot(activityRule.scenario)
21 | }
22 |
23 | @Test
24 | fun shouldShowUSDCurrencyInPrefix(): Unit = with(robot) {
25 | launchApp()
26 | type("1")
27 | checkIfCurrencySymbolIsShown("$")
28 | }
29 |
30 | @Test
31 | fun shouldShowBrazilianCurrencyInPrefix(): Unit = with(robot) {
32 | launchApp(withLocale = brLocale)
33 | type("1")
34 | checkIfCurrencySymbolIsShown("R$")
35 | }
36 |
37 | @Test
38 | fun shouldHaveCorrectRawValue(): Unit = with(robot) {
39 | launchApp()
40 | type("120")
41 | hasText("1.20")
42 | hasRawValue(120)
43 | }
44 |
45 | @Test
46 | fun shouldHaveCorrectRawValueInDifferentLocale(): Unit = with(robot) {
47 | launchApp(withLocale = brLocale)
48 | type("120")
49 | hasText("1,20")
50 | hasRawValue(120)
51 | }
52 |
53 | @Test
54 | fun shouldFormatTextInCorrectUSDFormat(): Unit = with(robot) {
55 | launchApp()
56 | type("999999")
57 | hasText("9,999.99")
58 | }
59 |
60 | @Test
61 | fun shouldFormatTextInCorrectBRLFormat(): Unit = with(robot) {
62 | launchApp(withLocale = brLocale)
63 | type("999999")
64 | hasText("9.999,99") // (output is always R$ 0,00 instead of R$0.00)
65 | }
66 |
67 | @Test
68 | fun shouldDoNothingWhenTypingInvalidValues(): Unit = with(robot) {
69 | launchApp()
70 | type("1")
71 | hasText("0.01")
72 | type("-MidNiUqClub")
73 | type("-")
74 | hasText("0.01")
75 | }
76 |
77 | @Test
78 | fun shouldDeleteValue(): Unit = with(robot) {
79 | launchApp()
80 | type("111")
81 | hasText("1.11")
82 | backspace()
83 | hasText("0.11")
84 | backspace()
85 | hasText("0.01")
86 | backspace()
87 | hasText("0.00")
88 | }
89 | }
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/lifecycle/ActivityRetainedCoroutineScopeTest.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.lifecycle
2 |
3 | import androidx.lifecycle.Lifecycle
4 | import androidx.test.ext.junit.rules.ActivityScenarioRule
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import androidx.test.filters.SmallTest
7 | import app.boletinhos.main.MainActivity
8 | import app.boletinhos.main.injection.activityRetainedComponent
9 | import assertk.assertAll
10 | import assertk.assertThat
11 | import assertk.assertions.isFalse
12 | import assertk.assertions.isNotNull
13 | import assertk.assertions.isSameAs
14 | import assertk.assertions.isTrue
15 | import com.zhuinden.simplestackextensions.navigatorktx.backstack
16 | import kotlinx.coroutines.CoroutineScope
17 | import kotlinx.coroutines.isActive
18 | import org.junit.Rule
19 | import org.junit.Test
20 | import org.junit.runner.RunWith
21 |
22 | @RunWith(AndroidJUnit4::class)
23 | @SmallTest
24 | class ActivityRetainedCoroutineScopeTest {
25 | @get:Rule val mainActivity = ActivityScenarioRule(MainActivity::class.java)
26 |
27 | @Test fun shouldLifecycleCoroutineScopeContainerSurviveRecreation() {
28 | var beforeRecreation: CoroutineScope? = null
29 | var afterRecreation: CoroutineScope? = null
30 |
31 | mainActivity.scenario.run {
32 | onActivity {
33 | beforeRecreation = it.backstack.activityRetainedComponent.coroutineScope()
34 | }
35 |
36 | recreate()
37 |
38 | onActivity {
39 | afterRecreation = it.backstack.activityRetainedComponent.coroutineScope()
40 | }
41 | }
42 |
43 | assertAll {
44 | assertThat(beforeRecreation).isNotNull()
45 | assertThat(beforeRecreation).isSameAs(afterRecreation)
46 | assertThat(afterRecreation!!.isActive).isTrue()
47 | }
48 | }
49 |
50 | // ~> I don't know if moveToState(DESTROYED) really simulates process death. Maybe!
51 | // But it should work just fine for our use case.
52 | @Test fun shouldLifecycleCoroutineScopeContainerBeCancelledAfterProcessDeath() {
53 | var uiCoroutineScope: CoroutineScope? = null
54 |
55 | mainActivity.scenario.run {
56 | recreate()
57 | moveToState(Lifecycle.State.RESUMED)
58 | onActivity { activity ->
59 | uiCoroutineScope = activity
60 | .backstack
61 | .activityRetainedComponent
62 | .coroutineScope()
63 | }
64 | moveToState(Lifecycle.State.DESTROYED)
65 | }
66 |
67 | assertThat {
68 | assertThat(uiCoroutineScope).isNotNull()
69 | assertThat(uiCoroutineScope!!.isActive).isFalse()
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/welcome/WitnessRobot.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.welcome
2 |
3 | import androidx.test.core.app.ActivityScenario
4 | import androidx.test.espresso.Espresso.onView
5 | import androidx.test.espresso.action.ViewActions.click
6 | import androidx.test.espresso.assertion.ViewAssertions.matches
7 | import androidx.test.espresso.matcher.ViewMatchers
8 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
9 | import app.boletinhos.R
10 | import app.boletinhos.bill.add.AddBillViewKey
11 | import app.boletinhos.main.MainActivity
12 | import app.boletinhos.navigation.ViewKey
13 | import app.boletinhos.summary.SummaryViewKey
14 | import assertk.assertThat
15 | import assertk.assertions.isInstanceOf
16 | import com.zhuinden.simplestack.History
17 | import com.zhuinden.simplestack.StateChange
18 | import com.zhuinden.simplestackextensions.navigatorktx.backstack
19 | import com.zhuinden.simplestackextensions.servicesktx.lookup
20 | import javax.inject.Inject
21 |
22 | class WitnessRobot @Inject constructor() {
23 | private val welcomeViewKey = WelcomeViewKey()
24 |
25 | fun launchWelcome(withScenario: ActivityScenario) = apply {
26 | withScenario.onActivity { activity ->
27 | activity.backstack.setHistory(History.single(welcomeViewKey), StateChange.FORWARD)
28 | }
29 | }
30 |
31 | fun checkIfTitleAndMessageIsShown() = apply {
32 | val title = R.string.text_welcome_title
33 | val message = R.string.text_welcome_message
34 | val isViewDisplayed = matches(isDisplayed())
35 |
36 | onView(ViewMatchers.withText(title)).check(isViewDisplayed)
37 | onView(ViewMatchers.withText(message)).check(isViewDisplayed)
38 | }
39 |
40 | fun checkIfNavigatedToAddBillScreen(withScenario: ActivityScenario) = apply {
41 | withScenario.onActivity { activity ->
42 | val backstack = activity.backstack
43 | assertThat(backstack.top()).isInstanceOf(AddBillViewKey::class.java)
44 | }
45 | }
46 |
47 | fun checkIfNavigatedToSummary(withScenario: ActivityScenario) = apply {
48 | withScenario.onActivity { activity ->
49 | val backstack = activity.backstack
50 | assertThat(backstack.top()).isInstanceOf(SummaryViewKey::class.java)
51 | }
52 | }
53 |
54 | fun tapOnAddBillAction() = apply {
55 | onView(ViewMatchers.withId(R.id.action_add_bill)).perform(click())
56 | }
57 |
58 | fun simulateBillCreated(withScenario: ActivityScenario) = apply {
59 | withScenario.onActivity { activity ->
60 | val welcomeViewModel = activity.backstack.lookup()
61 | welcomeViewModel.onBillCreated()
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/android-ui/src/androidTest/java/app/boletinhos/widget/currency/QuindimRobot.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.widget.currency
2 |
3 | import android.view.Gravity
4 | import android.view.KeyEvent
5 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT
6 | import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
7 | import android.widget.FrameLayout
8 | import androidx.test.core.app.ActivityScenario
9 | import androidx.test.espresso.Espresso
10 | import androidx.test.espresso.action.ViewActions
11 | import androidx.test.espresso.assertion.ViewAssertions
12 | import androidx.test.espresso.matcher.ViewMatchers
13 | import app.boletinhos.R
14 | import app.boletinhos.testutil.TestActivity
15 | import app.boletinhos.testutil.currencyInputHasRawValue
16 | import app.boletinhos.testutil.inputLayoutHasPrefix
17 | import app.boletinhos.testutil.textInputHasTextValue
18 | import org.hamcrest.Matchers
19 | import java.util.Locale
20 |
21 | /* Android Q @_@ */
22 | class QuindimRobot(private val scenario: ActivityScenario) {
23 | private lateinit var locale: Locale
24 | private lateinit var currencyInput: CurrencyInput
25 |
26 | fun launchApp(withLocale: Locale = Locale.US) = apply {
27 | configureLocale(withLocale)
28 | launchActivityAndShowCurrencyInput(scenario)
29 | }
30 |
31 | private fun configureLocale(locale: Locale) {
32 | this.locale = locale
33 | Locale.setDefault(locale)
34 | }
35 |
36 | private fun launchActivityAndShowCurrencyInput(
37 | withScenario: ActivityScenario
38 | ) = apply {
39 | withScenario.onActivity { activity ->
40 | currencyInput = activity.createCurrencyInputView()
41 | activity.rootView.addView(currencyInput)
42 | }
43 | }
44 |
45 | private fun TestActivity.createCurrencyInputView(): CurrencyInput {
46 | return CurrencyInput(context = rootView.context, locale = locale).apply {
47 | id = VIEW_ID
48 | layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
49 | gravity = Gravity.CENTER
50 | }
51 | }
52 | }
53 |
54 | private fun view() = Espresso.onView(Matchers.allOf(ViewMatchers.withId(VIEW_ID)))
55 |
56 | private fun inputView() = Espresso.onView(Matchers.allOf(ViewMatchers.withId(INPUT_VIEW_ID)))
57 |
58 | fun checkIfCurrencySymbolIsShown(expectedSymbol: String) = apply {
59 | view().check(ViewAssertions.matches(inputLayoutHasPrefix(expectedSymbol)))
60 | }
61 |
62 | fun hasText(text: String) = apply {
63 | view().check(ViewAssertions.matches(textInputHasTextValue(text)))
64 | }
65 |
66 | fun hasRawValue(value: Long) = apply {
67 | view().check(ViewAssertions.matches(currencyInputHasRawValue(value)))
68 | }
69 |
70 | fun type(text: String) = apply {
71 | inputView().perform(ViewActions.typeText(text))
72 | }
73 |
74 | fun backspace() = apply {
75 | inputView().perform(ViewActions.pressKey(KeyEvent.KEYCODE_DEL))
76 | }
77 |
78 | private companion object {
79 | private const val VIEW_ID = 8
80 | private const val INPUT_VIEW_ID = R.id.input
81 | }
82 | }
--------------------------------------------------------------------------------
/android-ui/src/main/res/values/baseline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
51 |
--------------------------------------------------------------------------------
/bills-service/src/test/java/app/boletinhos/summary/FakePreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.summary
2 |
3 | import android.content.SharedPreferences
4 |
5 | object FakePreferences : SharedPreferences {
6 | private val prefs = mutableMapOf()
7 |
8 | override fun contains(key: String?): Boolean {
9 | return prefs.contains(key)
10 | }
11 |
12 | override fun getBoolean(key: String, defValue: Boolean): Boolean {
13 | return prefs[key] as? Boolean ?: defValue
14 | }
15 |
16 | override fun edit(): SharedPreferences.Editor {
17 | return Editor
18 | }
19 |
20 | override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
21 | TODO("Not implemented")
22 | }
23 |
24 | override fun getInt(key: String?, defValue: Int): Int {
25 | TODO("Not implemented")
26 | }
27 |
28 | override fun getAll(): MutableMap {
29 | TODO("Not implemented")
30 | }
31 |
32 | override fun getLong(key: String?, defValue: Long): Long {
33 | return prefs[key] as? Long ?: defValue
34 | }
35 |
36 | override fun getFloat(key: String?, defValue: Float): Float {
37 | TODO("Not implemented")
38 | }
39 |
40 | override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet {
41 | TODO("Not implemented")
42 | }
43 |
44 | override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
45 | TODO("Not implemented")
46 | }
47 |
48 | override fun getString(key: String?, defValue: String?): String? {
49 | TODO("Not implemented")
50 | }
51 |
52 | object Editor : SharedPreferences.Editor {
53 | private val temp = mutableMapOf()
54 |
55 | override fun apply() {
56 | prefs.putAll(temp)
57 | }
58 |
59 | override fun putBoolean(key: String, value: Boolean) = apply {
60 | TODO("Not implemented")
61 | }
62 |
63 | override fun clear() = apply {
64 | temp.clear()
65 | prefs.clear()
66 | }
67 |
68 | override fun putLong(key: String?, value: Long) = apply {
69 | temp[key!!] = value
70 | }
71 |
72 | override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
73 | TODO("Not implemented")
74 | }
75 |
76 | override fun remove(key: String?): SharedPreferences.Editor {
77 | TODO("Not implemented")
78 | }
79 |
80 | override fun putStringSet(
81 | key: String?,
82 | values: MutableSet?
83 | ): SharedPreferences.Editor {
84 | TODO("Not implemented")
85 | }
86 |
87 | override fun commit(): Boolean {
88 | TODO("Not implemented")
89 | }
90 |
91 | override fun putFloat(key: String?, value: Float): SharedPreferences.Editor {
92 | TODO("Not implemented")
93 | }
94 |
95 | override fun putString(key: String?, value: String?): SharedPreferences.Editor {
96 | TODO("Not implemented")
97 | }
98 |
99 | }
100 | }
--------------------------------------------------------------------------------
/preferences/src/test/java/app/boletinhos/preferences/FakePreferences.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.preferences
2 |
3 | import android.content.SharedPreferences
4 |
5 | object FakePreferences : SharedPreferences {
6 | private val prefs = mutableMapOf()
7 |
8 | override fun contains(key: String?): Boolean {
9 | return prefs.contains(key)
10 | }
11 |
12 | override fun getBoolean(key: String, defValue: Boolean): Boolean {
13 | return prefs[key] as? Boolean ?: defValue
14 | }
15 |
16 | override fun edit(): SharedPreferences.Editor {
17 | return Editor
18 | }
19 |
20 | override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
21 | TODO("Not implemented")
22 | }
23 |
24 | override fun getInt(key: String?, defValue: Int): Int {
25 | TODO("Not implemented")
26 | }
27 |
28 | override fun getAll(): MutableMap {
29 | TODO("Not implemented")
30 | }
31 |
32 | override fun getLong(key: String?, defValue: Long): Long {
33 | TODO("Not implemented")
34 | }
35 |
36 | override fun getFloat(key: String?, defValue: Float): Float {
37 | TODO("Not implemented")
38 | }
39 |
40 | override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet {
41 | TODO("Not implemented")
42 | }
43 |
44 | override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
45 | TODO("Not implemented")
46 | }
47 |
48 | override fun getString(key: String?, defValue: String?): String? {
49 | TODO("Not implemented")
50 | }
51 |
52 | object Editor : SharedPreferences.Editor {
53 | private val temp = mutableMapOf()
54 |
55 | override fun apply() {
56 | prefs.putAll(temp)
57 | }
58 |
59 | override fun putBoolean(key: String, value: Boolean) = apply {
60 | temp[key] = value
61 | }
62 |
63 | override fun clear() = apply {
64 | temp.clear()
65 | prefs.clear()
66 | }
67 |
68 | override fun putLong(key: String?, value: Long): SharedPreferences.Editor {
69 | TODO("Not implemented")
70 | }
71 |
72 | override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
73 | TODO("Not implemented")
74 | }
75 |
76 | override fun remove(key: String?): SharedPreferences.Editor {
77 | TODO("Not implemented")
78 | }
79 |
80 | override fun putStringSet(
81 | key: String?,
82 | values: MutableSet?
83 | ): SharedPreferences.Editor {
84 | TODO("Not implemented")
85 | }
86 |
87 | override fun commit(): Boolean {
88 | TODO("Not implemented")
89 | }
90 |
91 | override fun putFloat(key: String?, value: Float): SharedPreferences.Editor {
92 | TODO("Not implemented")
93 | }
94 |
95 | override fun putString(key: String?, value: String?): SharedPreferences.Editor {
96 | TODO("Not implemented")
97 | }
98 |
99 | }
100 | }
--------------------------------------------------------------------------------
/android-ui/src/main/java/app/boletinhos/bill/add/AddBillView.kt:
--------------------------------------------------------------------------------
1 | package app.boletinhos.bill.add
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.view.View
6 | import android.widget.RelativeLayout
7 | import app.boletinhos.databinding.BillAddContentBinding
8 | import app.boletinhos.ext.view.getString
9 | import app.boletinhos.ext.view.inflater
10 | import app.boletinhos.ext.view.service
11 | import app.boletinhos.messaging.UiEvent
12 | import app.boletinhos.navigation.viewScope
13 | import com.google.android.material.snackbar.Snackbar
14 | import kotlinx.coroutines.flow.Flow
15 | import kotlinx.coroutines.flow.launchIn
16 | import kotlinx.coroutines.flow.onEach
17 | import app.boletinhos.R.layout as Layouts
18 |
19 | class AddBillView(context: Context, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) {
20 | private val binding by lazy {
21 | BillAddContentBinding.bind(this)
22 | }
23 |
24 | private val inputs get() = sequenceOf(
25 | binding.inputBillName,
26 | binding.inputBillDescription,
27 | binding.inputBillValue,
28 | binding.inputBillDueDate
29 | )
30 |
31 | private val viewModel by service()
32 |
33 | override fun onAttachedToWindow() {
34 | super.onAttachedToWindow()
35 | showContentPreview()
36 | configureActions()
37 |
38 | viewModel.inputsErrors.handleInputsErrors()
39 | viewModel.messages.handleUiEvents()
40 | }
41 |
42 | private fun showContentPreview() {
43 | if (!isInEditMode) return
44 | inflater.inflate(Layouts.bill_add_content, this, true)
45 | }
46 |
47 | private fun configureActions() = with(binding) {
48 | actionCreateBill.setOnClickListener {
49 | val input = AddBillViewInput(
50 | value = inputBillValue.rawValue,
51 | name = inputBillName.value,
52 | description = inputBillDescription.value,
53 | dueDate = inputBillDueDate.date
54 | )
55 |
56 | viewModel.onAddBillActionClick(input)
57 | }
58 |
59 | toolbar.setNavigationOnClickListener { viewModel.onBackClick() }
60 | }
61 |
62 | private fun Flow.handleInputsErrors() {
63 | onEach { viewError -> showErrors(viewError?.errors) }.launchIn(viewScope)
64 | }
65 |
66 | private fun showErrors(errors: Map?) {
67 | if (errors == null || errors.isEmpty()) {
68 | inputs.forEach { it.error = null }
69 | return
70 | }
71 |
72 | errors.forEach { (inputId, fieldError) ->
73 | if (inputId == View.NO_ID) {
74 | showSnackbar(fieldError.messageRes)
75 | return@forEach
76 | }
77 |
78 | val (messageRes, value) = fieldError
79 | val message = value?.let { getString(messageRes, it) } ?: getString(messageRes)
80 |
81 | inputs.first { it.id == inputId }.error = message
82 | }
83 | }
84 |
85 | private fun Flow.handleUiEvents() {
86 | onEach { (messageRes) -> showSnackbar(messageRes) }.launchIn(viewScope)
87 | }
88 |
89 | private fun showSnackbar(messageRes: Int) {
90 | Snackbar.make(parent as View, messageRes, Snackbar.LENGTH_SHORT).show()
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/jacoco.gradle:
--------------------------------------------------------------------------------
1 | import config.Jacoco
2 |
3 | apply plugin: 'jacoco'
4 |
5 | jacoco {
6 | toolVersion = Jacoco.version
7 | }
8 |
9 | configure(subprojects) {
10 | apply plugin: 'jacoco'
11 |
12 | jacoco {
13 | toolVersion = Jacoco.version
14 | }
15 |
16 | tasks.withType(Test) {
17 | jacoco.includeNoLocationClasses = true
18 | }
19 |
20 | tasks.withType(Test) {
21 | jacoco.includeNoLocationClasses = true
22 | }
23 |
24 | task jacocoReport(type: JacocoReport) {
25 | reports {
26 | csv.enabled = true
27 | xml.enabled = false
28 | html.enabled = true
29 | }
30 |
31 | def exclusions = [
32 | 'android/**/*.*',
33 | '**/R.class',
34 | '**/R$*.class',
35 | '**/BuildConfig.*',
36 | '**/Manifest*.*',
37 | '**/*Test*.*',
38 | '**/*Module.*',
39 | '**/*Fake*.*',
40 | '**/*Module$Companion.*',
41 | '**/*Dagger*.*',
42 | '**/*MembersInjector*.*',
43 | '**/*_Provide*Factory*.*',
44 | '**/*_Factory.*',
45 | '**/*_MembersInjector.*',
46 | '**/Dagger*Component.*',
47 | '**/Dagger*Component$Builder.*',
48 | '**/*Module_*Factory$InstanceHolder.*',
49 | '**/injection/*',
50 | '**/di/*',
51 | '**/*Entity*.*' /* We'll ignore Entities because it is only representative. We'll not touch it. */
52 | ]
53 |
54 | def kotlinLibExclusions = [
55 | '**/*Test*.*',
56 | '**/*_Factory.*',
57 | '**/*Fake*.*',
58 | 'testutil/**'
59 | ]
60 |
61 | def javacTree = fileTree(dir: "$buildDir/intermediates/javac/debug", excludes: exclusions)
62 | def kotlinTree = fileTree(dir: "$buildDir/tmp/kotlin-classes/debug", excludes: exclusions)
63 | def kotlinLibTree = fileTree(dir: "$buildDir/classes/kotlin", excludes: kotlinLibExclusions)
64 | def mainSrc = "$project.projectDir/src/main/java"
65 |
66 | sourceDirectories.from = files([mainSrc])
67 | classDirectories.from = files([kotlinTree, javacTree, kotlinLibTree])
68 | executionData.from = fileTree(
69 | dir: buildDir,
70 | includes: [
71 | 'jacoco/testDebugUnitTest.exec',
72 | 'jacoco/test.exec',
73 | 'outputs/code-coverage/connected/*.ec'
74 | ]
75 | )
76 | }
77 | }
78 |
79 | task projectCodeCoverageReport(type: JacocoReport) {
80 | def modulesTask = subprojects.jacocoReport
81 |
82 | dependsOn modulesTask
83 |
84 | additionalSourceDirs.from = files(modulesTask.sourceDirectories)
85 | sourceDirectories.from = files(modulesTask.sourceDirectories)
86 |
87 | classDirectories.from = files(modulesTask.classDirectories)
88 | executionData.from = files(modulesTask.executionData)
89 |
90 | reports {
91 | html.enabled = true
92 | html.destination = file('build/reports/jacoco/html')
93 | xml.enabled = true
94 | xml.destination = file('build/reports/jacoco/boletinhos.xml')
95 | }
96 |
97 | doFirst {
98 | executionData.from = files(executionData.findAll { it.exists() })
99 | }
100 | }
--------------------------------------------------------------------------------