Blog Infos
Author
Published
Topics
Published
Topics

Created by Veo

Hello Folks! I feel like sharing a meal with friends after work is such a classic way to connect, and I’ve hit on a really smart point about splitting the bill. Keeping things clear and fair upfront definitely helps those friendships last. Let’s dive into the simple split bill application mechanism to figure out how this system will be work.

Image Generated by Gemini

In this article, will explain step by step to build simple application. But before that let’s start with knowing the tech stack and setup the requirement first.

Introduction

Compose is a powerful UI toolkit that streamlines the development of our app’s visual elements, instead of manually detailing every drawing instruction, we declaratively describe how the UI should appear, and compose intelligently handles the complex task of drawing it on screen, an efficient design paradigm especially relevant as intelligent and adaptive applications are increasingly expected in the AI era.

This article will teaches how to build smart applications using the Gemini API to extract the image and converting as JSON format. We will create modern user interfaces with Jetpack Compose and unlock the power of AI directly on user devices for faster, and highly responsive Android experiences.

Get Set Up

There are two ways to build this app using starter code or build it from scratch.

If you decide to build using starter code follow the instruction below :

  1. Prepare your android studio and try at least you can compile Hello World! (During temporary disable Android Studio on Cloud)
  2. The initial code for this workshop can be found in the branch starter directory within the SplitBill GitHub repository. To clone the repo, run the following command:
    git clone https://github.com/veroanggraa/DemoSplitBillApp.git
  3. Alternatively, you can download the repository as a ZIP file.
  4. After starting Android Studio, import the project, selecting just the DemoSplitBillApp/starter directory. The DemoSplitBillApp/final-result directory contains the solution code, which you can reference at any point if you get stuck or just want to see the full project.

If you want to build from scratch for deep understanding, follow the instruction below:

  • Let’s open up Android Studio. Once it’s loaded, we’ll navigate to the menu to begin our creation process select File → New Project → Phone and Tablet → Empty Activity.

  • Giving the project a name! We can pick anything that makes sense for the app. Maybe something that clearly tells us what the app does, but for now, let’s keep it simple and easy to understand like “Demo Split Bill App”

  • After that, it’s a really good idea to test it out. We can do this in two ways, using an emulator or by plugging in our real phone or tablet. But I suggest using real device because this app will access camera to scan the bill.
Library Setup

Let’s add some special tools to set up the library dependencies using something called a version catalog, which helps us keep everything neat and organized.

  • Compose ViewModel Library
    Add the following code below in the file TOML

 

 

[versions]
versionViewmodel = "2.8.7"
versionRuntimeLifecycle = "2.8.7"

[libraries]
# view model
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "versionViewmodel" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "versionRuntimeLifecycle" }

 

  • Compose Navigation Library
    Add the following code below in the file TOML

 

[versions]
navigationVersion = "2.8.9"

[libraries]
# navigation
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationVersion" }

 

  • Compose Coroutine Library
    Add the following code below in the file TOML

 

[versions]
versionCoroutine = "1.7.3"

[libraries]
# coroutine
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "versionCoroutine" }

 

  • Generative AI Library
    Add the following code below in the file TOML

 

[versions]
generativeAiVersion = "0.3.0"

