Banking-App-Mock-Compose is a mock android app written with Jetpack Compose
The primary purpose of the app is to develop a medium-size (~20K LoC) app using Jetpack Compose instead of Android Views and enjoy all Compose pitfalls :)
The app is built with a data -> domain <- ui architecture typical for real applications with only difference that there is no remote data source. Thus, all the banking info is mocked on data
layer. However, some data like Cards and Transactions is cached locally for persistence.
Demo video:
banking-app-demo.mp4
Design reference: https://www.figma.com/community/file/1106055934725589367/Finance-Mobile-App-UI-KIT by Aparna Tati
- Jetpack Compose
- compose-state-events by Leonard Palm
- Koin DI
- Ksprefs
- Jetpack Navigation 🫠
- Jetpack Security and Biometrics
- Jetpack Paging for Compose
- PermissionX plus own helpers
- Splashscreen API
- Coil
- Zxing (QR codes)
- WorkManager
Working credentials for the app:
Login:
example@mail.com
Password:1234567Ab
OTP:1111
Use other credentials if you want to trigger an error.
If app asks to scan QR:
(or use QR generated in Profile)- App core:
- Splash screen
- Navigation
- Operation handling core (OperationResult class)
- Popup messages (snackbar)
- Error UI
- App lock (PIN)
- App lock (biometrics)
- Data encryption
- Permissions management
- Onboarding slider
- Sign up with email
- Sign up with phone number
- Restore password
- OTP verification
- Home screen:
- Cards
- Savings
- Profile
- Balance observable
- Cards
- Card list
- Create card
- Delete card
- Default card
- Card Statistics
- Savings
- Savings list
- Saving details
- Account operations
- Send money
- Top up
- Request money
- Pay
- Transaction manager
- Transaction history
- Transaction contacts
- Profile
- Profile info
- QR scanner
- My QR
- Edit profile
- Account and security section
- Help section
- Notifications
- App Settings
- Logout
- Terms and Conditions (WebView)
The app uses Clean Architecture layers and MVI for presentation layer.
UI layer
The app contains a root AppContainerScreen
screen which servers for several purposes:
- Show loading screen when app state prepared on start.
- Prepare and pass conditional navigation to NavHost.
- Setup CompositionLocal for NavHost.
// Root State
sealed class AppState {
object Loading: AppState()
data class Ready(
val conditionalNavigation: ConditionalNavigation,
val requireUnlock: Boolean = false
): AppState()
data class InitFailure(val error: UiText): AppState()
}
// Root screen
@Composable
fun AppContainerScreen(viewModel: AppViewModel = koinViewModel()) {
// Init jetpack navigation
val navController = rememberNavController()
// Preparations for CompositionLocal
val snackBarHostState = remember { SnackbarHostState() }
val hostCoroutineScope = rememberCoroutineScope()
val permissionHelper = koinInject<PermissionHelper>()
val state = viewModel.appState.collectAsStateWithLifecycle().value
when (state) {
is AppState.Loading -> {
AppLoadingScreen()
}
is AppState.Ready -> {
CompositionLocalProvider(
// "Global" snackBar that survives navigation
LocalScopedSnackbarState provides ScopedSnackBarState(
value = snackBarHostState,
coroutineScope = hostCoroutineScope
),
// Permissions utils to check and request runtime permissions
LocalPermissionHelper provides permissionHelper
) {
if (state.requireUnlock) {
// Show app lock screen with PIN / biometrics
LockScreen(...)
}
else {
// Nav host with all screens of the app
AppNavHost(
navController = navController,
// Pass conditional navigation
// Here we may redirect to login screen
conditionalNavigation = state.conditionalNavigation,
...
)
}
}
}
// App init failed
// E.g. we'are checking access token validity and receive some error
is AppState.InitFailure -> {
ErrorFullScreen(
error = state.error,
onRetry = { viewModel.emitIntent(AppIntent.EnterApp) }
)
}
}
}
A typical screen is handled with 4 components:
- State - an object with screen data and user input
- Intent - an action triggered from screen (e.g. data load request or button click)
- Screen @Composable - screen's UI
- ViewModel - to manage UI's data in a lifecycle-aware way
Screen state is represented with data or sealed class:
// State
data class ScreenState(
val isLoading: Boolean = true,
val error: UiText? = null,
val displayedText: String? = null,
// One-off event
val someEvent: StateEvent = consumed
)
An Intent
is typically a sealed class to easilly handle it with exhaustive when
condition:
// Intent
sealed class ScreenIntent {
object Load: ScreenIntent()
object SomeClick: ScreenIntent()
...
}
// Then in ViewModel
when (intent) {
is Load -> { loadDataFromServer() }
is SomeClick -> { handleSomeClick() }
}
A screen composable typically contains a nested ScreenUi composable to make it work with @Preview
:
// Screen
@Composable
fun Screen(
// Inject ViewModel
viewModel: CardListViewModel = koinViewModel(),
// Navigation callbacks
onBack: () -> Unit,
onAddCard: () -> Unit,
) {
// Get composition local
val permissionHelper = LocalPermisionHelper.current
val context = LocalContext.current
...
// Collect the state from ViewModel
val state = viewModel.state.collectAsStateWithLifecycle().value
// Show ui
ScreenUi(state = state)
// Init data load with some effect
LaunchedEffect(Unit) {
viewModel.emitIntent(ScreenIntent.Load)
}
// The EventEffect is a LaunchedEffect that will be executed, when the event is in its triggered state.
// When the event action was executed the effect calls the onConsumed callback to force ViewMode to set the event field to be consumed.
EventEffect(
event = state.someEvent,
onConsumed = viewModel::onconsumeSomeEvent
) {
// Do action, e.g. show a snackbar notification
}
}
ViewModel gets data from one or several usecases and reduces it to state for ui. More complex cases may require Flows for multiple values and Deferred async/await for parallel requess.
class ScreenViewModel: ViewModel(
private val someUseCase: SomeUseCase
) {
private val _state = MutableStateFlow(ScreenState())
val state = _state.asStateFlow()
fun emitIntent(intent: ScreenIntent) {
when (intent) {
is ScreenIntent.Load {
viewModelScope.launch {
// This extension wraps data returned by use case into
// OperationResult class which encapsulates errors
val res = OperationResult.runWrapped {
someUsecase.execute()
}
when (res) {
is OperationResult.Success -> {
// res.data is some domain model which may be mapped to ui
val resUi = SomeUiModel.mapFromDomain(res.data)
reduceSuccess(resUi)
// Additionally, we may trigger some event on condition:
_state.update {
it.copy(someEvent = triggered)
}
}
is OperationResult.Failure -> {
// ErrorType is a domain error which should be mapped to error text
reduceError(res.error.errorType.asUiTextError())
}
}
}
}
// Here screen state is updated
fun reduceError(...) {}
fun reduceSuccess(...) {}
fun onConsumeSomeEvent() {
_state.update {
it.copy(someEvent = consumed)
}
}
}
}
}
Domain layer
Building blocks:
- Use cases
- Domain models
- Domain error handling
- Repository interfaces
A use case is a class for specific app feature. By using use cases the presentation layer remains unaware of the data source details.
class GetHomeCardsUseCase(private val cardsRepository: CardsRepository) {
// A use case contains only one public execute() method with a single responsibility
suspend fun execute(): List<PaymentCard> {
val allCards = cardsRepository.getCards()
val (primary, other) = allCards.partition { it.isPrimary }
val sortedPrimary = primary.sortedByDescending { it.addedDate }
val sortedOther = other.sortedByDescending { it.addedDate }
return (sortedPrimary + sortedOther).take(DISPLAYED_COUNT)
}
companion object {
private const val DISPLAYED_COUNT = 3
}
}
A domain OperationResult
model is used to represent result of a operation that may either succeed or finish with business logic error.
sealed class OperationResult<out T> {
data class Success<out T>(val data: T) : OperationResult<T>()
data class Failure(val error: AppError) : OperationResult<Nothing>()
fun isSuccess(): Boolean {
return when (this) {
is Success -> true
is Failure -> false
}
}
companion object {
inline fun <R> runWrapped(block: () -> R): OperationResult<R> {
return try {
val res = block()
Success(res)
} catch (e: Exception) {
when (e) {
is AppError -> Failure(e)
else -> Failure(AppError(ErrorType.fromThrowable(e)))
}
}
}
}
}
// Domain error types
enum class ErrorType {
USER_NOT_FOUND,
WRONG_PASSWORD,
CARD_NOT_FOUND,
INSUFFICIENT_CARD_BALANCE,
UNKNOWN_ERROR,;
companion object {
fun fromThrowable(e: Throwable): ErrorType {
// Here may be additional mapping depending on exception type
return when (e) {
is AppError -> e.errorType
else -> UNKNOWN_ERROR
}
}
}
}
AppError
is an exception that can be used on all layers of the application:
data class AppError(val errorType: ErrorType): Exception()
// We can throw AppError in repositories
// It will be later handled in business logic or ui
override suspend fun getCardById(id: String): PaymentCard = withContext(coroutineDispatcher) {
// Load card from DB or throw error
val card = cardsDao.getCardByNumber(id) ?: throw AppError(ErrorType.CARD_NOT_FOUND)
return@withContext mapCachedCardToDomain(card)
}
// Or detect AppError directly from exception
// Example of usage in ViewModel's CoroutineExceptionHandler
private val errorHandler = CoroutineExceptionHandler { _, throwable ->
reduceError(ErrorType.fromThrowable(throwable))
}
viewModelScope.launch(errorHandler) {
// Can safely call usecases here without wrapping in OperationResult
}
Domain layer also includes models for core app entities like Cards, Savings and balances. This is an example of domain model for money representation
// Wrapper class for money representation
// Used to encapsulation of chosen base types (Double, BigDecimal and so on)
// And to handle additional properties like currencies
data class MoneyAmount(
val value: Float,
val currency: BalanceCurrency = BalanceCurrency.USD,
) {
operator fun plus(other: MoneyAmount): MoneyAmount {
return this.copy(value = this.value + other.value)
}
operator fun minus(other: MoneyAmount): MoneyAmount {
return this.copy(value = this.value - other.value)
}
operator fun compareTo(other: MoneyAmount): Int {
return this.value.compareTo(other.value)
}
}
Data layer
Building blocks:
- Repository implemetations
- Cache models and DAOs
- Workers
This is an example of a mock repository implementation for transaction caching and execution:
class TransactionRepositoryMock(
private val workManager: WorkManager,
private val transactionDao: TransactionDao,
private val coroutineDispatcher: CoroutineDispatcher,
private val contactsRepository: ContactsRepository
) : TransactionRepository {
// Cache new transaction and start a Worker for it
override suspend fun submitTransaction(payload: TransactionRowPayload) {
val raw = TransactionEntity(
type = payload.type,
value = payload.amount,
linkedContactId = payload.contactId,
createdDate = System.currentTimeMillis(),
recentStatus = TransactionStatus.PENDING,
updatedStatusDate = System.currentTimeMillis(),
cardId = payload.cardId
)
val savedId = transactionDao.addTransaction(raw)
val data = Data.Builder()
.putLong(TransactionWorker.TRANSACTION_ID_KEY, savedId)
.build()
val workRequest =
OneTimeWorkRequestBuilder<TransactionWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(false)
.build()
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(data)
.build()
workManager.enqueue(workRequest)
}
// Load transactions from cache with paging
override suspend fun getTransactions(filterByType: TransactionType?): Flow<PagingData<Transaction>> {
val contacts = contactsRepository.getContacts()
return Pager(
config = PagingConfig(
pageSize = PAGE_MAX_SIZE,
prefetchDistance = PREFETCH_DISTANCE
),
pagingSourceFactory = {
TransactionSource(
filterByType = filterByType,
transactionDao = transactionDao
)
}
).flow.map {
it.map { cachedTx ->
mapTransactionFromCache(cachedTx, contacts)
}
}
}
// Get transaction status observer
override fun getTransactionStatusFlow(transactionId: Long): Flow<TransactionStatus> {
return flow {
// Emit last cached status
while (true) {
val tx = transactionDao.getTransaction(transactionId) ?: throw AppError(ErrorType.TRANSACTION_NOT_FOUND)
emit(tx.recentStatus)
delay(MOCK_TRANSACTION_STATUS_CHECK_DELAY)
}
}.flowOn(coroutineDispatcher)
}
private fun mapTransactionFromCache(entity: TransactionEntity, contacts: List<Contact>) : Transaction {
return Transaction(
id = entity.id,
type = entity.type,
value = entity.value,
recentStatus = entity.recentStatus,
linkedContact = when (entity.type) {
TransactionType.TOP_UP -> null
else -> entity.linkedContactId?.let { id -> contacts.find { contact -> contact.id == id } }
},
createdDate = entity.createdDate,
updatedStatusDate = entity.updatedStatusDate
)
}
companion object {
private const val PAGE_MAX_SIZE = 5
private const val PREFETCH_DISTANCE = 1
private const val MOCK_TRANSACTION_STATUS_CHECK_DELAY = 5000L
}
}
App lock
Used material: https://habr.com/ru/companies/redmadrobot/articles/475112/
PIN lock:
- use Password-Based Key Derivation Function to generate
serect
from PIN andsalt
- use
EncryptedSharedPreferences
to store secrect. So that PIN not stored in raw way.
private fun savePin(pin: String) {
val salt = CryptoUtils.generateSalt()
val secretKey = CryptoUtils.generatePbkdf2Key(
passphraseOrPin = pin.toCharArray(),
salt = salt
)
val encodedPinData = Base64.encodeToString(secretKey.encoded, Base64.DEFAULT)
val encodedSalt = Base64.encodeToString(salt, Base64.DEFAULT)
securedPreferences.edit()
.putString(PIN_KEY, encodedPinData)
.putString(PIN_SALT_KEY, encodedSalt)
.apply()
}
private fun isPinValid(pin: String): Boolean {
val storedSalt = securedPreferences.getString(PIN_SALT_KEY, null)
val decodedSalt = Base64.decode(storedSalt, Base64.DEFAULT)
val storedPinData = securedPreferences.getString(PIN_KEY, null)
val decodedPinData = Base64.decode(storedPinData, Base64.DEFAULT)
val enteredPinData = CryptoUtils.generatePbkdf2Key(pin.toCharArray(), decodedSalt)
return decodedPinData contentEquals enteredPinData.encoded
}
Biometrics:
- use
androidx.biometric
to authenticate
CryptoObject
from biometrics auth result is not used for PIN generation or data encryption. Biometric auth relies solely ononAuthenticationSucceeded(...)
callback.- No data encryption provied (except EncryptedSharedPreferences).
This implementation is very basic and not appropriate for a real banking app. In this app it just used to showcase lock screen feature.
Permissions
App uses PermissionHelper
class with PermissionX
library and some custom logic under the hood.
val LocalPermissionHelper = compositionLocalOf<PermissionHelper> { error("No PermissionHelper provided") }
// use in Composable
val permissionHelper = LocalPermissionHelper.current
val permission = android.Manifest.permission.CAMERA
when (permissionHelper.checkIfPermissionGranted(context, permission)) {
CheckPermissionResult.SHOULD_ASK_PERMISSION -> {
permissionHelper.askForPermission(context, permission) { res ->
when (res) {
AskPermissionResult.GRANTED -> { ... }
AskPermissionResult.REJECTED -> { ... }
}
}
}
CheckPermissionResult.SHOULD_REDIRECT_TO_SETTINGS -> { ... }
CheckPermissionResult.PERMISSION_ALREADY_GRANTED -> { ... }
}
- Authenticate me. If you can…
- Android: Simple MVI implementation with Jetpack Compose
- Animations in Jetpack Compose with examples
- Clickable SpannableText in Jetpack Compose
- Custom UI Component - Canvas and Jetpack Compose
- Exception handling in Kotlin Coroutines
- Field Validations using Jetpack Compose and MVVM
- Firebase Analytics + Jetpack Compose
- Formatting credit card number input in Jetpack compose Android
- How to Render Text as a QR code in Jetpack Compose
- How to Validate Fields Using Jetpack Compose in Android
- Input Validation With Clean Architecture In Jetpack Compose
- Jetpack Compose OTP input field
- Kotlin Coroutines patterns & anti-patterns
- Navigate back with result with Jetpack Compose
- Paging With Clean Architecture In Jetpack Compose
- Parallel API calls using Coroutines, having different return types