[libraries]
# generative ai
generativeai = {group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeAiVersion"}

 

  • Accompanist Library
    Add the following code below in the file TOML

 

[versions]
accompanistVersion = "0.22.0-rc"

[libraries]
# permission
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistVersion" }

 

  • Serialization Library and Plugin
    Add the following code below in the file TOML

 

[versions]
kotlinxSerializationJson = "1.6.3"
kotlinxSerialization = "1.9.22"

[libraries]
# json
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization" }

 

  • Compose Camera Library
    Add the following code below in the file TOML

 

[versions]
cameraVersion = "1.3.3"

[libraries]
# camera
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraVersion" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraVersion" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraVersion" }
camera-lifecycle = {group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraVersion"}

 

After declare the library and version the next step is to actually use them. We’ll do this by calling their names in the Gradle file, which is like telling the app, “Hey, bring these tools into the build! ☺️”

  • Call the plugin and library on gradle module:app.

 

plugins {
...
    // additional plugin
    alias(libs.plugins.kotlin.serialization)
}
dependencies {
...
    // view model
    implementation(libs.androidx.lifecycle.runtime.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)

    // camerax
    implementation(libs.camera.core)
    implementation(libs.camera.camera2)
    implementation(libs.camera.view)
    implementation(libs.camera.lifecycle)

    // genAI
    implementation(libs.generativeai)

    // coroutine
    implementation(libs.kotlinx.coroutines.android)

    // json
    implementation(libs.kotlinx.serialization.json)

    // permission
    implementation(libs.accompanist.permissions)

    // navigation
    implementation(libs.androidx.navigation.compose)
...
}

 

  • Also put the additional plugin on the gradle :project, to avoid double implementation put false value like the code below.

 

plugins {
...
    alias(libs.plugins.kotlin.serialization) apply false
}

 

Permission Setup

This app using the camera and the internet, so we need to ask for permission. We’ll do this inside the Manifest file, it’s super important to add these lines there, so our app can open to the camera and connect to the network when we need it to.

<uses-feature
    android:name="android.hardware.camera"
    android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
Create Application Landing Page

Now, We’re ready to create what users will see first. This is often called the landing screen. Think of it as the welcome page for our app, and on this screen, we’ll put a special button Add Bill. When user taps this button, it will open the camera so user can scan a bill. So, our goal for this first screen is to make a simple that leads right to the bill scanning feature. It’ll look something like the picture below.

This article doesn’t go deep into how to build the look of the app with Jetpack Compose. If you’re eager to learn more about that, check out these other articles for clearer explanations.

Okay, let’s start building the very first page of this app. This screen will have three main things such as a image, text, and a button to scan bills.

  • Let’s start with the small component, this app will have a few buttons, but they’ll all look the same. To save us from writing the same code over and over again, we can just create one special button design and then use that same design everywhere we need a button. This way, we build it once and use it many times, making our work much easier. So let’s create new package and file. Right click → New Package → give name as component. Then create new file for Button, with Right Click → New → Kotlin File/Class → give name as Button. Create composable function with RectButtonTextFilled as name, and put the following code below.

 

@Composable
fun RectButtonTextFilled(
    modifier: Modifier = Modifier,
    onClick: () -> Unit,
    label: String,
    colorButton: Color,
    colorLabel: Color,
    padding: Dp,
    height: Dp
) {
    TextButton(
        onClick = onClick,
        modifier
            .fillMaxWidth()
            .padding(start = padding, end = padding)
            .clip(RoundedCornerShape(4.dp))
            .padding()
            .height(height)
            .background(color = colorButton)
    ) {
        Text(
            text = label, style = TextStyle(
                fontSize = 15.sp,
                fontWeight = FontWeight.SemiBold,
                color = colorLabel
            )
        )
    }
}

 

  • Let’s make our app’s colors consistent and easy to change, we’ll store all our color codes in ui.theme/Color.kt file. This means instead of typing the color code every time we need it, we’ll just give it a name in that file, and then we can use that name anywhere in our app.

 

// add new color
val BlueDark = Color(0xFF07689F)
val Black3C4E57 = Color(0xFF3C4E57)
val GrayB6B7B8 = Color(0xFFB6B7B8)

 

  • To make things super easy, we’ll use a special tool in Jetpack Compose called Scaffold, which is like a template that already has spaces for a title bar at the top and the main content area where we’ll put our image, text, and button. Implement the component button and other component on SplitBillLandingScreen

 

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SplitBillLandingScreen(modifier: Modifier = Modifier, navController: NavController) {
    Scaffold(
        modifier = modifier.fillMaxWidth(),
        containerColor = Color.White,
        contentColor = Color.White,
        topBar = {
            CenterAlignedTopAppBar(
                modifier = modifier.background(Color.White),
                title = {
                    Text(
                        text = "Split Bill",
                        fontSize = 17.sp,
                        modifier = modifier.padding(top = 40.dp)
                    )
                }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                    containerColor = Color.White,
                    titleContentColor = Color.Black
                )
            )
        },
        content = { innerPadding ->
            Column(modifier = modifier.fillMaxSize()) {
                Spacer(modifier = modifier.weight(1f))
                Image(
                    modifier = modifier
                        .size(200.dp)
                        .align(Alignment.CenterHorizontally),
                    painter = painterResource(R.drawable.ic_illustration_scan),
                    contentDescription = null
                )
                Spacer(modifier = modifier.height(20.dp))
                Text(
                    modifier = modifier.align(Alignment.CenterHorizontally),
                    text = "You have no active bill.\nCreate a new one by scanning or \nimportant bill photo from your album.",
                    textAlign = TextAlign.Center,
                    fontSize = 15.sp,
                    color = Black3C4E57
                )
                Spacer(modifier = modifier.height(40.dp))
                RectButtonTextFilled(
                    modifier = modifier.align(Alignment.CenterHorizontally),
                    onClick = {
                        navController.navigate("split_bill_main")
                    },
                    label = "Add Bill",
                    colorButton = BlueDark,
                    colorLabel = Color.White,
                    padding = 120.dp,
                    height = 48.dp
                )
                Spacer(modifier = modifier.weight(1f))
            }
        })
}

 

The code above defines a SplitBillLandingScreen that serves as the initial view for a bill-splitting application. It features a Scaffold with a centered top app bar titled Split Bill. The main content displays an illustration (presumably an icon related to scanning or bills), a central text message indicating that no active bills exist and prompting the user to create a new one by scanning or importing a bill photo, and a Add Bill button at the bottom. The layout uses Spacer with weights to distribute content vertically, ensuring the illustration and text are centered, and the button is positioned appropriately, making for a clean and user-friendly landing experience.

Create Scan Bill Page
  • We’ll set up the camera so we can see our bill on the screen. Then, we’ll snap a picture to pull all the information from it. Before we do that, we’ll create a cool animation line to show the progress as we capture the image, Right Click → New → package → give name (component) → Right click on component package → New → kotlin class/file → choose file give name (Animation.kt). Then put the following code insid ethe Animation file.

 

@Composable
fun ScanLineAnimation(modifier: Modifier = Modifier) {
    val infiniteTransition = rememberInfiniteTransition(label = "scan_line_animation")
    val offsetY by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ), label = "offset_y"
    )

    Box(
        modifier = modifier
            .fillMaxSize()
            .padding(horizontal = 32.dp, vertical = 64.dp)
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            val lineHeight = 4.dp.toPx()
            val currentY = size.height * offsetY

            drawLine(
                color = Color.Green,
                start = Offset(0f, currentY),
                end = Offset(size.width, currentY),
                strokeWidth = lineHeight
            )

            drawRect(
                color = Color.White.copy(alpha = 0.3f),
                style = Stroke(width = 2.dp.toPx())
            )
        }
    }
}

 

Let’s figure out what all this code does :

  1. rememberInfiniteTransition, this as the main engine for our animation to keep the scanning line moving forever, so we don’t have to worry about it stopping.
  2. animateFloat, this like a race for our scanning line. We set its starting point at 0 (the top of the box) and the finish line at 1 (the bottom). The code is to move that line smoothly through all the points in between
  3. tween(durationMillis = 2000), this code to set the speed of the animation and scanning line will take 2 seconds to go from the top to the bottom.
  4. repeatMode = RepeatMode.Reverse, this code makes it gracefully reverse direction and move back up, instead of the line suddenly jumping back to the top.
  5. Canvas, this code to draw the shapes we need, like a drawing board that lets us create the scanning line and a nice rectangle around it.
  6. drawLine, this code to draw the scanning line, declare it where to start and where to end. Beside that he currentY value, which is constantly changing to animation, is what makes the line move up and down.
  7. drawRect, this code is to create a frame. The frame shape is a simple white rectangle that gives the scanning animation a nice, polished border.
  • Now, we’ll build the camera screen, so we can see a live preview of what about to capture bill. But before that, we’ll check for camera permission, and if we don’t have it yet, a quick pop-up will appear on the screen. Now let’s Right Click → New -> Package (Give name feature/scan). Then Right Click on scan package → New → File/Class → give name SplitBillMainScreen.kt, and put the following code.

 

...
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
... 

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionDialog(
    onDismissRequest: () -> Unit,
    cameraPermissionState: PermissionState
) {
    AlertDialog(
        onDismissRequest = onDismissRequest,
        title = { Text(text = "Permission Required") },
        text = { Text(text = "This app needs camera access to scan your bills.") },
        confirmButton = {
            TextButton(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                Text("Grant")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismissRequest) {
                Text("Cancel")
            }
        }
    )
}
...

 

The code above creates a pop-up window that asks for camera permission. It’s a friendly way to remind user why the app needs access to the camera. Let’s break down the code below :

  1. rememberPermissionState, this code is to keep an eye on the camera permission, the tool that tells us if we already have permission, or if we need to ask for it.
  2. @Composable fun CameraPermissionDialog, this is the function that builds the pop-up window and it needs the cameraPermissionState so it knows exactly which permission it’s dealing with.
  3. AlertDialog, this code is component from Android, to get a simple, clean pop-up window.
  4. title, this code for the title to set a clear headline for the pop-up, telling the user upfront that a permission is needed.
  5. text, this code for the text to give a simple, clear message This app needs camera access to scan your bills.
  6. confirmButton, this is the code for the main button, and the goal is to let the user say YES and it will be we open the permission pop-up again, giving user a second opportunity to approve camera access and get started.
  7. dismissButton, this code lets the user choose to not grant permission and will simply close the pop-up.
  • Next step is create the code that handles everything for camera permission. Our main goal is to show the camera screen, but we’ll do it smartly by checking if we have permission first. Still inside the same file, put the following code.

 

...
LaunchedEffect(Unit) {
        cameraPermissionState.launchPermissionRequest()
    }
...

 

The code above is asking for permission as soon as the screen loads. This is a crucial first step to get the process rolling.

... 
Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        modifier = modifier
    ) { paddingValues ->
        Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
            when {
                cameraPermissionState.hasPermission -> {
                    CameraScanScreen(
                       ...
                    )
                }

                cameraPermissionState.shouldShowRationale -> {
                    CameraPermissionDialog(
                        onDismissRequest = {},
                        cameraPermissionState = cameraPermissionState
                    )
                }

                else -> { 
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(text = "Camera permission is required to scan bills.")
                        Spacer(modifier = Modifier.height(8.dp))
                        Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                            Text(text = "Grant Permission")
                        }
                    }
                }
            }
...

The code above then checks the permission status and displays one of three different screens based on the result:

  1. cameraPermissionState.hasPermissionIf the user has already said YES, the app shows the CameraScanScreen. This is the screen with the live camera preview where the user can start scanning a bill.
  2. cameraPermissionState.shouldShowRationale, If the user previously denied permission, the app shows the CameraPermissionDialog. This is a pop-up that politely explains why the app needs camera access, giving the user another chance to say YES.
  3. Permission Denied Permanently, If the user has denied permission and also selected don’t ask again, the app can’t show the dialog anymore. Instead, it displays a simple message and a button that guides the user to the app’s settings and can manually grant the permission if user change their mind.
  • The full code inside of the SplitBillMainScreen it will be look like the code below.

 

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SplitBillMainScreen(
    modifier: Modifier = Modifier,
    navController: NavController,
    viewModel: SplitBillViewModel
) {
    val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
    val uiState by viewModel.uiState.collectAsState()
    val snackbarHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()
    LaunchedEffect(Unit) {
        cameraPermissionState.launchPermissionRequest()
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        modifier = modifier
    ) { paddingValues ->
        Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
            when {
                cameraPermissionState.hasPermission -> {
                    CameraScanScreen(
                        onBillScanned = { imageProxy ->
                            viewModel.extractBillDataFromImage(imageProxy)
                        },
                        uiState = uiState
                    )
                }

                cameraPermissionState.shouldShowRationale -> {
                    CameraPermissionDialog(
                        onDismissRequest = {},
                        cameraPermissionState = cameraPermissionState
                    )
                }

                else -> { 
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(text = "Camera permission is required to scan bills.")
                        Spacer(modifier = Modifier.height(8.dp))
                        Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                            Text(text = "Grant Permission")
                        }
                    }
                }
            }

            if (uiState is SplitBillUiState.Loading) {
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier.fillMaxSize()
                ) {
                    CircularProgressIndicator()
                }
            }
        }
    }


    LaunchedEffect(uiState) {
        when (val currentUiState = uiState) {
            is SplitBillUiState.Success -> {
                if (navController.currentDestination?.route != Screen.BillResultScreen.route) {
                    navController.navigate(Screen.BillResultScreen.route)
                }
            }

            is SplitBillUiState.Error -> {
                scope.launch {
                    snackbarHostState.showSnackbar(
                        message = currentUiState.message,
                        duration = SnackbarDuration.Long,
                        actionLabel = "Retry"
                    )
                }
            }

            else -> {
            }
        }
    }
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner, viewModel) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_PAUSE) {
            } else if (event == Lifecycle.Event.ON_RESUME) {
                if (uiState !is SplitBillUiState.Success && uiState !is SplitBillUiState.Loading) {
                    viewModel.resetScanState()
                }
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionDialog(
    onDismissRequest: () -> Unit,
    cameraPermissionState: PermissionState
) {
    AlertDialog(
        onDismissRequest = onDismissRequest,
        title = { Text(text = "Permission Required") },
        text = { Text(text = "This app needs camera access to scan your bills.") },
        confirmButton = {
            TextButton(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                Text("Grant")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismissRequest) {
                Text("Cancel")
            }
        }
    )
}

@Composable
fun CameraScanScreen(
    modifier: Modifier = Modifier,
    onBillScanned: (ImageProxy) -> Unit,
    uiState: SplitBillUiState
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
    val cameraExecutor = remember { Executors.newSingleThreadExecutor() }

    DisposableEffect(Unit) {
        onDispose {
            cameraExecutor.shutdown()
        }
    }

    Box(modifier = modifier.fillMaxSize()) {
        AndroidView(
            modifier = Modifier.fillMaxSize(),
            factory = { ctx ->
                val previewView = PreviewView(ctx).apply {
                    this.scaleType = PreviewView.ScaleType.FILL_CENTER
                }
                cameraProviderFuture.addListener({
                    try {
                        val cameraProvider = cameraProviderFuture.get()
                        val preview = Preview.Builder().build().also {
                            it.setSurfaceProvider(previewView.surfaceProvider)
                        }

                        val imageAnalyzer = ImageAnalysis.Builder()
                            .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                            .build()
                            .also { analyzer ->
                                analyzer.setAnalyzer(cameraExecutor) { imageProxy ->
                                    onBillScanned(imageProxy)
                                }
                            }

                        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                        cameraProvider.unbindAll()
                        cameraProvider.bindToLifecycle(
                            lifecycleOwner, cameraSelector, preview, imageAnalyzer
                        )
                        Log.d("CameraScanScreen", "Camera bound to lifecycle.")
                    } catch (e: Exception) {
                        Log.e("CameraScanScreen", "Use case binding failed", e)
                    }
                }, ContextCompat.getMainExecutor(ctx))
                previewView
            }
        )
        ScanLineAnimation(modifier = Modifier.fillMaxSize())

 

  • Before we build the scanning logic, we’ll create some data models. We’ll make one for the Bill Detail and another for the Item Bill Detail to store all the information we get from the scanned bill. Let’s Right Click → New -> Package (Give name model), then Right Click on model package → New → Data Class → Give name as BillItem and put the code below.

 

import kotlinx.serialization.Serializable

@Serializable
data class BillItem(
    val name: String = "",
    val unitPrice: String = "0.00",
    val quantity: String = "1",
    val totalPrice: String = "0.00"
)

 

Let’s break the code above :

  1. @Serializable, this annotation is for tells the app how to save or share the data and it makes it easy to convert the BillItem into a JSON.
  2. data class BillItemthis creates a simple data class, which is a perfect fit for holding data.
  3. val name: String, this code is creates a space to store the name of the item and set to an empty string by default, so if there’s no name, it won’t cause an error.
  4. val unitPrice: String = “0.00”, this code is the price of a single unit of the item and the data type use is a string here to handle currency, and it defaults to “0.00″.
  5. val quantity: String = “1”, this code is holds how many of the item were purchased and the defaults value is “1”.
  6. val totalPrice: String = “0.00”, this code stores the total cost for the item and the default value is “0.00”.
  • After create the Bill Item, let’s create another data class BillDetails, start with the Right Click on model package → New → Data Class → Give name as BillDetail and put the code below.

 

import kotlinx.serialization.Serializable

@Serializable
data class BillDetails(
    val dateTime: String = "N/A",
    val restaurantName: String = "",
    val items: List<BillItem> = emptyList(),
    val subTotal: String = "0.00",
    val tax: String = "0.00",
    val service: String = "0.00",
    val discount: String = "0.00",
    val others: String = "0.00",
    val total: String = "0.00"
)

 

To have better understanding, let’s break the code above :

  1. data class BillDetailsthis code for creates a simple data class, which is perfect for holding information about bill details.
  2. val dateTime: String = “N/A”, this code for storing the date and time of the bill and the default value is N/A.
  3. val restaurantName: String = “”this code is where the name of the restaurant or store goes and the default value is an empty string.
  4. val items: List<BillItem> = emptyList(), this code is a list that holds all the each items from the bill detail, it uses the BillItem that already create before. It starts as an empty list and ready to be filled with items.
  5. val subTotal: String = “0.00”, this code is stores the subtotal of the bill.
  6. val tax: String = “0.00”, this code is stores the tax amount.
  7. val service: String = “0.00”this code is stores any service fees.
  8. val discount: String = “0.00”, this code is stores any discount amount.
  9. val others: String = “0.00”, this code is a space for any other charges.
  10. val total: String = “0.00”, this stores the final total amount of the bill.
  • create something called a UI State for our Split Bill page. The goal of this is to keep track of what the screen should look like at any given moment. Let’s Right Click on feature/scan package → New → File/Class → interface → Give name as SplitBillUiState and put the code below.

 

import com.veroanggra.demosplitbillapp.model.BillDetails

sealed interface SplitBillUiState {
    data object Idle : SplitBillUiState
    data object Loading : SplitBillUiState
    data class Success(val billDetails: BillDetails) : SplitBillUiState
    data class Error(val message: String) : SplitBillUiState
}

 

The code above will set up four different states:

  1. Idle, this indicate the screen is ready and waiting for the user to do something.
  2. Loading, this indicate app currently processing the bill, so we’ll show a loading spinner.
  3. Success, this indicate the scan is complete, and we have the results ready to be displayed.
  4. Error, this indicate there is something went wrong, and we need to show an error message to the user.
  • On this part we need to create the api key from gemini, to get started, visit https://aistudio.google.com/. Once in there, look for the Get API key menu, and then click Create new API key.

 

  • Now, let’s build the logic that powers the scan bill page. We’ll put all of this logic inside the ViewModel to keep our code organized and easy to manage. Start with Right Click on scan package → New → File/Class → give name SplitBillViewModel.kt.
  • Then, we need to create a prompt that tells Gemini exactly what we want. The goal is to extract all the bill details from an image and convert that information into a neat JSON format (because our app only consume JSON format response), just like the example below. You can also try writing your own prompt!

 

private fun createExtractionPrompt(): String {
        return """
        Extract details from the bill image. Provide the output STRICTLY in a JSON format
        If info is not found, use "N/A" for strings or "0.00" for numbers.
        If the image is not a bill, return an empty JSON object: {}.
        DO NOT add any text or explanations outside the JSON object.
        """
    }
// Or try another just for your opstions
private fun createExtractionPrompt(): String {
        return """
        Extract details from the bill image. Provide the output STRICTLY in a JSON format.
        Example:
        {
          "restaurantName": "The Food Place",
          "dateTime": "2024-07-30 12:34 PM",
          "items": [
            {"name": "Burger", "quantity": "1", "unitPrice": "15.00", "totalPrice": "15.00"}
          ],
          "subTotal": "25.00",
          "tax": "2.50",
          "service": "3.00",
          "discount": "0.00",
          "others": "0.00",
          "total": "30.50"
        }
        If info is not found, use "N/A" for strings or "0.00" for numbers.
        If the image is not a bill, return an empty JSON object: {}.
        DO NOT add any text or explanations outside the JSON object.
        """
    }

 

  • Create the SplitBillViewModel class and have it extend ViewModel() like code below.

 

class SplitBillViewModel : ViewModel() {

}

 

  • The full code inside of the SplitBillViewModel it will be look like the code below.

 

class SplitBillViewModel : ViewModel() {
private val _uiState = MutableStateFlow<SplitBillUiState>(SplitBillUiState.Idle)
val uiState = _uiState.asStateFlow()
private val generativeModel = GenerativeModel(
modelName = "gemini-1.5-pro",
apiKey = "API-KEY"
)
fun extractBillDataFromImage(imageProxy: ImageProxy) {
if (_uiState.value is SplitBillUiState.Loading) {
imageProxy.close()
return
}
viewModelScope.launch {
_uiState.value = SplitBillUiState.Loading
try {
val bitmap = imageProxy.toBitmap()
val prompt = createExtractionPrompt()
Log.d("SplitBillViewModel", "Sending image to GenerativeModel")
val response: GenerateContentResponse = generativeModel.generateContent(
content {
image(bitmap)
text(prompt)
}
)
val responseText = response.text
if (responseText != null) {
Log.d("SplitBillViewModel", "Received raw response: $responseText")
val cleanedJson = cleanJsonString(responseText)
Log.d("SplitBillViewModel", "Cleaned JSON for parsing: $cleanedJson")
if (cleanedJson.trimStart().startsWith("{")) {
val billDetails = parseBillDetails(cleanedJson)
if (billDetails.restaurantName.isBlank() && billDetails.items.isEmpty() && billDetails.total == "0.00") {
Log.w("SplitBillViewModel", "Parsing resulted in an empty BillDetails object. Assuming not a bill.")
_uiState.value = SplitBillUiState.Error("Could not detect a bill in the image. Please try again.")
} else {
_uiState.value = SplitBillUiState.Success(billDetails)
}
} else {
Log.e("SplitBillViewModel", "Model returned non-JSON text after cleaning: $cleanedJson")
_uiState.value = SplitBillUiState.Error("The image may not be a bill, or details could not be extracted.")
}
} else {
Log.e("SplitBillViewModel", "Failed to extract details: Response text is null.")
val blockReason = response.promptFeedback?.blockReason?.toString()
var errorMessage = "Failed to get a response from the model."
if (blockReason != null) errorMessage += " Reason: $blockReason."
_uiState.value = SplitBillUiState.Error(errorMessage)
}
} catch (e: SerializationException) {
Log.e("SplitBillViewModel", "JSON Parsing Error: ${e.message}", e)
_uiState.value = SplitBillUiState.Error("Failed to understand the bill details format: ${e.message}")
} catch (e: Exception) {
Log.e("SplitBillViewModel", "Error extracting bill data: ${e.message}", e)
_uiState.value = SplitBillUiState.Error("An error occurred: ${e.message}")
} finally {
try {
imageProxy.close()
} catch (e: IllegalStateException) {
Log.w("SplitBillViewModel", "ImageProxy was already closed.")
}
}
}
}
fun updateBillDetails(billDetails: BillDetails) {
_uiState.update {
if (it is SplitBillUiState.Success) {
it.copy(billDetails = billDetails)
} else {
it
}
}
}
fun resetScanState() {
_uiState.value = SplitBillUiState.Idle
}
private fun cleanJsonString(rawResponse: String): String {
return rawResponse
.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
}
private fun ImageProxy.toBitmap(): Bitmap {
val planeProxy = planes[0]
val buffer = planeProxy.buffer
buffer.rewind()
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
private fun createExtractionPrompt(): String {
return """
Extract details from the bill image. Provide the output STRICTLY in a JSON format.
Example:
{
"restaurantName": "The Food Place",
"dateTime": "2024-07-30 12:34 PM",
"items": [
{"name": "Burger", "quantity": "1", "unitPrice": "15.00", "totalPrice": "15.00"}
],
"subTotal": "25.00",
"tax": "2.50",
"service": "3.00",
"discount": "0.00",
"others": "0.00",
"total": "30.50"
}
If info is not found, use "N/A" for strings or "0.00" for numbers.
If the image is not a bill, return an empty JSON object: {}.
DO NOT add any text or explanations outside the JSON object.
"""
}
private val jsonParser = Json { ignoreUnknownKeys = true; isLenient = true }
private fun parseBillDetails(jsonString: String): BillDetails {
return jsonParser.decodeFromString<BillDetails>(jsonString)
}
private val _people = MutableStateFlow<List<String>>(emptyList())
val people = _people.asStateFlow()
private val _itemAssignments = MutableStateFlow<Map<BillItem, List<String>>>(emptyMap())
val itemAssignments = _itemAssignments.asStateFlow()
fun addPerson(name: String) {
if (name.isNotBlank() && !_people.value.contains(name)) {
_people.update { currentPeople -> currentPeople + name }
}
}
fun removePerson(name: String) {
_people.update { currentPeople -> currentPeople - name }
_itemAssignments.update { currentAssignments ->
val newAssignments = mutableMapOf<BillItem, List<String>>()
currentAssignments.forEach { (item, assignedPeople) ->
newAssignments[item] = assignedPeople.filterNot { it == name }
}
newAssignments
}
}
fun assignItemToPerson(item: BillItem, personName: String) {
_itemAssignments.update { currentMap ->
val currentAssignmentsForItem = currentMap[item] ?: emptyList()
val newAssignmentsForItem = if (currentAssignmentsForItem.contains(personName)) {
currentAssignmentsForItem - personName
} else {
currentAssignmentsForItem + personName
}
currentMap + (item to newAssignmentsForItem)
}
}
fun getSplitBillResult(): Map<String, Double> {
val currentUiState = uiState.value
if (currentUiState !is SplitBillUiState.Success) return emptyMap()
val billDetails = currentUiState.billDetails
val finalSplit = mutableMapOf<String, Double>()
_people.value.forEach { person -> finalSplit[person] = 0.0 }
billDetails.items.forEach { item ->
val peopleSharingThisItem = _itemAssignments.value[item]?.filter { _people.value.contains(it) } ?: emptyList()
val itemPrice = item.totalPrice.toDoubleOrNull() ?: 0.0
if (peopleSharingThisItem.isNotEmpty() && itemPrice > 0) {
val pricePerPersonForItem = itemPrice / peopleSharingThisItem.size
peopleSharingThisItem.forEach { person ->
finalSplit[person] = (finalSplit[person] ?: 0.0) + pricePerPersonForItem
}
}
}
val subTotalOfAssignedItems = finalSplit.values.sum()
val tax = billDetails.tax.toDoubleOrNull() ?: 0.0
val service = billDetails.service.toDoubleOrNull() ?: 0.0
val others = billDetails.others.toDoubleOrNull() ?: 0.0
val discount = billDetails.discount.toDoubleOrNull() ?: 0.0
val totalSharedCosts = tax + service + others - discount
if (_people.value.isNotEmpty()) {
if (subTotalOfAssignedItems > 0) {
_people.value.forEach { person ->
val personSubTotal = finalSplit[person] ?: 0.0
val proportion = personSubTotal / subTotalOfAssignedItems
val personShareOfShared = totalSharedCosts * proportion
finalSplit[person] = personSubTotal + personShareOfShared
}
} else {
val equalShare = totalSharedCosts / _people.value.size
_people.value.forEach { person ->
finalSplit[person] = (finalSplit[person] ?: 0.0) + equalShare
}
}
}
finalSplit.forEach { (person, total) ->
if (total < 0) finalSplit[person] = 0.0
}
return finalSplit
}
}

 

Create Result and Edit Page
  • After scan the bill and extract the image, show the result on the result page

 

@Composable
fun BillResultScreen(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: SplitBillViewModel
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
modifier = modifier.fillMaxSize(),
bottomBar = {
Button(
onClick = {
navController.navigate(Screen.SplitBillScreen.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 64.dp, start = 16.dp, end = 16.dp)
) {
Text("Confirm and Split Bill")
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
when (val state = uiState) {
is SplitBillUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is SplitBillUiState.Success -> {
var editableBillDetails by remember(state.billDetails) {
mutableStateOf(state.billDetails)
}
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item {
Text(
"Edit Bill Details",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(vertical = 16.dp)
)
}
item {
EditableBillField(
label = "Restaurant Name",
value = editableBillDetails.restaurantName,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(restaurantName = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Date & Time",
value = editableBillDetails.dateTime, // Assuming dateTime exists
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(dateTime = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
item { Text("Items:", style = MaterialTheme.typography.titleMedium) }
item { Spacer(modifier = Modifier.height(8.dp)) }
items(editableBillDetails.items) { billItem ->
EditableBillItem(
item = billItem,
onValueChange = { updatedItem ->
val updatedItems = editableBillDetails.items.map {
if (it.name == billItem.name && it.totalPrice == billItem.totalPrice) { // Or use a unique ID if items can have same name/price
updatedItem
} else {
it
}
}
editableBillDetails = editableBillDetails.copy(items = updatedItems)
viewModel.updateBillDetails(editableBillDetails)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
item { Spacer(modifier = Modifier.height(16.dp)) }
item { Text("Summary:", style = MaterialTheme.typography.titleMedium) }
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
EditableBillField(
label = "Subtotal",
value = editableBillDetails.subTotal,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(subTotal = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Tax",
value = editableBillDetails.tax,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(tax = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Service Charge",
value = editableBillDetails.service, 
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(service = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Discount",
value = editableBillDetails.discount,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(discount = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Other Fees",
value = editableBillDetails.others,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(others = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
EditableBillField(
label = "Total",
value = editableBillDetails.total,
onValueChange = { newValue ->
editableBillDetails = editableBillDetails.copy(total = newValue)
viewModel.updateBillDetails(editableBillDetails)
}
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
is SplitBillUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = state.message, color = MaterialTheme.colorScheme.error)
}
}
is SplitBillUiState.Idle -> { 
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No bill data loaded yet.")
}
}
}
}
}
}

 

  • Make it editable like the code below

 

@Composable
fun EditableBillField(label: String, value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
)
}
@Composable
fun EditableBillItem(item: BillItem, onValueChange: (BillItem) -> Unit) {
var name by remember(item.name) { mutableStateOf(item.name) }
var quantity by remember(item.quantity) { mutableStateOf(item.quantity) }
var totalPrice by remember(item.totalPrice) { mutableStateOf(item.totalPrice) }
Column(modifier = Modifier.padding(vertical = 8.dp)) {
OutlinedTextField(
value = name,
onValueChange = {
name = it
onValueChange(item.copy(name = it, quantity = quantity, totalPrice = totalPrice))
},
label = { Text("Item Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = quantity,
onValueChange = {
quantity = it
onValueChange(item.copy(name = name, quantity = it, totalPrice = totalPrice))
},
label = { Text("Quantity") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = totalPrice,
onValueChange = {
totalPrice = it
onValueChange(item.copy(name = name, quantity = quantity, totalPrice = it))
},
label = { Text("Total Price") },
modifier = Modifier.weight(1f)
)
}
}
}

 

Create Page for Split Logic
  • Make add name for split the bill

 

@Composable
fun AddPersonSection(onAddPerson: (String) -> Unit) {
var name by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Person's Name") },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
onAddPerson(name)
name = ""
}) {
Icon(Icons.Default.Add, contentDescription = "Add Person")
}
}
}
@Composable
fun ItemAssignmentCard(
item: BillItem,
people: List<String>,
assignedPeople: List<String>,
onAssignPerson: (String) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = item.name, style = MaterialTheme.typography.titleMedium)
Text(text = "Price: ${item.totalPrice}", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
people.forEach { person ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onAssignPerson(person) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = assignedPeople.contains(person),
onCheckedChange = { onAssignPerson(person) }
)
Text(text = person)
}
}
}
}
}

 

  • Create the Result screen

 

Composable
fun SplitBillScreen(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: SplitBillViewModel
) {
val uiState by viewModel.uiState.collectAsState()
val people by viewModel.people.collectAsState()
val itemAssignments by viewModel.itemAssignments.collectAsState()
var showResult by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
bottomBar = {
Button(
onClick = { showResult = true },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("Calculate Split")
}
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
item {
AddPersonSection(onAddPerson = { viewModel.addPerson(it) })
Spacer(modifier = Modifier.height(16.dp))
Text("Assign Items to People", style = MaterialTheme.typography.headlineSmall)
}
if (uiState is SplitBillUiState.Success) {
val billItems = (uiState as SplitBillUiState.Success).billDetails.items
items(billItems) { item ->
ItemAssignmentCard(
item = item,
people = people,
assignedPeople = itemAssignments[item] ?: emptyList(),
onAssignPerson = { person ->
viewModel.assignItemToPerson(item, person)
}
)
}
}
}
if (showResult) {
val splitResult = viewModel.getSplitBillResult()
ResultDialog(
result = splitResult,
onDismiss = { showResult = false }
)
}
}
}

 

  • Show dialog result

 

Show dialog result
@Composable
fun ResultDialog(result: Map<String, Double>, onDismiss: () -> Unit) {
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text("Split Result", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
result.forEach { (person, amount) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = person)
Text(text = String.format("%.2f", amount))
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close")
}
}
}
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

If you want to check more about the code check on my github repository below.

If you found this article helpful, please consider giving it a clap to show your support! Don’t forget to follow my account for more engaging insights about Android Technology. You can also connect with me on social media through the links InstagramLinkedInX. I’d love to connect, hear your thoughts and see what amazing things you build!

Stay curious, and happy coding ! 🙂

This article was previously published on proandroiddev.com.

Menu