Invoice App Source Code Android Studio Project

| Al Saeed
Invoice app source code Android Studio project free, open source app built with Jetpack Compose showing invoice dashboard
This image is generated by AI

You are looking for an invoice app source code Android Studio project? You have landed in the right place, seriously.

I built this Android invoice app for one reason, to help freelancers and non-coders who just need a simple, modern, and fully offline invoicing solution. No monthly fees, No ads, just a private and your own tool that actually works.

Now, let’s be real for a second.

Creating professional invoices? Well, It is a key part of freelancing.. But here is the catch: most people either pay hefty monthly fees for fancy invoicing apps or struggle with clunky Word and Excel templates. Managing those templates? A nightmare. Customizing them? Even worse.

That is exactly why so many freelancers hunt for an invoice app source code Android Studio project. They want complete project source code they can learn from, customize, and use in their own apps or workspace.

So, I rolled up my sleeves and built a complete Invoice App using Jetpack Compose and Room Database for native Android. And guess what? It works totally offline. That’s why, no log in and internet requirement. Just open the app, create invoice with your own name or brand and boom, you’re ready to go. It is a simple solution for all kind of service providers and freelancers.

In this post, I’ll walk you through the app’s features, explain how it’s built, and share the complete invoice app source code android studio project built with Kotlin. You can download from the bottom download button, explore the project, customize it for your own needs, or even add it to your portfolio.

So, if you are hunting for a practical invoice app source code Android Studio project built with modern Android development practices, this guide will get you up and running fast.

If you are scrolling for an invoice app source code Android Studio project, then your scrolling ends here. You can download this complete project source code and open it in your Android Studio.

Use this project source code to learn Android development with Jetpack Compose. Build your own invoice app with your own name or brand. Customize it according to your requirements. The code is clean, beginner friendly, and easy to understand.


Invoice App Source Code Android Studio Demo (YouTube Short)


Ad loading...

Invoice App Source Code for Android Studio – Github Download

If you are scrolling for an invoice app source code Android Studio project, then your scrolling ends here. You can download this complete project source code and open it in your Android Studio.

Use this project source code to learn Android development with Jetpack Compose. Build your own invoice app with your own name or brand. Customize it according to your requirements. The code is clean, beginner friendly, and easy to understand.


Download Source Code

Features of This Invoice App 

Here’s what makes this app stand out as one of the best invoice apps for freelancers:

  • Create, edit, view, and delete client invoices  with auto-incrementing invoice numbers (INV-1001, INV-1002…)
  • Unlimited service/line items  each with its own quantity, rate, and taxable toggle
  • Fixed or percentage-based discounts, with tax correctly calculated on the taxable subtotal after the discount is applied
  • Business profile branding  business name, address, contact info, currency symbol, and default tax rate
  • Logo customization  auto-generated monogram, preset icons, or a custom uploaded photo
  • Live revenue dashboard  total earned, pending amount, and paid/unpaid invoice counts
  • Search and status filtering (All / Paid / Pending)
  • Share or save invoices as a PNG image or PDF, via WhatsApp, Email, Drive, or anywhere else
  • 100% offline all data is stored locally in a Room Database, no account needed

Tech Stack Used

  • UI: Jetpack Compose + Material 3
  • Architecture: MVVM (ViewModel + Compose State + StateFlow)
  • Database: Room (SQLite) with reactive Flow queries
  • Concurrency: Kotlin Coroutines
  • Image Loading: Coil
  • PDF/Image Export: Native android.graphics Canvas + PdfDocument
  • Language: 100% Kotlin

App Architecture Overview

This invoice app for freelancers follows the MVVM pattern. A single ReceiptViewModel manages all UI state (form inputs, calculated totals, and the current screen) through Compose State, and exposes reactive StateFlow data from Room. Instead of the Jetpack Navigation library, navigation is handled with a simple sealed interface Screen combined with AnimatedContent  a lightweight approach that works well for smaller apps like this one.

MainActivity.kt (App Entry & Navigation Setup)

This is the entry point of the app — where the app launches, the theme is applied, and navigation between screens is controlled.


package com.alsaeeddev

import android.app.DatePickerDialog
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.compose.AsyncImagePainter
import com.alsaeeddev.data.BusinessProfile
import com.alsaeeddev.data.ReceiptItem
import com.alsaeeddev.data.ReceiptWithItems
import com.alsaeeddev.ui.*
import com.alsaeeddev.ui.theme.MyApplicationTheme
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.*

class MainActivity : ComponentActivity() {
    private val viewModel: ReceiptViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MyApplicationTheme {
                MainAppScreen(viewModel = viewModel)
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainAppScreen(viewModel: ReceiptViewModel) {
    val receipts by viewModel.allReceipts.collectAsStateWithLifecycle()
    val profile by viewModel.businessProfile.collectAsStateWithLifecycle()
    val context = LocalContext.current

    // Handle system back button / gesture to navigate back to Dashboard when on sub-screens
    BackHandler(enabled = viewModel.currentScreen != Screen.Dashboard) {
        viewModel.navigateTo(Screen.Dashboard)
    }

    // Set up PhotoPicker launcher
    val pickMedia = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        if (uri != null) {
            viewModel.copyAndSaveCustomLogo(uri) { savedUriStr ->
                // Save immediately in the profile with saved internal file URI
                viewModel.saveProfile(
                    name = profile.name,
                    address = profile.address,
                    phone = profile.phone,
                    email = profile.email,
                    website = profile.website,
                    defaultTaxRate = profile.defaultTaxRate,
                    taxLabel = profile.taxLabel,
                    currencySymbol = profile.currencySymbol,
                    logoType = "custom",
                    logoPresetName = profile.logoPresetName,
                    logoCustomUri = savedUriStr,
                    logoColorArgb = profile.logoColorArgb,
                    onSuccess = {
                        Toast.makeText(context, "Logo updated successfully!", Toast.LENGTH_SHORT)
                            .show()
                    })
            }
        }
    }

    Scaffold(
        modifier = Modifier.fillMaxSize()
    ) { innerPadding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
        ) {
            AnimatedContent(
                targetState = viewModel.currentScreen, transitionSpec = {
                    fadeIn() togetherWith fadeOut()
                }, label = "ScreenTransition"
            ) { screen ->
                when (screen) {
                    Screen.Dashboard -> {
                        DashboardScreen(
                            viewModel = viewModel, receipts = receipts, profile = profile
                        )
                    }

                    Screen.CreateReceipt -> {
                        CreateReceiptScreen(
                            viewModel = viewModel, profile = profile
                        )
                    }

                    Screen.ViewReceipt -> {
                        ViewReceiptScreen(
                            viewModel = viewModel, profile = profile
                        )
                    }

                    Screen.BusinessProfileEdit -> {
                        BusinessProfileEditScreen(
                            viewModel = viewModel, profile = profile, onPickCustomLogo = {
                                pickMedia.launch(
                                    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
                                )
                            })
                    }
                }
            }
        }
    }
}

Dashboard Screen (Revenue Overview & Invoice List)

The Dashboard screen displays total revenue, pending amounts, search, filters, and the invoice list.


@Composable
fun DashboardScreen(
    viewModel: ReceiptViewModel, receipts: List, profile: BusinessProfile
) {
    var searchQuery by remember { mutableStateOf("") }
    var statusFilter by remember { mutableStateOf("All") }

    val filteredReceipts = remember(receipts, searchQuery, statusFilter) {
        receipts.filter { rw ->
            val matchesSearch = rw.receipt.receiptNumber.contains(
                searchQuery, ignoreCase = true
            ) || rw.receipt.customerName.contains(
                searchQuery, ignoreCase = true
            ) || rw.items.any { it.name.contains(searchQuery, ignoreCase = true) }
            val matchesFilter = when (statusFilter) {
                "Paid" -> rw.receipt.isPaid
                "Pending" -> !rw.receipt.isPaid
                else -> true
            }
            matchesSearch && matchesFilter
        }
    }

    val totalRevenue =
        remember(receipts) { receipts.filter { it.receipt.isPaid }.sumOf { it.receipt.grandTotal } }
    val pendingRevenue = remember(receipts) {
        receipts.filter { !it.receipt.isPaid }.sumOf { it.receipt.grandTotal }
    }
    val paidCount = remember(receipts) { receipts.count { it.receipt.isPaid } }
    val pendingCount = remember(receipts) { receipts.count { !it.receipt.isPaid } }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // Freelance Studio Header
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            BusinessLogoView(profile = profile, size = 52)
            Spacer(modifier = Modifier.width(12.dp))
            Column(modifier = Modifier.weight(1.0f)) {
                Text(
                    text = profile.name.ifBlank { "Freelance Studio" },
                    style = MaterialTheme.typography.titleLarge,
                    fontWeight = FontWeight.Bold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                Text(
                    text = "Freelance Client Billing & Invoices",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            IconButton(
                onClick = { viewModel.navigateTo(Screen.BusinessProfileEdit) },
                modifier = Modifier.testTag("settings_button")
            ) {
                Icon(
                    imageVector = Icons.Outlined.Settings,
                    contentDescription = "Studio Settings",
                    tint = MaterialTheme.colorScheme.primary
                )
            }
        }

        // Hero Financial Overview Card
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primaryContainer
            ),
            shape = RoundedCornerShape(20.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
        ) {
            Column(
                modifier = Modifier.padding(18.dp)
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.spacedBy(6.dp)
                    ) {
                        Icon(
                            imageVector = Icons.Default.MonetizationOn,
                            contentDescription = "Earnings",
                            tint = MaterialTheme.colorScheme.primary
                        )
                        Text(
                            text = "Total Earned Revenue",
                            style = MaterialTheme.typography.titleSmall,
                            color = MaterialTheme.colorScheme.onPrimaryContainer,
                            fontWeight = FontWeight.SemiBold
                        )
                    }
                    Surface(
                        color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
                        shape = RoundedCornerShape(12.dp)
                    ) {
                        Text(
                            text = "$paidCount Paid Invoices",
                            style = MaterialTheme.typography.labelSmall,
                            fontWeight = FontWeight.Bold,
                            color = MaterialTheme.colorScheme.primary,
                            modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
                        )
                    }
                }

                Text(
                    text = "${profile.currencySymbol}${formatMoney(totalRevenue)}",
                    style = MaterialTheme.typography.headlineLarge,
                    fontWeight = FontWeight.ExtraBold,
                    color = MaterialTheme.colorScheme.primary,
                    modifier = Modifier.padding(vertical = 6.dp)
                )

                HorizontalDivider(
                    color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f),
                    modifier = Modifier.padding(vertical = 8.dp)
                )

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Column {
                        Text(
                            text = "Outstanding / Pending Invoices",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
                        )
                        Row(
                            verticalAlignment = Alignment.CenterVertically,
                            horizontalArrangement = Arrangement.spacedBy(4.dp)
                        ) {
                            Text(
                                text = "${profile.currencySymbol}${formatMoney(pendingRevenue)}",
                                style = MaterialTheme.typography.titleMedium,
                                fontWeight = FontWeight.Bold,
                                color = Color(0xFFC62828)
                            )
                            if (pendingCount > 0) {
                                Text(
                                    text = "($pendingCount unpaid)",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = Color(0xFFC62828).copy(alpha = 0.8f)
                                )
                            }
                        }
                    }

                    Column(horizontalAlignment = Alignment.End) {
                        Text(
                            text = "Total Invoices",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
                        )
                        Text(
                            text = "${receipts.size}",
                            style = MaterialTheme.typography.titleMedium,
                            fontWeight = FontWeight.Bold,
                            color = MaterialTheme.colorScheme.onPrimaryContainer
                        )
                    }
                }
            }
        }

        // Search Bar
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            placeholder = {
                Text(
                    "Search client name, company, or invoice #...",
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
            },
            leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
            trailingIcon = {
                if (searchQuery.isNotEmpty()) {
                    IconButton(onClick = { searchQuery = "" }) {
                        Icon(Icons.Default.Clear, contentDescription = "Clear")
                    }
                }
            },
            singleLine = true,
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 12.dp)
                .testTag("search_input"),
            colors = OutlinedTextFieldDefaults.colors(
                focusedBorderColor = MaterialTheme.colorScheme.primary,
                unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
            ),
            shape = RoundedCornerShape(14.dp)
        )

        // Filter Chips
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 12.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            val filters = listOf("All", "Paid", "Pending")
            filters.forEach { filterName ->
                val isSelected = statusFilter == filterName
                FilterChip(
                    selected = isSelected,
                    onClick = { statusFilter = filterName },
                    label = { Text(filterName) },
                    colors = FilterChipDefaults.filterChipColors(
                        selectedContainerColor = MaterialTheme.colorScheme.primary,
                        selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
                        containerColor = MaterialTheme.colorScheme.surface,
                        labelColor = MaterialTheme.colorScheme.onSurface
                    ),
                    border = FilterChipDefaults.filterChipBorder(
                        enabled = true,
                        selected = isSelected,
                        borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
                        selectedBorderColor = MaterialTheme.colorScheme.primary
                    )
                )
            }
        }

        Text(
            text = "Client Invoices (${filteredReceipts.size})",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 8.dp)
        )

        if (filteredReceipts.isEmpty()) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f), contentAlignment = Alignment.Center
            ) {
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    modifier = Modifier.padding(32.dp)
                ) {
                    Icon(
                        imageVector = Icons.Default.ReceiptLong,
                        contentDescription = "No invoices",
                        modifier = Modifier.size(64.dp),
                        tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Text(
                        text = if (searchQuery.isNotEmpty()) "No matching client invoices" else "No invoices created yet",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    Text(
                        text = if (searchQuery.isNotEmpty()) "Try searching with a different term or clear filters." else "Tap '+ New Invoice' to generate your first professional invoice for a client.",
                        style = MaterialTheme.typography.bodyMedium,
                        textAlign = TextAlign.Center,
                        color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
                        modifier = Modifier.padding(top = 6.dp)
                    )
                }
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f),
                verticalArrangement = Arrangement.spacedBy(10.dp)
            ) {
                items(filteredReceipts) { rw ->
                    ReceiptItemRow(
                        receiptWithItems = rw,
                        currencySymbol = profile.currencySymbol,
                        onClick = { viewModel.viewReceiptDetails(rw) })
                }
            }
        }

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 10.dp),
            contentAlignment = Alignment.CenterEnd
        ) {
            ExtendedFloatingActionButton(
                onClick = {
                    val nextNum = if (receipts.isEmpty()) {
                        "INV-1001"
                    } else {
                        val lastNum = receipts.first().receipt.receiptNumber
                        val numberPart = lastNum.substringAfter("-").toIntOrNull()
                        if (numberPart != null) {
                            "INV-${numberPart + 1}"
                        } else {
                            "INV-${1000 + receipts.size + 1}"
                        }
                    }
                    viewModel.startNewReceipt(nextNum)
                },
                containerColor = MaterialTheme.colorScheme.primary,
                contentColor = MaterialTheme.colorScheme.onPrimary,
                shape = RoundedCornerShape(16.dp),
                modifier = Modifier.testTag("create_receipt_fab")
            ) {
                Icon(Icons.Default.Add, contentDescription = "Create Invoice")
                Spacer(modifier = Modifier.width(8.dp))
                Text("New Invoice", fontWeight = FontWeight.Bold)
            }
        }
    }
}

@Composable
fun ReceiptItemRow(
    receiptWithItems: ReceiptWithItems, currencySymbol: String, onClick: () -> Unit
) {
    val r = receiptWithItems.receipt
    val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
    val formattedDate = sdf.format(Date(r.dateTimestamp))

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .testTag("receipt_item_${r.id}"),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
        shape = RoundedCornerShape(14.dp),
        border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.18f)),
        elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(
                        text = r.receiptNumber,
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.ExtraBold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    Spacer(modifier = Modifier.width(8.dp))
                    StatusBadge(isPaid = r.isPaid)
                }
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = if (r.customerName.isNotBlank()) r.customerName else "Client / Organization",
                    style = MaterialTheme.typography.bodyLarge,
                    fontWeight = FontWeight.Bold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                Text(
                    text = "Issued: $formattedDate • ${r.paymentMethod}",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            Column(horizontalAlignment = Alignment.End) {
                Text(
                    text = "$currencySymbol${formatMoney(r.grandTotal)}",
                    style = MaterialTheme.typography.titleLarge,
                    fontWeight = FontWeight.ExtraBold,
                    color = MaterialTheme.colorScheme.onSurface
                )
                Text(
                    text = "${receiptWithItems.items.size} deliverable service${if (receiptWithItems.items.size > 1) "s" else ""}",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

Create / Edit Invoice Screen

This screen is used to create a new invoice or edit an existing one — client details, service items, tax/discount settings, and payment terms are all managed here.


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateReceiptScreen(
    viewModel: ReceiptViewModel, profile: BusinessProfile
) {
    val context = LocalContext.current
    val scrollState = rememberScrollState()

    val calendar = Calendar.getInstance()
    calendar.timeInMillis = viewModel.dateTimestampInput
    val datePickerDialog = DatePickerDialog(
        context,
        { _, year, month, dayOfMonth ->
            val newCal = Calendar.getInstance()
            newCal.set(year, month, dayOfMonth)
            viewModel.dateTimestampInput = newCal.timeInMillis
        },
        calendar.get(Calendar.YEAR),
        calendar.get(Calendar.MONTH),
        calendar.get(Calendar.DAY_OF_MONTH)
    )

    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        TopAppBar(
            title = {
            Text(
                text = if (viewModel.receiptIdToEdit == null) "New Client Invoice" else "Edit Invoice",
                fontWeight = FontWeight.Bold
            )
        }, navigationIcon = {
            IconButton(onClick = { viewModel.navigateTo(Screen.Dashboard) }) {
                Icon(Icons.Default.ArrowBack, contentDescription = "Back")
            }
        }, actions = {
            IconButton(
                onClick = {
                    if (viewModel.draftItems.none { it.name.isNotBlank() }) {
                        Toast.makeText(
                            context,
                            "Please add at least one deliverable service",
                            Toast.LENGTH_SHORT
                        ).show()
                    } else {
                        viewModel.saveReceipt {
                            Toast.makeText(
                                context, "Invoice saved successfully!", Toast.LENGTH_SHORT
                            ).show()
                            viewModel.navigateTo(Screen.ViewReceipt)
                        }
                    }
                }, modifier = Modifier.testTag("save_receipt_button")
            ) {
                Icon(
                    imageVector = Icons.Default.Save,
                    contentDescription = "Save Invoice",
                    tint = MaterialTheme.colorScheme.primary
                )
            }
        }, colors = TopAppBarDefaults.topAppBarColors(
            containerColor = MaterialTheme.colorScheme.surface,
            titleContentColor = MaterialTheme.colorScheme.onSurface
        )
        )

        Column(
            modifier = Modifier
                .weight(1f)
                .verticalScroll(scrollState)
                .padding(16.dp)
        ) {
            // Freelancer Billed From Preview Card
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 16.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
                        alpha = 0.5f
                    )
                ),
                shape = RoundedCornerShape(12.dp)
            ) {
                Row(
                    modifier = Modifier.padding(14.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    BusinessLogoView(profile = profile, size = 44)
                    Spacer(modifier = Modifier.width(12.dp))
                    Column {
                        Text(
                            text = "BILLED FROM (Studio / Freelancer)",
                            style = MaterialTheme.typography.labelSmall,
                            fontWeight = FontWeight.Bold,
                            color = MaterialTheme.colorScheme.primary
                        )
                        Text(
                            text = profile.name,
                            style = MaterialTheme.typography.bodyMedium,
                            fontWeight = FontWeight.Bold
                        )
                        Text(
                            text = "${profile.email} • ${profile.phone}",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                }
            }

            Text(
                text = "1. Invoice Identifier & Date",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                OutlinedTextField(
                    value = viewModel.receiptNumberInput,
                    onValueChange = { viewModel.receiptNumberInput = it },
                    label = { Text("Invoice Number") },
                    singleLine = true,
                    modifier = Modifier
                        .weight(1f)
                        .testTag("receipt_number_input"),
                    shape = RoundedCornerShape(10.dp)
                )

                Box(
                    modifier = Modifier
                        .weight(1f)
                        .clickable { datePickerDialog.show() }) {
                    val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
                    OutlinedTextField(
                        value = sdf.format(Date(viewModel.dateTimestampInput)),
                        onValueChange = {},
                        readOnly = true,
                        enabled = false,
                        label = { Text("Issue Date") },
                        leadingIcon = {
                            Icon(
                                Icons.Default.CalendarToday,
                                contentDescription = "Date",
                                tint = MaterialTheme.colorScheme.primary
                            )
                        },
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth(),
                        shape = RoundedCornerShape(10.dp),
                        colors = OutlinedTextFieldDefaults.colors(
                            disabledTextColor = MaterialTheme.colorScheme.onSurface,
                            disabledBorderColor = MaterialTheme.colorScheme.outline,
                            disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
                            disabledLeadingIconColor = MaterialTheme.colorScheme.primary
                        )
                    )
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            // Payment Status & Method Card
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 4.dp),
                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
                shape = RoundedCornerShape(12.dp),
                border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
            ) {
                Column(
                    modifier = Modifier.padding(14.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    // Payment Status Switch Segment
                    Column {
                        Text(
                            text = "Invoice Status",
                            style = MaterialTheme.typography.bodySmall,
                            fontWeight = FontWeight.Medium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                            modifier = Modifier.padding(bottom = 6.dp)
                        )
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(44.dp)
                                .clip(RoundedCornerShape(10.dp))
                                .background(MaterialTheme.colorScheme.surfaceVariant)
                                .border(
                                    1.dp,
                                    MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
                                    RoundedCornerShape(10.dp)
                                ), verticalAlignment = Alignment.CenterVertically
                        ) {
                            val isPaid = viewModel.isPaidInput
                            Box(
                                modifier = Modifier
                                    .weight(1f)
                                    .fillMaxHeight()
                                    .clip(RoundedCornerShape(topStart = 10.dp, bottomStart = 10.dp))
                                    .background(if (isPaid) Color(0xFF2E7D32) else Color.Transparent)
                                    .clickable { viewModel.isPaidInput = true }
                                    .testTag("is_paid_switch"), contentAlignment = Alignment.Center) {
                                Row(
                                    verticalAlignment = Alignment.CenterVertically,
                                    horizontalArrangement = Arrangement.spacedBy(6.dp)
                                ) {
                                    Icon(
                                        imageVector = Icons.Default.CheckCircle,
                                        contentDescription = "Paid",
                                        tint = if (isPaid) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
                                        modifier = Modifier.size(18.dp)
                                    )
                                    Text(
                                        text = "PAID",
                                        fontWeight = FontWeight.Bold,
                                        fontSize = 13.sp,
                                        color = if (isPaid) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
                                    )
                                }
                            }

                            HorizontalDivider(
                                modifier = Modifier
                                    .fillMaxHeight()
                                    .width(1.dp),
                                color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
                            )

                            Box(
                                modifier = Modifier
                                    .weight(1f)
                                    .fillMaxHeight()
                                    .clip(RoundedCornerShape(topEnd = 10.dp, bottomEnd = 10.dp))
                                    .background(if (!isPaid) Color(0xFFC62828) else Color.Transparent)
                                    .clickable { viewModel.isPaidInput = false },
                                contentAlignment = Alignment.Center
                            ) {
                                Row(
                                    verticalAlignment = Alignment.CenterVertically,
                                    horizontalArrangement = Arrangement.spacedBy(6.dp)
                                ) {
                                    Icon(
                                        imageVector = Icons.Default.Schedule,
                                        contentDescription = "Pending",
                                        tint = if (!isPaid) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
                                        modifier = Modifier.size(18.dp)
                                    )
                                    Text(
                                        text = "PENDING / UNPAID",
                                        fontWeight = FontWeight.Bold,
                                        fontSize = 13.sp,
                                        color = if (!isPaid) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
                                    )
                                }
                            }
                        }
                    }

                    // Payment Terms / Method
                    Column {
                        Text(
                            text = "Accepted Payment Terms / Method",
                            style = MaterialTheme.typography.bodySmall,
                            fontWeight = FontWeight.Medium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                            modifier = Modifier.padding(bottom = 6.dp)
                        )
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(6.dp)
                        ) {
                            val methods = listOf("Bank Transfer", "PayPal", "Card", "Direct Wire")
                            methods.forEach { m ->
                                val isSelected = viewModel.paymentMethodInput == m
                                FilterChip(
                                    selected = isSelected,
                                    onClick = { viewModel.paymentMethodInput = m },
                                    label = {
                                        Text(
                                            text = m,
                                            fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
                                            fontSize = 11.sp,
                                            maxLines = 1
                                        )
                                    },
                                    colors = FilterChipDefaults.filterChipColors(
                                        selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
                                        selectedLabelColor = MaterialTheme.colorScheme.primary,
                                        containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
                                            alpha = 0.4f
                                        ),
                                        labelColor = MaterialTheme.colorScheme.onSurface
                                    ),
                                    border = FilterChipDefaults.filterChipBorder(
                                        enabled = true,
                                        selected = isSelected,
                                        borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
                                        selectedBorderColor = MaterialTheme.colorScheme.primary
                                    ),
                                    modifier = Modifier.weight(1f)
                                )
                            }
                        }
                    }
                }
            }

            Spacer(modifier = Modifier.height(16.dp))

            Text(
                text = "2. Client Billing Information",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            OutlinedTextField(
                value = viewModel.customerNameInput,
                onValueChange = { viewModel.customerNameInput = it },
                label = { Text("Client Name / Company") },
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 12.dp)
                    .testTag("customer_name_input"),
                shape = RoundedCornerShape(10.dp)
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                OutlinedTextField(
                    value = viewModel.customerPhoneInput,
                    onValueChange = { viewModel.customerPhoneInput = it },
                    label = { Text("Client Phone") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
                    singleLine = true,
                    modifier = Modifier
                        .weight(1f)
                        .testTag("customer_phone_input"),
                    shape = RoundedCornerShape(10.dp)
                )

                OutlinedTextField(
                    value = viewModel.customerEmailInput,
                    onValueChange = { viewModel.customerEmailInput = it },
                    label = { Text("Client Email") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
                    singleLine = true,
                    modifier = Modifier
                        .weight(1f)
                        .testTag("customer_email_input"),
                    shape = RoundedCornerShape(10.dp)
                )
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Services & Line Items
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 8.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "3. Deliverable Services & Tasks",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
                TextButton(
                    onClick = { viewModel.addDraftItem() },
                    colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.primary),
                    modifier = Modifier.testTag("add_item_button")
                ) {
                    Icon(Icons.Default.Add, contentDescription = "Add Deliverable")
                    Spacer(modifier = Modifier.width(4.dp))
                    Text("Add Service Row")
                }
            }

            viewModel.draftItems.forEachIndexed { index, item ->
                DraftItemRow(
                    item = item,
                    index = index + 1,
                    currencySymbol = profile.currencySymbol,
                    onUpdateName = { name ->
                        viewModel.updateDraftItem(item.tempId) { it.copy(name = name) }
                    },
                    onUpdatePrice = { price ->
                        viewModel.updateDraftItem(item.tempId) { it.copy(unitPriceString = price) }
                    },
                    onUpdateQty = { qty ->
                        viewModel.updateDraftItem(item.tempId) { it.copy(quantity = qty) }
                    },
                    onUpdateTaxable = { taxable ->
                        viewModel.updateDraftItem(item.tempId) { it.copy(isTaxable = taxable) }
                    },
                    onDelete = {
                        viewModel.removeDraftItem(item.tempId)
                    })
                Spacer(modifier = Modifier.height(12.dp))
            }

            Spacer(modifier = Modifier.height(16.dp))

            Text(
                text = "4. Tax & Discount Settings",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 16.dp),
                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
                shape = RoundedCornerShape(12.dp),
                border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        OutlinedTextField(
                            value = viewModel.taxLabelInput,
                            onValueChange = {
                                viewModel.taxLabelInput = it
                                viewModel.recalculateTotals()
                            },
                            label = { Text("Tax (e.g. VAT, GST)") },
                            singleLine = true,
                            modifier = Modifier
                                .weight(1f)
                                .testTag("tax_label_input")
                        )

                        OutlinedTextField(
                            value = viewModel.taxRateInput,
                            onValueChange = {
                                viewModel.taxRateInput = it
                                viewModel.recalculateTotals()
                            },
                            label = { Text("Tax Rate (%)") },
                            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                            singleLine = true,
                            modifier = Modifier
                                .weight(1f)
                                .testTag("tax_rate_input")
                        )
                    }

                    Spacer(modifier = Modifier.height(12.dp))

                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                        verticalAlignment = Alignment.Top
                    ) {
                        Column(
                            modifier = Modifier.weight(1.2f)
                        ) {
                            Text(
                                text = "Discount Value",
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                                fontSize = 12.sp,
                                modifier = Modifier.padding(bottom = 4.dp, start = 2.dp)
                            )
                            OutlinedTextField(
                                value = viewModel.discountInput,
                                onValueChange = {
                                    viewModel.discountInput = it
                                    viewModel.recalculateTotals()
                                },
                                placeholder = { Text("e.g. 10 or 5%") },
                                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                singleLine = true,
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .height(56.dp)
                                    .testTag("discount_input"),
                                shape = RoundedCornerShape(10.dp)
                            )
                        }

                        Column(
                            modifier = Modifier.weight(1f)
                        ) {
                            Text(
                                text = "Discount Unit",
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                                fontSize = 12.sp,
                                modifier = Modifier.padding(bottom = 4.dp, start = 2.dp)
                            )
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .height(56.dp)
                                    .clip(RoundedCornerShape(10.dp))
                                    .background(MaterialTheme.colorScheme.surfaceVariant)
                                    .border(
                                        1.dp,
                                        MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
                                        RoundedCornerShape(10.dp)
                                    ), verticalAlignment = Alignment.CenterVertically
                            ) {
                                val isPerc = viewModel.discountIsPercentageInput
                                Box(
                                    modifier = Modifier
                                        .weight(1f)
                                        .fillMaxHeight()
                                        .background(if (!isPerc) MaterialTheme.colorScheme.primary else Color.Transparent)
                                        .clickable {
                                            viewModel.discountIsPercentageInput = false
                                            viewModel.recalculateTotals()
                                        }, contentAlignment = Alignment.Center
                                ) {
                                    Text(
                                        text = profile.currencySymbol,
                                        fontWeight = FontWeight.Bold,
                                        color = if (!isPerc) Color.White else MaterialTheme.colorScheme.onSurface
                                    )
                                }
                                Box(
                                    modifier = Modifier
                                        .weight(1f)
                                        .fillMaxHeight()
                                        .background(if (isPerc) MaterialTheme.colorScheme.primary else Color.Transparent)
                                        .clickable {
                                            viewModel.discountIsPercentageInput = true
                                            viewModel.recalculateTotals()
                                        }, contentAlignment = Alignment.Center
                                ) {
                                    Text(
                                        text = "%",
                                        fontWeight = FontWeight.Bold,
                                        color = if (isPerc) Color.White else MaterialTheme.colorScheme.onSurface
                                    )
                                }
                            }
                        }
                    }
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            Text(
                text = "5. Payment Terms & Wire Instructions",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            OutlinedTextField(
                value = viewModel.notesInput,
                onValueChange = { viewModel.notesInput = it },
                label = { Text("Bank / PayPal Payment Instructions") },
                modifier = Modifier
                    .fillMaxWidth()
                    .height(100.dp)
                    .testTag("notes_input"),
                shape = RoundedCornerShape(10.dp)
            )

            Spacer(modifier = Modifier.height(24.dp))
        }

        // Live Sticky Summary Bar
        Card(
            modifier = Modifier.fillMaxWidth(),
            colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
            shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text("Subtotal:", style = MaterialTheme.typography.bodyMedium)
                    Text(
                        "${profile.currencySymbol}${formatMoney(viewModel.subtotal)}",
                        style = MaterialTheme.typography.bodyMedium,
                        fontWeight = FontWeight.SemiBold
                    )
                }

                if (viewModel.discountTotal > 0) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        Text(
                            "Discount:",
                            style = MaterialTheme.typography.bodyMedium,
                            color = Color(0xFFC62828)
                        )
                        Text(
                            "-${profile.currencySymbol}${formatMoney(viewModel.discountTotal)}",
                            style = MaterialTheme.typography.bodyMedium,
                            fontWeight = FontWeight.SemiBold,
                            color = Color(0xFFC62828)
                        )
                    }
                }

                if (viewModel.taxTotal > 0) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        val taxLbl = viewModel.taxLabelInput.ifBlank { "Tax" }
                        Text(
                            "$taxLbl (${viewModel.taxRateInput}%):",
                            style = MaterialTheme.typography.bodyMedium
                        )
                        Text(
                            "${profile.currencySymbol}${formatMoney(viewModel.taxTotal)}",
                            style = MaterialTheme.typography.bodyMedium,
                            fontWeight = FontWeight.SemiBold
                        )
                    }
                }

                HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = "GRAND TOTAL:",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold
                    )
                    Text(
                        text = "${profile.currencySymbol}${formatMoney(viewModel.grandTotal)}",
                        style = MaterialTheme.typography.titleLarge,
                        fontWeight = FontWeight.ExtraBold,
                        color = MaterialTheme.colorScheme.primary
                    )
                }
            }
        }
    }
}

@Composable
fun DraftItemRow(
    item: DraftItem,
    index: Int,
    currencySymbol: String,
    onUpdateName: (String) -> Unit,
    onUpdatePrice: (String) -> Unit,
    onUpdateQty: (Int) -> Unit,
    onUpdateTaxable: (Boolean) -> Unit,
    onDelete: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
        shape = RoundedCornerShape(12.dp),
        border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
    ) {
        Column(
            modifier = Modifier.padding(12.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Service #$index",
                    style = MaterialTheme.typography.labelMedium,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.primary
                )

                IconButton(
                    onClick = onDelete, modifier = Modifier.size(28.dp)
                ) {
                    Icon(
                        imageVector = Icons.Default.Delete,
                        contentDescription = "Delete Row",
                        tint = MaterialTheme.colorScheme.error,
                        modifier = Modifier.size(20.dp)
                    )
                }
            }

            Spacer(modifier = Modifier.height(4.dp))

            OutlinedTextField(
                value = item.name,
                onValueChange = onUpdateName,
                label = { Text("Service Description / Task") },
                placeholder = { Text("e.g. Web App UI Design & Development") },
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 12.dp),
                shape = RoundedCornerShape(8.dp)
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.Top
            ) {
                Column(
                    modifier = Modifier.weight(1.2f)
                ) {
                    Text(
                        text = "Rate / Price ($currencySymbol)",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        fontSize = 12.sp,
                        modifier = Modifier.padding(bottom = 4.dp, start = 2.dp)
                    )
                    OutlinedTextField(
                        value = item.unitPriceString,
                        onValueChange = onUpdatePrice,
                        placeholder = { Text("0.00") },
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(56.dp),
                        shape = RoundedCornerShape(8.dp)
                    )
                }

                Column(
                    modifier = Modifier.weight(1f)
                ) {
                    Text(
                        text = "Quantity / Hours",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        fontSize = 12.sp,
                        modifier = Modifier.padding(bottom = 4.dp, start = 2.dp)
                    )
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(56.dp)
                            .clip(RoundedCornerShape(8.dp))
                            .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
                            .border(
                                1.dp,
                                MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
                                RoundedCornerShape(8.dp)
                            ),
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        IconButton(
                            onClick = { if (item.quantity > 1) onUpdateQty(item.quantity - 1) },
                            modifier = Modifier.size(36.dp)
                        ) {
                            Icon(
                                Icons.Default.Remove,
                                contentDescription = "Decrease",
                                modifier = Modifier.size(16.dp)
                            )
                        }
                        Text(
                            text = "${item.quantity} hr/qty",
                            style = MaterialTheme.typography.bodyMedium,
                            fontWeight = FontWeight.Bold
                        )
                        IconButton(
                            onClick = { onUpdateQty(item.quantity + 1) },
                            modifier = Modifier.size(36.dp)
                        ) {
                            Icon(
                                Icons.Default.Add,
                                contentDescription = "Increase",
                                modifier = Modifier.size(16.dp)
                            )
                        }
                    }
                }
            }

            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 4.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier.clickable { onUpdateTaxable(!item.isTaxable) }) {
                    Checkbox(
                        checked = item.isTaxable,
                        onCheckedChange = onUpdateTaxable,
                        colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.primary)
                    )
                    Text("Taxable Service", style = MaterialTheme.typography.bodySmall)
                }

                val rowTotal = (item.unitPriceString.toDoubleOrNull() ?: 0.0) * item.quantity
                Text(
                    text = "Total: $currencySymbol${formatMoney(rowTotal)}",
                    style = MaterialTheme.typography.bodyMedium,
                    fontWeight = FontWeight.ExtraBold,
                    color = MaterialTheme.colorScheme.primary
                )
            }
        }
    }
}

View Invoice Screen (Digital Invoice Sheet)

This screen renders a clean, printable digital invoice sheet — including a rotated PAID/PENDING stamp, plus share and save options.


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewReceiptScreen(
    viewModel: ReceiptViewModel, profile: BusinessProfile
) {
    val rw = viewModel.selectedReceipt
    val context = LocalContext.current

    if (rw == null) {
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Text("No invoice selected.")
        }
        return
    }

    val r = rw.receipt
    val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
    val formattedDate = sdf.format(Date(r.dateTimestamp))

    var showDeleteConfirmDialog by remember { mutableStateOf(false) }

    if (showDeleteConfirmDialog) {
        AlertDialog(
            onDismissRequest = { showDeleteConfirmDialog = false },
            title = { Text("Delete Invoice?") },
            text = { Text("Are you sure you want to delete invoice ${r.receiptNumber}? This action cannot be undone.") },
            confirmButton = {
                TextButton(
                    onClick = {
                        showDeleteConfirmDialog = false
                        viewModel.deleteReceipt(rw) {
                            Toast.makeText(context, "Invoice deleted", Toast.LENGTH_SHORT).show()
                            viewModel.navigateTo(Screen.Dashboard)
                        }
                    }) {
                    Text(
                        "Delete",
                        color = MaterialTheme.colorScheme.error,
                        fontWeight = FontWeight.Bold
                    )
                }
            },
            dismissButton = {
                TextButton(onClick = { showDeleteConfirmDialog = false }) {
                    Text("Cancel")
                }
            })
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        TopAppBar(
            title = { Text("Invoice ${r.receiptNumber}", fontWeight = FontWeight.Bold) },
            navigationIcon = {
                IconButton(onClick = { viewModel.navigateTo(Screen.Dashboard) }) {
                    Icon(Icons.Default.ArrowBack, contentDescription = "Back")
                }
            },
            actions = {
                IconButton(
                    onClick = { viewModel.startEditReceipt(rw) },
                    modifier = Modifier.testTag("edit_receipt_button")
                ) {
                    Icon(Icons.Default.Edit, contentDescription = "Edit")
                }
                IconButton(onClick = { showDeleteConfirmDialog = true }) {
                    Icon(
                        Icons.Default.Delete,
                        contentDescription = "Delete",
                        tint = MaterialTheme.colorScheme.error
                    )
                }
            },
            colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)
        )

        Column(
            modifier = Modifier
                .weight(1f)
                .verticalScroll(rememberScrollState())
                .padding(16.dp)
        ) {
            // DIGITAL INVOICE PAPER SHEET
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .testTag("receipt_paper_card"),
                colors = CardDefaults.cardColors(containerColor = Color.White),
                shape = RoundedCornerShape(16.dp),
                elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
                border = BorderStroke(1.dp, Color(0xFFE0E0E0))
            ) {
                Box(
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Column(
                        modifier = Modifier.padding(20.dp)
                    ) {
                        // 1. Invoice Header
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.SpaceBetween,
                            verticalAlignment = Alignment.Top
                        ) {
                            Column(modifier = Modifier.weight(1f)) {
                                BusinessLogoView(profile = profile, size = 50)
                                Spacer(modifier = Modifier.height(8.dp))
                                Text(
                                    text = profile.name,
                                    style = MaterialTheme.typography.titleMedium,
                                    fontWeight = FontWeight.ExtraBold,
                                    color = Color.Black
                                )
                                if (profile.address.isNotBlank()) {
                                    Text(
                                        profile.address,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color.Gray
                                    )
                                }
                                if (profile.email.isNotBlank()) {
                                    Text(
                                        profile.email,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color.Gray
                                    )
                                }
                                if (profile.phone.isNotBlank()) {
                                    Text(
                                        profile.phone,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color.Gray
                                    )
                                }
                            }

                            Column(horizontalAlignment = Alignment.End) {
                                Surface(
                                    color = MaterialTheme.colorScheme.primary,
                                    shape = RoundedCornerShape(8.dp)
                                ) {
                                    Text(
                                        text = "INVOICE",
                                        fontWeight = FontWeight.Black,
                                        fontSize = 14.sp,
                                        color = Color.White,
                                        modifier = Modifier.padding(
                                            horizontal = 10.dp, vertical = 4.dp
                                        )
                                    )
                                }
                                Spacer(modifier = Modifier.height(6.dp))
                                Text(
                                    text = r.receiptNumber,
                                    style = MaterialTheme.typography.titleSmall,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Black
                                )
                                Text(
                                    text = "Date: $formattedDate",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = Color.DarkGray
                                )
                            }
                        }

                        HorizontalDivider(
                            modifier = Modifier.padding(vertical = 16.dp), color = Color(0xFFEEEEEE)
                        )

                        // 2. Client Billing Details
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.SpaceBetween
                        ) {
                            Column(modifier = Modifier.weight(1f)) {
                                Text(
                                    text = "BILLED TO",
                                    style = MaterialTheme.typography.labelSmall,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray
                                )
                                Text(
                                    text = if (r.customerName.isNotBlank()) r.customerName else "Client / Organization",
                                    style = MaterialTheme.typography.bodyLarge,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Black
                                )
                                if (r.customerEmail.isNotBlank()) {
                                    Text(
                                        r.customerEmail,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color.DarkGray
                                    )
                                }
                                if (r.customerPhone.isNotBlank()) {
                                    Text(
                                        r.customerPhone,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color.DarkGray
                                    )
                                }
                            }

                            Column(horizontalAlignment = Alignment.End) {
                                Text(
                                    text = "PAYMENT METHOD",
                                    style = MaterialTheme.typography.labelSmall,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray
                                )
                                Text(
                                    text = r.paymentMethod,
                                    style = MaterialTheme.typography.bodyLarge,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Black
                                )
                            }
                        }

                        Spacer(modifier = Modifier.height(16.dp))

                        // 3. Deliverables Table
                        Surface(
                            color = Color(0xFFF8F9FA), shape = RoundedCornerShape(8.dp)
                        ) {
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 12.dp, vertical = 8.dp),
                                horizontalArrangement = Arrangement.SpaceBetween
                            ) {
                                Text(
                                    "Service / Task",
                                    style = MaterialTheme.typography.labelMedium,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray,
                                    modifier = Modifier.weight(1.8f)
                                )
                                Text(
                                    "Qty",
                                    style = MaterialTheme.typography.labelMedium,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray,
                                    modifier = Modifier.weight(0.6f),
                                    textAlign = TextAlign.Center
                                )
                                Text(
                                    "Rate",
                                    style = MaterialTheme.typography.labelMedium,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray,
                                    modifier = Modifier.weight(0.8f),
                                    textAlign = TextAlign.End
                                )
                                Text(
                                    "Total",
                                    style = MaterialTheme.typography.labelMedium,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Gray,
                                    modifier = Modifier.weight(1f),
                                    textAlign = TextAlign.End
                                )
                            }
                        }

                        rw.items.forEach { item ->
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 12.dp, vertical = 10.dp),
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                Text(
                                    text = item.name,
                                    style = MaterialTheme.typography.bodyMedium,
                                    fontWeight = FontWeight.Medium,
                                    color = Color.Black,
                                    modifier = Modifier.weight(1.8f)
                                )
                                Text(
                                    text = "${item.quantity}",
                                    style = MaterialTheme.typography.bodyMedium,
                                    color = Color.DarkGray,
                                    modifier = Modifier.weight(0.6f),
                                    textAlign = TextAlign.Center
                                )
                                Text(
                                    text = "${profile.currencySymbol}${formatMoney(item.unitPrice)}",
                                    style = MaterialTheme.typography.bodyMedium,
                                    color = Color.DarkGray,
                                    modifier = Modifier.weight(0.8f),
                                    textAlign = TextAlign.End
                                )
                                Text(
                                    text = "${profile.currencySymbol}${formatMoney(item.unitPrice * item.quantity)}",
                                    style = MaterialTheme.typography.bodyMedium,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Black,
                                    modifier = Modifier.weight(1f),
                                    textAlign = TextAlign.End
                                )
                            }
                            HorizontalDivider(color = Color(0xFFF0F0F0))
                        }

                        Spacer(modifier = Modifier.height(16.dp))

                        // 4. Financial Calculations Summary
                        Column(
                            modifier = Modifier.fillMaxWidth()
                        ) {
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 12.dp, vertical = 2.dp),
                                horizontalArrangement = Arrangement.SpaceBetween,
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                Text(
                                    "Subtotal:",
                                    style = MaterialTheme.typography.bodyMedium,
                                    color = Color.Gray
                                )
                                Text(
                                    "${profile.currencySymbol}${formatMoney(r.subtotal)}",
                                    style = MaterialTheme.typography.bodyMedium,
                                    fontWeight = FontWeight.SemiBold,
                                    color = Color.Black
                                )
                            }

                            if (r.discountTotal > 0) {
                                val discDetails =
                                    if (r.discountIsPercentage) " (${r.discountAmount}%):" else ":"
                                Row(
                                    modifier = Modifier
                                        .fillMaxWidth()
                                        .padding(horizontal = 12.dp, vertical = 2.dp),
                                    horizontalArrangement = Arrangement.SpaceBetween,
                                    verticalAlignment = Alignment.CenterVertically
                                ) {
                                    Text(
                                        "Discount$discDetails",
                                        style = MaterialTheme.typography.bodyMedium,
                                        color = Color(0xFFC62828)
                                    )
                                    Text(
                                        "-${profile.currencySymbol}${formatMoney(r.discountTotal)}",
                                        style = MaterialTheme.typography.bodyMedium,
                                        fontWeight = FontWeight.SemiBold,
                                        color = Color(0xFFC62828)
                                    )
                                }
                            }

                            if (r.taxTotal > 0) {
                                val taxLbl = r.taxLabel.ifBlank { "Tax" }
                                Row(
                                    modifier = Modifier
                                        .fillMaxWidth()
                                        .padding(horizontal = 12.dp, vertical = 2.dp),
                                    horizontalArrangement = Arrangement.SpaceBetween,
                                    verticalAlignment = Alignment.CenterVertically
                                ) {
                                    Text(
                                        "$taxLbl (${r.taxRate}%):",
                                        style = MaterialTheme.typography.bodyMedium,
                                        color = Color.Gray
                                    )
                                    Text(
                                        "${profile.currencySymbol}${formatMoney(r.taxTotal)}",
                                        style = MaterialTheme.typography.bodyMedium,
                                        fontWeight = FontWeight.SemiBold,
                                        color = Color.Black
                                    )
                                }
                            }

                            HorizontalDivider(
                                modifier = Modifier.padding(
                                    horizontal = 12.dp, vertical = 8.dp
                                ), color = Color(0xFFEEEEEE)
                            )

                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 12.dp, vertical = 2.dp),
                                horizontalArrangement = Arrangement.SpaceBetween,
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                Text(
                                    "GRAND TOTAL:",
                                    style = MaterialTheme.typography.titleSmall,
                                    fontWeight = FontWeight.Bold,
                                    color = Color.Black
                                )
                                Text(
                                    text = "${profile.currencySymbol}${formatMoney(r.grandTotal)}",
                                    style = MaterialTheme.typography.titleMedium,
                                    fontWeight = FontWeight.ExtraBold,
                                    color = MaterialTheme.colorScheme.primary
                                )
                            }
                        }

                        if (r.notes.isNotBlank()) {
                            Spacer(modifier = Modifier.height(16.dp))
                            Surface(
                                color = Color(0xFFFFF8E1),
                                shape = RoundedCornerShape(8.dp),
                                modifier = Modifier.fillMaxWidth()
                            ) {
                                Column(modifier = Modifier.padding(10.dp)) {
                                    Text(
                                        "PAYMENT TERMS & BANK INSTRUCTIONS",
                                        style = MaterialTheme.typography.labelSmall,
                                        fontWeight = FontWeight.Bold,
                                        color = Color(0xFFF57F17)
                                    )
                                    Text(
                                        r.notes,
                                        style = MaterialTheme.typography.bodySmall,
                                        color = Color(0xFF5D4037)
                                    )
                                }
                            }
                        }

                        Spacer(modifier = Modifier.height(24.dp))

                        // 5. OFFICIAL DOUBLE-BORDERED RUBBER STAMP
                        Box(
                            modifier = Modifier
                                .padding(start = 8.dp)
                                .rotate(-10f)
                                .border(
                                    width = 2.5.dp,
                                    color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.60f) else Color(
                                        0xFFC62828
                                    ).copy(alpha = 0.60f),
                                    shape = RoundedCornerShape(6.dp)
                                )
                                .padding(3.dp)
                                .border(
                                    width = 1.dp,
                                    color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.35f) else Color(
                                        0xFFC62828
                                    ).copy(alpha = 0.35f),
                                    shape = RoundedCornerShape(3.dp)
                                )
                                .background(
                                    color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.04f) else Color(
                                        0xFFC62828
                                    ).copy(alpha = 0.04f), shape = RoundedCornerShape(3.dp)
                                )
                                .padding(horizontal = 12.dp, vertical = 6.dp)
                        ) {
                            Column(
                                horizontalAlignment = Alignment.CenterHorizontally
                            ) {
                                Text(
                                    text = "OFFICIAL STAMP",
                                    fontSize = 8.sp,
                                    fontWeight = FontWeight.Bold,
                                    letterSpacing = 1.2.sp,
                                    color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.60f) else Color(
                                        0xFFC62828
                                    ).copy(alpha = 0.60f)
                                )
                                Spacer(modifier = Modifier.height(2.dp))
                                Row(
                                    verticalAlignment = Alignment.CenterVertically,
                                    horizontalArrangement = Arrangement.spacedBy(4.dp)
                                ) {
                                    Icon(
                                        imageVector = if (r.isPaid) Icons.Default.CheckCircle else Icons.Default.Schedule,
                                        contentDescription = null,
                                        tint = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.65f) else Color(
                                            0xFFC62828
                                        ).copy(alpha = 0.65f),
                                        modifier = Modifier.size(16.dp)
                                    )
                                    Text(
                                        text = if (r.isPaid) "PAID" else "PENDING",
                                        style = MaterialTheme.typography.titleMedium,
                                        fontWeight = FontWeight.Black,
                                        letterSpacing = 1.5.sp,
                                        color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.65f) else Color(
                                            0xFFC62828
                                        ).copy(alpha = 0.65f),
                                        fontSize = 18.sp
                                    )
                                }
                                Spacer(modifier = Modifier.height(2.dp))
                                Text(
                                    text = "VERIFIED",
                                    fontSize = 8.sp,
                                    fontWeight = FontWeight.Bold,
                                    letterSpacing = 1.2.sp,
                                    color = if (r.isPaid) Color(0xFF2E7D32).copy(alpha = 0.50f) else Color(
                                        0xFFC62828
                                    ).copy(alpha = 0.50f)
                                )
                            }
                        }

                        Spacer(modifier = Modifier.height(16.dp))
                    }
                }
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Export & Share Options Card
            Card(
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(16.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
                        alpha = 0.4f
                    )
                ),
                border = BorderStroke(
                    1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
                )
            ) {
                Column(
                    modifier = Modifier.padding(16.dp)
                ) {
                    Text(
                        text = "Share Invoice",
                        style = MaterialTheme.typography.labelLarge,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        Button(
                            onClick = { shareReceiptImage(context, rw, profile) },
                            modifier = Modifier.weight(1f),
                            shape = RoundedCornerShape(10.dp)
                        ) {
                            Icon(
                                Icons.Default.Share,
                                contentDescription = null,
                                modifier = Modifier.size(16.dp)
                            )
                            Spacer(modifier = Modifier.width(6.dp))
                            Text("Share Image", fontSize = 13.sp, fontWeight = FontWeight.Bold)
                        }

                        Button(
                            onClick = { shareReceiptPdf(context, rw, profile) },
                            modifier = Modifier.weight(1f),
                            colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary),
                            shape = RoundedCornerShape(10.dp)
                        ) {
                            Icon(
                                Icons.Default.PictureAsPdf,
                                contentDescription = null,
                                modifier = Modifier.size(16.dp)
                            )
                            Spacer(modifier = Modifier.width(6.dp))
                            Text("Share PDF", fontSize = 13.sp, fontWeight = FontWeight.Bold)
                        }
                    }

                    Spacer(modifier = Modifier.height(14.dp))

                    Text(
                        text = "Save to Device",
                        style = MaterialTheme.typography.labelLarge,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        OutlinedButton(
                            onClick = { saveReceiptImageToDevice(context, rw, profile) },
                            modifier = Modifier.weight(1f),
                            shape = RoundedCornerShape(10.dp)
                        ) {
                            Icon(
                                Icons.Default.Image,
                                contentDescription = null,
                                modifier = Modifier.size(16.dp)
                            )
                            Spacer(modifier = Modifier.width(6.dp))
                            Text("Save Image", fontSize = 13.sp, fontWeight = FontWeight.Bold)
                        }

                        OutlinedButton(
                            onClick = { saveReceiptPdfToDevice(context, rw, profile) },
                            modifier = Modifier.weight(1f),
                            shape = RoundedCornerShape(10.dp)
                        ) {
                            Icon(
                                Icons.Default.PictureAsPdf,
                                contentDescription = null,
                                modifier = Modifier.size(16.dp)
                            )
                            Spacer(modifier = Modifier.width(6.dp))
                            Text("Save PDF", fontSize = 13.sp, fontWeight = FontWeight.Bold)
                        }
                    }
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            // Quick Status Toggle Button
            OutlinedButton(
                onClick = {
                    val updatedReceipt = r.copy(isPaid = !r.isPaid)
                    viewModel.startEditReceipt(rw.copy(receipt = updatedReceipt))
                    viewModel.saveReceipt {
                        Toast.makeText(
                            context,
                            "Invoice marked as ${if (!r.isPaid) "PAID" else "PENDING"}",
                            Toast.LENGTH_SHORT
                        ).show()
                    }
                },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(12.dp),
                colors = ButtonDefaults.outlinedButtonColors(
                    contentColor = if (r.isPaid) Color(0xFFC62828) else Color(0xFF2E7D32)
                )
            ) {
                Icon(
                    imageVector = if (r.isPaid) Icons.Default.Schedule else Icons.Default.CheckCircle,
                    contentDescription = "Toggle Status",
                    modifier = Modifier.size(18.dp)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    text = if (r.isPaid) "Mark as Unpaid / Pending" else "Mark Invoice as PAID",
                    fontWeight = FontWeight.Bold
                )
            }
        }
    }
}

Business / Freelancer Profile Edit Screen

Studio branding, logo, contact details, and default tax settings are configured here.


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BusinessProfileEditScreen(
    viewModel: ReceiptViewModel, profile: BusinessProfile, onPickCustomLogo: () -> Unit
) {
    val context = LocalContext.current

    var name by remember(profile) { mutableStateOf(profile.name) }
    var address by remember(profile) { mutableStateOf(profile.address) }
    var phone by remember(profile) { mutableStateOf(profile.phone) }
    var email by remember(profile) { mutableStateOf(profile.email) }
    var website by remember(profile) { mutableStateOf(profile.website) }
    var defaultTaxRate by remember(profile) { mutableStateOf(profile.defaultTaxRate.toString()) }
    var taxLabel by remember(profile) { mutableStateOf(profile.taxLabel) }
    var currencySymbol by remember(profile) { mutableStateOf(profile.currencySymbol) }

    var logoType by remember(profile) { mutableStateOf(profile.logoType) }
    var logoPresetName by remember(profile) { mutableStateOf(profile.logoPresetName) }
    var logoColorArgb by remember(profile) { mutableStateOf(profile.logoColorArgb) }

    val tempProfile = remember(
        name,
        address,
        phone,
        email,
        website,
        defaultTaxRate,
        taxLabel,
        currencySymbol,
        logoType,
        logoPresetName,
        profile.logoCustomUri,
        logoColorArgb
    ) {
        profile.copy(
            name = name,
            address = address,
            phone = phone,
            email = email,
            website = website,
            defaultTaxRate = defaultTaxRate.toDoubleOrNull() ?: 0.0,
            taxLabel = taxLabel,
            currencySymbol = currencySymbol,
            logoType = logoType,
            logoPresetName = logoPresetName,
            logoColorArgb = logoColorArgb
        )
    }

    Column(modifier = Modifier.fillMaxSize()) {
        TopAppBar(
            title = { Text("Studio & Profile Settings", fontWeight = FontWeight.Bold) },
            navigationIcon = {
                IconButton(onClick = { viewModel.navigateTo(Screen.Dashboard) }) {
                    Icon(Icons.Default.ArrowBack, contentDescription = "Back")
                }
            },
            actions = {
                Button(
                    onClick = {
                        viewModel.saveProfile(
                            name = name,
                            address = address,
                            phone = phone,
                            email = email,
                            website = website,
                            defaultTaxRate = defaultTaxRate.toDoubleOrNull() ?: 0.0,
                            taxLabel = taxLabel,
                            currencySymbol = currencySymbol,
                            logoType = logoType,
                            logoPresetName = logoPresetName,
                            logoCustomUri = profile.logoCustomUri,
                            logoColorArgb = logoColorArgb,
                            onSuccess = {
                                Toast.makeText(context, "Studio profile saved!", Toast.LENGTH_SHORT)
                                    .show()
                                viewModel.navigateTo(Screen.Dashboard)
                            })
                    },
                    colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
                    shape = RoundedCornerShape(10.dp),
                    modifier = Modifier
                        .padding(end = 8.dp)
                        .testTag("save_profile_button")
                ) {
                    Icon(Icons.Default.Save, contentDescription = "Save")
                    Spacer(modifier = Modifier.width(4.dp))
                    Text("Save", fontWeight = FontWeight.Bold)
                }
            },
            colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)
        )

        Column(
            modifier = Modifier
                .weight(1f)
                .verticalScroll(rememberScrollState())
                .padding(16.dp)
        ) {
            // Live Studio Logo Preview Card
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 16.dp)
                    .clickable { onPickCustomLogo() }, colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
                        alpha = 0.5f
                    )
                ), shape = RoundedCornerShape(14.dp)
            ) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "STUDIO LOGO PREVIEW",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary,
                        modifier = Modifier.padding(bottom = 6.dp)
                    )
                    BusinessLogoView(profile = tempProfile, size = 72)
                    Spacer(modifier = Modifier.height(6.dp))
                    Text(
                        text = "Tap preview to upload custom photo logo",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        fontSize = 11.sp
                    )
                }
            }

            Text(
                text = "Logo Style & Brand Color",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            // Logo Type Selector
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 12.dp),
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                val types = listOf(
                    "monogram" to "Monogram", "preset" to "Presets", "custom" to "Custom Photo"
                )
                types.forEach { (typeKey, typeLabel) ->
                    val isSelected = logoType == typeKey
                    FilterChip(
                        selected = isSelected,
                        onClick = {
                            if (typeKey == "custom") {
                                onPickCustomLogo()
                            }
                            logoType = typeKey
                        },
                        label = { Text(typeLabel, fontSize = 12.sp) },
                        modifier = Modifier.weight(1f),
                        colors = FilterChipDefaults.filterChipColors(
                            selectedContainerColor = MaterialTheme.colorScheme.primary,
                            selectedLabelColor = MaterialTheme.colorScheme.onPrimary
                        )
                    )
                }
            }

            // Preset Icon Selector (if preset selected)
            if (logoType == "preset") {
                Text(
                    "Select Studio Icon:",
                    style = MaterialTheme.typography.bodySmall,
                    modifier = Modifier.padding(bottom = 6.dp)
                )
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(bottom = 12.dp),
                    horizontalArrangement = Arrangement.Center
                ) {
                    val presets = listOf(
                        "computer", "palette", "shopping_bag", "restaurant", "local_shipping"
                    )
                    presets.forEach { pr ->
                        val isSelected = logoPresetName == pr
                        IconButton(
                            onClick = { logoPresetName = pr },
                            modifier = Modifier
                                .padding(horizontal = 4.dp)
                                .size(44.dp)
                                .clip(CircleShape)
                                .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
                        ) {
                            val icon = when (pr) {
                                "computer" -> Icons.Default.Computer
                                "palette" -> Icons.Default.Palette
                                "shopping_bag" -> Icons.Default.ShoppingBag
                                "restaurant" -> Icons.Default.Restaurant
                                "local_shipping" -> Icons.Default.LocalShipping
                                else -> Icons.Default.Business
                            }
                            Icon(
                                icon,
                                contentDescription = pr,
                                tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                    }
                }
            }

            // Palette Color Selector
            Text(
                "Select Accent Brand Color:",
                style = MaterialTheme.typography.bodySmall,
                modifier = Modifier.padding(bottom = 6.dp)
            )
            val brandColors = listOf(
                0xFF4F46E5.toInt(), // Indigo
                0xFF00796B.toInt(), // Teal
                0xFF1565C0.toInt(), // Blue
                0xFF6A1B9A.toInt(), // Purple
                0xFF2E7D32.toInt(), // Green
                0xFFD84315.toInt()  // Deep Orange
            )
            ColorPaletteSelector(
                selectedColor = logoColorArgb,
                colors = brandColors,
                onColorSelected = { logoColorArgb = it })

            Spacer(modifier = Modifier.height(20.dp))

            Text(
                text = "Freelancer / Studio Profile Details",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Studio / Freelancer Name") },
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 12.dp)
                    .testTag("profile_name_input"),
                shape = RoundedCornerShape(10.dp)
            )

            OutlinedTextField(
                value = address,
                onValueChange = { address = it },
                label = { Text("Billing / Office Address") },
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 12.dp)
                    .testTag("profile_address_input"),
                shape = RoundedCornerShape(10.dp)
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                OutlinedTextField(
                    value = phone,
                    onValueChange = { phone = it },
                    label = { Text("Phone Number") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
                    singleLine = true,
                    modifier = Modifier
                        .weight(1f)
                        .testTag("profile_phone_input"),
                    shape = RoundedCornerShape(10.dp)
                )

                OutlinedTextField(
                    value = email,
                    onValueChange = { email = it },
                    label = { Text("Email Address") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
                    singleLine = true,
                    modifier = Modifier
                        .weight(1f)
                        .testTag("profile_email_input"),
                    shape = RoundedCornerShape(10.dp)
                )
            }

            Spacer(modifier = Modifier.height(12.dp))

            OutlinedTextField(
                value = website,
                onValueChange = { website = it },
                label = { Text("Website Portfolio URL") },
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 16.dp)
                    .testTag("profile_website_input"),
                shape = RoundedCornerShape(10.dp)
            )

            Text(
                text = "Global Defaults",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 12.dp)
            )

            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
                shape = RoundedCornerShape(12.dp),
                border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(12.dp)
                    ) {
                        OutlinedTextField(
                            value = defaultTaxRate,
                            onValueChange = { defaultTaxRate = it },
                            label = { Text("Default Tax Rate (%)") },
                            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                            singleLine = true,
                            modifier = Modifier
                                .weight(1f)
                                .testTag("profile_tax_rate_input"),
                            shape = RoundedCornerShape(8.dp)
                        )

                        OutlinedTextField(
                            value = taxLabel,
                            onValueChange = { taxLabel = it },
                            label = { Text("Tax (e.g. VAT, GST)") },
                            singleLine = true,
                            modifier = Modifier
                                .weight(1f)
                                .testTag("profile_tax_label_input"),
                            shape = RoundedCornerShape(8.dp)
                        )
                    }

                    Spacer(modifier = Modifier.height(12.dp))

                    OutlinedTextField(
                        value = currencySymbol,
                        onValueChange = { currencySymbol = it },
                        label = { Text("Currency Symbol (e.g. $, €, £, ¥, PKR)") },
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .testTag("profile_currency_input"),
                        shape = RoundedCornerShape(8.dp)
                    )
                }
            }
            Spacer(modifier = Modifier.height(32.dp))
        }
    }
}

@Composable
fun ColorPaletteSelector(
    selectedColor: Int, colors: List, onColorSelected: (Int) -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center
    ) {
        colors.forEach { col ->
            val colorVal = Color(col)
            Box(
                modifier = Modifier
                    .padding(horizontal = 4.dp)
                    .size(36.dp)
                    .clip(CircleShape)
                    .background(colorVal)
                    .border(
                        width = if (selectedColor == col) 3.dp else 1.dp,
                        color = if (selectedColor == col) MaterialTheme.colorScheme.onSurface else Color.Transparent,
                        shape = CircleShape
                    )
                    .clickable { onColorSelected(col) })
        }
    }
}

Reusable Business Logo Composable

This universal composable renders all three logo types — monogram, preset icon, or custom photo — in a single place.


@Composable
fun BusinessLogoView(
    profile: BusinessProfile, size: Int
) {
    val sizeDp = size.dp
    val textInitials = remember(profile.name) {
        if (profile.name.isNotBlank()) {
            val words = profile.name.trim().split("\\s+".toRegex())
            if (words.size >= 2) {
                (words[0].take(1) + words[1].take(1)).uppercase()
            } else {
                profile.name.trim().take(2).uppercase()
            }
        } else {
            "F"
        }
    }

    Box(
        modifier = Modifier
            .size(sizeDp)
            .clip(RoundedCornerShape(size / 5))
            .background(Color(profile.logoColorArgb)), contentAlignment = Alignment.Center
    ) {
        when (profile.logoType) {
            "monogram" -> {
                Text(
                    text = textInitials,
                    fontWeight = FontWeight.ExtraBold,
                    color = Color.White,
                    fontSize = (size * 0.35f).sp,
                    textAlign = TextAlign.Center
                )
            }

            "preset" -> {
                val icon = when (profile.logoPresetName) {
                    "computer" -> Icons.Default.Computer
                    "palette" -> Icons.Default.Palette
                    "shopping_bag" -> Icons.Default.ShoppingBag
                    "restaurant" -> Icons.Default.Restaurant
                    "local_shipping" -> Icons.Default.LocalShipping
                    else -> Icons.Default.Business
                }
                Icon(
                    imageVector = icon,
                    contentDescription = profile.logoPresetName,
                    tint = Color.White,
                    modifier = Modifier.size((size * 0.5f).dp)
                )
            }

            "custom" -> {
                val painter = rememberAsyncImagePainter(model = profile.logoCustomUri)
                val state = painter.state
                if (profile.logoCustomUri != null && state !is AsyncImagePainter.State.Error) {
                    Image(
                        painter = painter,
                        contentDescription = "Studio Logo",
                        modifier = Modifier
                            .fillMaxSize()
                            .clip(RoundedCornerShape(size / 5)),
                        contentScale = ContentScale.Crop
                    )
                } else {
                    Text(
                        text = textInitials,
                        fontWeight = FontWeight.ExtraBold,
                        color = Color.White,
                        fontSize = (size * 0.35f).sp,
                        textAlign = TextAlign.Center
                    )
                }
            }

            else -> {
                Text(
                    text = textInitials,
                    fontWeight = FontWeight.ExtraBold,
                    color = Color.White,
                    fontSize = (size * 0.35f).sp,
                    textAlign = TextAlign.Center
                )
            }
        }
    }
}

@Composable
fun StatusBadge(isPaid: Boolean) {
    Box(
        modifier = Modifier
            .clip(RoundedCornerShape(6.dp))
            .background(if (isPaid) Color(0xFFE8F5E9) else Color(0xFFFFEBEE))
            .padding(horizontal = 8.dp, vertical = 2.dp)
    ) {
        Text(
            text = if (isPaid) "Paid" else "Pending",
            style = MaterialTheme.typography.bodySmall,
            fontWeight = FontWeight.Bold,
            color = if (isPaid) Color(0xFF2E7D32) else Color(0xFFC62828)
        )
    }
}

fun formatMoney(amount: Double): String {
    val df = DecimalFormat("#,##0.00")
    return df.format(amount)
}

Invoice PDF & Image Export Engine

This is the most interesting part — where Canvas drawing is used to manually render the invoice into both PNG image and PDF formats, along with scoped-storage-safe save/share logic.


fun wrapTextLines(text: String, paint: Paint, maxWidth: Float): List {
    val result = mutableListOf()
    val paragraphs = text.split("\n")
    for (paragraph in paragraphs) {
        if (paragraph.isBlank()) {
            result.add("")
            continue
        }
        val words = paragraph.split(" ")
        var currentLine = StringBuilder()
        for (word in words) {
            val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
            if (paint.measureText(testLine) <= maxWidth) { currentLine = StringBuilder(testLine) } else { if (currentLine.isNotEmpty()) { result.add(currentLine.toString()) } currentLine = StringBuilder(word) } } if (currentLine.isNotEmpty()) { result.add(currentLine.toString()) } } return result } fun generateReceiptBitmap( context: Context, rw: ReceiptWithItems, profile: BusinessProfile ): Bitmap { val r = rw.receipt val width = 1000 val notePaint = Paint().apply { isAntiAlias = true textSize = 22f color = android.graphics.Color.parseColor("#616161") typeface = Typeface.DEFAULT } val noteText = if (r.notes.isNotBlank()) r.notes else "Thank you for your business!" val noteLines = wrapTextLines(noteText, notePaint, 900f) val noteHeight = noteLines.size * 35 val hasCustomLogo = profile.logoType == "custom" && !profile.logoCustomUri.isNullOrBlank() val headerHeight = if (hasCustomLogo) 320 else 240 val metaHeight = 120 val itemsHeaderHeight = 60 val itemsHeight = (rw.items.size * 65).coerceAtLeast(65) val totalsHeight = 220 val footerHeight = noteHeight + 180 val totalHeight = headerHeight + metaHeight + itemsHeaderHeight + itemsHeight + totalsHeight + footerHeight + 100 val bitmap = Bitmap.createBitmap(width, totalHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.drawColor(android.graphics.Color.WHITE) val textPaint = Paint().apply { isAntiAlias = true color = android.graphics.Color.BLACK typeface = Typeface.DEFAULT } val boldPaint = Paint().apply { isAntiAlias = true color = android.graphics.Color.BLACK typeface = Typeface.DEFAULT_BOLD } val mutedPaint = Paint().apply { isAntiAlias = true color = android.graphics.Color.parseColor("#616161") typeface = Typeface.DEFAULT } val linePaint = Paint().apply { isAntiAlias = true color = android.graphics.Color.parseColor("#BDBDBD") strokeWidth = 3f pathEffect = DashPathEffect(floatArrayOf(15f, 15f), 0f) } var leftY = 60f var rightY = 60f // 1A. TOP LEFT: Logo & Business Details if (hasCustomLogo) { try { val logoUri = Uri.parse(profile.logoCustomUri) val logoStream = if (logoUri.scheme == "file") { File(logoUri.path ?: "").takeIf { it.exists() }?.inputStream() } else { context.contentResolver.openInputStream(logoUri) } if (logoStream != null) { val logoBitmap = BitmapFactory.decodeStream(logoStream) logoStream.close() if (logoBitmap != null) { val targetSize = 100 val scaled = Bitmap.createScaledBitmap(logoBitmap, targetSize, targetSize, true) val circlePath = Path().apply { addCircle( 50f + targetSize / 2f, leftY + targetSize / 2f, targetSize / 2f, Path.Direction.CW ) } canvas.save() canvas.clipPath(circlePath) canvas.drawBitmap(scaled, 50f, leftY, null) canvas.restore() leftY += targetSize + 20f } } } catch (e: Exception) { e.printStackTrace() } } boldPaint.textSize = 38f boldPaint.color = android.graphics.Color.BLACK val nameBaseline = leftY + 38f canvas.drawText(profile.name.ifBlank { "Freelancer / Studio" }, 50f, nameBaseline, boldPaint) leftY = nameBaseline + 14f if (profile.address.isNotBlank()) { mutedPaint.textSize = 24f val addrLines = wrapTextLines(profile.address, mutedPaint, 520f) addrLines.forEach { line ->
            val lineBaseline = leftY + 24f
            canvas.drawText(line, 50f, lineBaseline, mutedPaint)
            leftY = lineBaseline + 8f
        }
    }

    val contactStr =
        listOf(profile.phone, profile.email).filter { it.isNotBlank() }.joinToString("  |  ")
    if (contactStr.isNotBlank()) {
        mutedPaint.textSize = 22f
        val cLines = wrapTextLines(contactStr, mutedPaint, 520f)
        cLines.forEach { line ->
            val lineBaseline = leftY + 22f
            canvas.drawText(line, 50f, lineBaseline, mutedPaint)
            leftY = lineBaseline + 8f
        }
    }

    if (profile.website.isNotBlank()) {
        mutedPaint.textSize = 22f
        val webBaseline = leftY + 22f
        canvas.drawText(profile.website, 50f, webBaseline, mutedPaint)
        leftY = webBaseline + 8f
    }

    // 1B. TOP RIGHT: Clean Text INVOICE, Direct Invoice Number, Issue Date
    val rightX = width - 50f // 950f

    boldPaint.textSize = 38f
    boldPaint.color = android.graphics.Color.parseColor("#1565C0")
    val invTitleW = boldPaint.measureText("INVOICE")
    canvas.drawText("INVOICE", rightX - invTitleW, rightY + 38f, boldPaint)
    rightY += 50f

    textPaint.textSize = 22f
    textPaint.typeface = Typeface.DEFAULT
    textPaint.color = android.graphics.Color.parseColor("#212121")
    val numW = textPaint.measureText(r.receiptNumber)
    canvas.drawText(r.receiptNumber, rightX - numW, rightY + 22f, textPaint)
    rightY += 30f

    boldPaint.textSize = 18f
    boldPaint.color = android.graphics.Color.parseColor("#757575")
    val dateLabelW = boldPaint.measureText("ISSUE DATE")
    canvas.drawText("ISSUE DATE", rightX - dateLabelW, rightY + 18f, boldPaint)
    rightY += 24f

    val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
    val dateStr = sdf.format(Date(r.dateTimestamp))
    textPaint.textSize = 22f
    textPaint.typeface = Typeface.DEFAULT
    val dateW = textPaint.measureText(dateStr)
    canvas.drawText(dateStr, rightX - dateW, rightY + 22f, textPaint)
    rightY += 30f

    var currentY = maxOf(leftY, rightY) + 20f

    canvas.drawLine(50f, currentY, width - 50f, currentY, linePaint)
    currentY += 35f

    // 2. BILLED TO & PAYMENT METHOD
    boldPaint.textSize = 20f
    boldPaint.color = android.graphics.Color.parseColor("#9E9E9E")
    canvas.drawText("BILLED TO", 50f, currentY, boldPaint)
    val payLabelW = boldPaint.measureText("PAYMENT METHOD")
    canvas.drawText("PAYMENT METHOD", rightX - payLabelW, currentY, boldPaint)
    currentY += 28f

    textPaint.textSize = 26f
    textPaint.typeface = Typeface.DEFAULT_BOLD
    textPaint.color = android.graphics.Color.parseColor("#212121")
    val custName = if (r.customerName.isNotBlank()) r.customerName else "Client / Organization"
    var displayCust = custName
    while (displayCust.length > 3 && textPaint.measureText(displayCust) > 420f) {
        displayCust = displayCust.dropLast(1)
    }
    if (displayCust.length < custName.length) { displayCust = displayCust.take((displayCust.length - 2).coerceAtLeast(1)) + "..." } canvas.drawText(displayCust, 50f, currentY, textPaint) textPaint.typeface = Typeface.DEFAULT val payMethod = r.paymentMethod var displayPay = payMethod while (displayPay.length > 3 && textPaint.measureText(displayPay) > 420f) {
        displayPay = displayPay.dropLast(1)
    }
    if (displayPay.length < payMethod.length) { displayPay = displayPay.take((displayPay.length - 2).coerceAtLeast(1)) + "..." } val payW = textPaint.measureText(displayPay) canvas.drawText(displayPay, rightX - payW, currentY, textPaint) currentY += 40f canvas.drawLine(50f, currentY, width - 50f, currentY, linePaint) currentY += 35f // 3. Services Table Header boldPaint.textSize = 24f boldPaint.color = android.graphics.Color.parseColor("#757575") canvas.drawText("Service Description", 50f, currentY, boldPaint) canvas.drawText("Qty", 520f, currentY, boldPaint) val priceHeaderW = boldPaint.measureText("Rate") canvas.drawText("Rate", 750f - priceHeaderW, currentY, boldPaint) val totalHeaderW = boldPaint.measureText("Total") canvas.drawText("Total", width - 50f - totalHeaderW, currentY, boldPaint) currentY += 20f val solidLinePaint = Paint().apply { color = android.graphics.Color.parseColor("#EEEEEE") strokeWidth = 2f } canvas.drawLine(50f, currentY, width - 50f, currentY, solidLinePaint) currentY += 35f // Services List textPaint.textSize = 26f textPaint.color = android.graphics.Color.parseColor("#212121") rw.items.forEach { item ->
        textPaint.typeface = Typeface.DEFAULT_BOLD
        var itemName = item.name
        if (itemName.length > 22) itemName = itemName.take(20) + "..."
        canvas.drawText(itemName, 50f, currentY, textPaint)

        textPaint.typeface = Typeface.DEFAULT
        canvas.drawText(item.quantity.toString(), 525f, currentY, textPaint)

        val uPrice = "${profile.currencySymbol}${formatMoney(item.unitPrice)}"
        val uPriceW = textPaint.measureText(uPrice)
        canvas.drawText(uPrice, 750f - uPriceW, currentY, textPaint)

        val itemTot = "${profile.currencySymbol}${formatMoney(item.unitPrice * item.quantity)}"
        textPaint.typeface = Typeface.DEFAULT_BOLD
        val itemTotW = textPaint.measureText(itemTot)
        canvas.drawText(itemTot, width - 50f - itemTotW, currentY, textPaint)

        currentY += 45f
    }

    currentY += 10f
    canvas.drawLine(50f, currentY, width - 50f, currentY, linePaint)
    currentY += 35f

    // 4. Financial Totals
    fun drawSummaryLine(
        label: String,
        valStr: String,
        isBold: Boolean = false,
        textColor: Int = android.graphics.Color.parseColor("#212121")
    ) {
        textPaint.textSize = if (isBold) 32f else 26f
        textPaint.color =
            if (isBold) android.graphics.Color.BLACK else android.graphics.Color.parseColor("#757575")
        textPaint.typeface = if (isBold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
        canvas.drawText(label, 50f, currentY, textPaint)

        textPaint.color = textColor
        textPaint.typeface = Typeface.DEFAULT_BOLD
        val valW = textPaint.measureText(valStr)
        canvas.drawText(valStr, width - 50f - valW, currentY, textPaint)
        currentY += 40f
    }

    drawSummaryLine("Subtotal", "${profile.currencySymbol}${formatMoney(r.subtotal)}")
    if (r.discountTotal > 0) {
        val discDetails = if (r.discountIsPercentage) " (${r.discountAmount}%):" else ":"
        drawSummaryLine(
            "Discount$discDetails",
            "-${profile.currencySymbol}${formatMoney(r.discountTotal)}",
            textColor = android.graphics.Color.parseColor("#C62828")
        )
    }
    if (r.taxTotal > 0) {
        val taxLbl = r.taxLabel.ifBlank { "Tax" }
        drawSummaryLine(
            "$taxLbl (${r.taxRate}%):", "${profile.currencySymbol}${formatMoney(r.taxTotal)}"
        )
    }

    currentY += 10f
    canvas.drawLine(50f, currentY, width - 50f, currentY, solidLinePaint)
    currentY += 40f

    drawSummaryLine(
        "GRAND TOTAL",
        "${profile.currencySymbol}${formatMoney(r.grandTotal)}",
        isBold = true,
        textColor = android.graphics.Color.parseColor("#4F46E5")
    )

    currentY += 15f
    canvas.drawLine(50f, currentY, width - 50f, currentY, linePaint)
    currentY += 45f

    // Payment Terms / Notes wrapped nicely
    noteLines.forEach { line ->
        val lineW = notePaint.measureText(line)
        val lineX = (width - lineW) / 2f
        canvas.drawText(line, lineX, currentY, notePaint)
        currentY += 32f
    }

    currentY += 20f
    // 5. Official Double-Bordered Rubber Stamp
    val stampColor =
        if (r.isPaid) android.graphics.Color.parseColor("#2E7D32") else android.graphics.Color.parseColor(
            "#C62828"
        )

    val stampWidth = 230f
    val stampHeight = 115f
    val stampX = 60f
    val stampY = currentY

    canvas.save()
    canvas.rotate(-14f, stampX + stampWidth / 2f, stampY + stampHeight / 2f)

    val stampOuterPaint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.STROKE
        strokeWidth = 6f
        color = stampColor
        alpha = 150
    }
    val stampInnerPaint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.STROKE
        strokeWidth = 2.5f
        color = stampColor
        alpha = 110
    }
    val stampFillPaint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.FILL
        color = stampColor
        alpha = 10
    }

    val outerRect =
        android.graphics.RectF(stampX, stampY, stampX + stampWidth, stampY + stampHeight)
    val innerRect = android.graphics.RectF(
        stampX + 8f, stampY + 8f, stampX + stampWidth - 8f, stampY + stampHeight - 8f
    )

    canvas.drawRoundRect(outerRect, 14f, 14f, stampFillPaint)
    canvas.drawRoundRect(outerRect, 14f, 14f, stampOuterPaint)
    canvas.drawRoundRect(innerRect, 8f, 8f, stampInnerPaint)

    val stampTextPaint = Paint().apply {
        isAntiAlias = true
        color = stampColor
        typeface = Typeface.DEFAULT_BOLD
    }

    stampTextPaint.textSize = 18f
    stampTextPaint.alpha = 140
    val topText = "OFFICIAL STAMP"
    val topW = stampTextPaint.measureText(topText)
    canvas.drawText(topText, stampX + (stampWidth - topW) / 2f, stampY + 32f, stampTextPaint)

    stampTextPaint.textSize = 38f
    stampTextPaint.typeface = Typeface.DEFAULT_BOLD
    stampTextPaint.alpha = 160
    val statusText = if (r.isPaid) "PAID" else "PENDING"
    val statusW = stampTextPaint.measureText(statusText)
    canvas.drawText(statusText, stampX + (stampWidth - statusW) / 2f, stampY + 72f, stampTextPaint)

    stampTextPaint.textSize = 16f
    stampTextPaint.alpha = 120
    val btmText = "VERIFIED"
    val btmW = stampTextPaint.measureText(btmText)
    canvas.drawText(btmText, stampX + (stampWidth - btmW) / 2f, stampY + 98f, stampTextPaint)

    canvas.restore()

    return bitmap
}

fun saveReceiptImageToDevice(context: Context, rw: ReceiptWithItems, profile: BusinessProfile) {
    try {
        val bitmap = generateReceiptBitmap(context, rw, profile)
        val safeReceiptNum = rw.receipt.receiptNumber.replace(Regex("[^a-zA-Z0-9_-]"), "_")
        val filename = "Invoice_${safeReceiptNum}_${System.currentTimeMillis()}.png"

        var savedSuccessfully = false

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            try {
                val resolver = context.contentResolver
                val contentValues = ContentValues().apply {
                    put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
                    put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
                    put(
                        MediaStore.MediaColumns.RELATIVE_PATH,
                        Environment.DIRECTORY_PICTURES + "/Invoices"
                    )
                }
                val imageUri =
                    resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                if (imageUri != null) {
                    resolver.openOutputStream(imageUri)?.use { stream ->
                        bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
                        savedSuccessfully = true
                    }
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (!savedSuccessfully) {
            // Fallback 1: Legacy Public Pictures directory
            try {
                val imagesDir =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
                val receiptDir = File(imagesDir, "Invoices").apply { if (!exists()) mkdirs() }
                val imageFile = File(receiptDir, filename)
                FileOutputStream(imageFile).use { stream ->
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
                    savedSuccessfully = true
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (!savedSuccessfully) {
            // Fallback 2: App-specific external files directory (guaranteed to be writable without permissions)
            try {
                val appPicturesDir =
                    context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
                val receiptDir = File(appPicturesDir, "Invoices").apply { if (!exists()) mkdirs() }
                val imageFile = File(receiptDir, filename)
                FileOutputStream(imageFile).use { stream ->
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
                    savedSuccessfully = true
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (savedSuccessfully) {
            Toast.makeText(context, "Invoice image saved to Pictures/Invoices!", Toast.LENGTH_LONG)
                .show()
        } else {
            Toast.makeText(context, "Failed to save invoice image", Toast.LENGTH_SHORT).show()
        }
    } catch (e: Exception) {
        e.printStackTrace()
        Toast.makeText(context, "Error saving invoice: ${e.localizedMessage}", Toast.LENGTH_SHORT)
            .show()
    }
}

fun shareReceiptImage(context: Context, rw: ReceiptWithItems, profile: BusinessProfile) {
    try {
        val bitmap = generateReceiptBitmap(context, rw, profile)
        val safeReceiptNum = rw.receipt.receiptNumber.replace(Regex("[^a-zA-Z0-9_-]"), "_")
        val cachePath = File(context.cacheDir, "shared_invoices").apply { if (!exists()) mkdirs() }
        val file = File(cachePath, "Invoice_${safeReceiptNum}.png")
        FileOutputStream(file).use { stream ->
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
        }

        val contentUri = FileProvider.getUriForFile(
            context, "${context.packageName}.fileprovider", file
        )

        val shareIntent = Intent(Intent.ACTION_SEND).apply {
            type = "image/png"
            putExtra(Intent.EXTRA_STREAM, contentUri)
            putExtra(Intent.EXTRA_SUBJECT, "Invoice #${rw.receipt.receiptNumber}")
            putExtra(
                Intent.EXTRA_TEXT,
                "Here is your invoice #${rw.receipt.receiptNumber} from ${profile.name}."
            )
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }

        context.startActivity(Intent.createChooser(shareIntent, "Share Invoice Image Via"))
    } catch (e: Exception) {
        e.printStackTrace()
        Toast.makeText(
            context, "Error sharing invoice image: ${e.localizedMessage}", Toast.LENGTH_SHORT
        ).show()
    }
}

fun generateReceiptPdf(context: Context, rw: ReceiptWithItems, profile: BusinessProfile): File {
    val bitmap = generateReceiptBitmap(context, rw, profile)
    val pdfDocument = PdfDocument()
    val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create()
    val page = pdfDocument.startPage(pageInfo)
    val canvas = page.canvas
    canvas.drawBitmap(bitmap, 0f, 0f, null)
    pdfDocument.finishPage(page)

    val safeReceiptNum = rw.receipt.receiptNumber.replace(Regex("[^a-zA-Z0-9_-]"), "_")
    val pdfDir = File(context.cacheDir, "generated_pdfs").apply { if (!exists()) mkdirs() }
    val pdfFile = File(pdfDir, "Invoice_${safeReceiptNum}.pdf")
    FileOutputStream(pdfFile).use { out ->
        pdfDocument.writeTo(out)
    }
    pdfDocument.close()
    return pdfFile
}

fun saveReceiptPdfToDevice(context: Context, rw: ReceiptWithItems, profile: BusinessProfile) {
    try {
        val safeReceiptNum = rw.receipt.receiptNumber.replace(Regex("[^a-zA-Z0-9_-]"), "_")
        val filename = "Invoice_${safeReceiptNum}.pdf"
        var savedSuccessfully = false

        val pdfFile = generateReceiptPdf(context, rw, profile)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            try {
                val resolver = context.contentResolver
                val contentValues = ContentValues().apply {
                    put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
                    put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf")
                    put(
                        MediaStore.MediaColumns.RELATIVE_PATH,
                        Environment.DIRECTORY_DOCUMENTS + "/Invoices"
                    )
                }
                val pdfUri =
                    resolver.insert(MediaStore.Files.getContentUri("external"), contentValues)
                if (pdfUri != null) {
                    resolver.openOutputStream(pdfUri)?.use { out ->
                        pdfFile.inputStream().use { input ->
                            input.copyTo(out)
                        }
                        savedSuccessfully = true
                    }
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (!savedSuccessfully) {
            try {
                val docsDir =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
                val receiptDir = File(docsDir, "Invoices").apply { if (!exists()) mkdirs() }
                val targetFile = File(receiptDir, filename)
                pdfFile.copyTo(targetFile, overwrite = true)
                savedSuccessfully = true
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (!savedSuccessfully) {
            try {
                val appDocsDir =
                    context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: context.filesDir
                val receiptDir = File(appDocsDir, "Invoices").apply { if (!exists()) mkdirs() }
                val targetFile = File(receiptDir, filename)
                pdfFile.copyTo(targetFile, overwrite = true)
                savedSuccessfully = true
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        if (savedSuccessfully) {
            Toast.makeText(context, "Invoice PDF saved to Documents/Invoices!", Toast.LENGTH_LONG)
                .show()
        } else {
            Toast.makeText(context, "Failed to save invoice PDF", Toast.LENGTH_SHORT).show()
        }
    } catch (e: Exception) {
        e.printStackTrace()
        Toast.makeText(context, "Error saving PDF: ${e.localizedMessage}", Toast.LENGTH_SHORT)
            .show()
    }
}

fun shareReceiptPdf(context: Context, rw: ReceiptWithItems, profile: BusinessProfile) {
    try {
        val pdfFile = generateReceiptPdf(context, rw, profile)
        val contentUri = FileProvider.getUriForFile(
            context, "${context.packageName}.fileprovider", pdfFile
        )

        val shareIntent = Intent(Intent.ACTION_SEND).apply {
            type = "application/pdf"
            putExtra(Intent.EXTRA_STREAM, contentUri)
            putExtra(Intent.EXTRA_SUBJECT, "Invoice #${rw.receipt.receiptNumber}")
            putExtra(
                Intent.EXTRA_TEXT,
                "Here is your invoice #${rw.receipt.receiptNumber} from ${profile.name}."
            )
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }

        context.startActivity(Intent.createChooser(shareIntent, "Share Invoice PDF Via"))
    } catch (e: Exception) {
        e.printStackTrace()
        Toast.makeText(
            context, "Error sharing invoice PDF: ${e.localizedMessage}", Toast.LENGTH_SHORT
        ).show()
    }
}

Room Database Layer

Now let’s look at the data layer — the Room entities, DAO, and repository that persist all local data.

Receipt.kt (Invoice Entity)


package com.alsaeeddev.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "receipts")
data class Receipt(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val receiptNumber: String,
    val dateTimestamp: Long,
    val customerName: String = "",
    val customerPhone: String = "",
    val customerEmail: String = "",
    val discountAmount: Double = 0.0,
    val discountIsPercentage: Boolean = false,
    val taxRate: Double = 8.0,
    val taxLabel: String = "Tax",
    val notes: String = "",
    val paymentMethod: String = "Cash",
    val isPaid: Boolean = true,
    val subtotal: Double = 0.0,
    val taxTotal: Double = 0.0,
    val discountTotal: Double = 0.0,
    val grandTotal: Double = 0.0
)

ReceiptItem.kt (Line Item Entity)


package com.alsaeeddev.data

import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "receipt_items",
    foreignKeys = [
        ForeignKey(
            entity = Receipt::class,
            parentColumns = ["id"],
            childColumns = ["receiptId"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index(value = ["receiptId"])]
)
data class ReceiptItem(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val receiptId: Long = 0,
    val name: String,
    val unitPrice: Double,
    val quantity: Int,
    val isTaxable: Boolean = true
)

ReceiptWithItems.kt (Relation Model)


package com.alsaeeddev.data

import androidx.room.Embedded
import androidx.room.Relation

data class ReceiptWithItems(
    @Embedded val receipt: Receipt,
    @Relation(
        parentColumn = "id",
        entityColumn = "receiptId"
    )
    val items: List
)

ReceiptDao.kt (Data Access Object)


package com.alsaeeddev.data

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface ReceiptDao {
    @Transaction
    @Query("SELECT * FROM receipts ORDER BY dateTimestamp DESC")
    fun getAllReceiptsWithItems(): Flow<List>

    @Transaction
    @Query("SELECT * FROM receipts WHERE id = :id")
    fun getReceiptWithItemsById(id: Long): Flow<ReceiptWithItems?>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertReceipt(receipt: Receipt): Long

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertReceiptItems(items: List)

    @Query("DELETE FROM receipt_items WHERE receiptId = :receiptId")
    suspend fun deleteReceiptItems(receiptId: Long)

    @Delete
    suspend fun deleteReceipt(receipt: Receipt)

    // Business Profile Queries
    @Query("SELECT * FROM business_profile WHERE id = 1 LIMIT 1")
    fun getBusinessProfile(): Flow<BusinessProfile?>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertBusinessProfile(profile: BusinessProfile)
}

ReceiptRepository.kt


package com.alsaeeddev.data

import kotlinx.coroutines.flow.Flow

class ReceiptRepository(private val receiptDao: ReceiptDao) {
    val allReceiptsWithItems: Flow<List> = receiptDao.getAllReceiptsWithItems()
    val businessProfile: Flow<BusinessProfile?> = receiptDao.getBusinessProfile()

    fun getReceiptWithItemsById(id: Long): Flow<ReceiptWithItems?> {
        return receiptDao.getReceiptWithItemsById(id)
    }

    suspend fun saveReceiptWithItems(receipt: Receipt, items: List): Long {
        val receiptId = receiptDao.insertReceipt(receipt)
        // Delete existing items if we are editing an existing receipt
        if (receipt.id != 0L) {
            receiptDao.deleteReceiptItems(receipt.id)
        }
        val itemsWithId = items.map { it.copy(receiptId = receiptId) }
        receiptDao.insertReceiptItems(itemsWithId)
        return receiptId
    }

    suspend fun deleteReceipt(receipt: Receipt) {
        receiptDao.deleteReceipt(receipt)
    }

    suspend fun saveBusinessProfile(profile: BusinessProfile) {
        receiptDao.insertBusinessProfile(profile)
    }
}

AppDatabase.kt (Room Database Singleton)


package com.alsaeeddev.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [Receipt::class, ReceiptItem::class, BusinessProfile::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun receiptDao(): ReceiptDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "receipt_database"
                )
                .fallbackToDestructiveMigration()
                .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

BusinessProfile.kt (Business Profile Entity)


package com.alsaeeddev.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "business_profile")
data class BusinessProfile(
    @PrimaryKey val id: Int = 1,
    val name: String = "Alex Vance Studio",
    val address: String = "450 Freelance Way, San Francisco, CA",
    val phone: String = "+1 (555) 382-9102",
    val email: String = "alex@vancestudio.dev",
    val website: String = "www.vancestudio.dev",
    val defaultTaxRate: Double = 0.0,
    val taxLabel: String = "Tax / VAT",
    val currencySymbol: String = "$",
    val logoType: String = "monogram", // "monogram", "preset", "custom"
    val logoPresetName: String = "computer", // "computer", "palette", "shopping_bag", "restaurant", "local_shipping"
    val logoCustomUri: String? = null,
    val logoColorArgb: Int = 0xFF4F46E5.toInt()
)

ReceiptViewModel.kt

This ViewModel manages the entire state of the app — form inputs, live totals calculation (tax + discount engine), and communication with the database.


package com.alsaeeddev.ui

import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.alsaeeddev.data.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID

data class DraftItem(
    val tempId: String = UUID.randomUUID().toString(),
    val id: Long = 0,
    val name: String = "",
    val unitPriceString: String = "",
    val quantity: Int = 1,
    val isTaxable: Boolean = true
)

class ReceiptViewModel(application: Application) : AndroidViewModel(application) {
    private val repository: ReceiptRepository

    // Current navigation state
    var currentScreen by mutableStateOf(Screen.Dashboard)
        private set

    // Database Flows
    val allReceipts: StateFlow<List>
    val businessProfile: StateFlow

    // View Receipt State
    var selectedReceipt by mutableStateOf<ReceiptWithItems?>(null)
        private set

    // Create/Edit Receipt State
    var receiptIdToEdit by mutableStateOf<Long?>(null)
    var receiptNumberInput by mutableStateOf("")
    var dateTimestampInput by mutableStateOf(System.currentTimeMillis())
    var customerNameInput by mutableStateOf("")
    var customerPhoneInput by mutableStateOf("")
    var customerEmailInput by mutableStateOf("")
    var discountInput by mutableStateOf("")
    var discountIsPercentageInput by mutableStateOf(false)
    var taxRateInput by mutableStateOf("")
    var taxLabelInput by mutableStateOf("")
    var notesInput by mutableStateOf("")
    var paymentMethodInput by mutableStateOf("Cash")
    var isPaidInput by mutableStateOf(true)
    val draftItems = mutableStateListOf()

    // Calculated fields based on current inputs
    var subtotal by mutableStateOf(0.0)
        private set
    var taxTotal by mutableStateOf(0.0)
        private set
    var discountTotal by mutableStateOf(0.0)
        private set
    var grandTotal by mutableStateOf(0.0)
        private set

    init {
        val database = AppDatabase.getDatabase(application)
        repository = ReceiptRepository(database.receiptDao())

        allReceipts = repository.allReceiptsWithItems
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = emptyList()
            )

        businessProfile = repository.businessProfile
            .map { it ?: BusinessProfile() }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = BusinessProfile()
            )
    }

    fun navigateTo(screen: Screen) {
        currentScreen = screen
    }

    // Receipt Form Actions
    fun startNewReceipt(nextNumber: String) {
        receiptIdToEdit = null
        receiptNumberInput = nextNumber
        dateTimestampInput = System.currentTimeMillis()
        customerNameInput = ""
        customerPhoneInput = ""
        customerEmailInput = ""
        discountInput = ""
        discountIsPercentageInput = false
        
        val profile = businessProfile.value
        taxRateInput = profile.defaultTaxRate.toString()
        taxLabelInput = profile.taxLabel
        notesInput = "Payment Terms: Net 15 Days. Direct Deposit / Wire / PayPal: payment@vancestudio.dev | Thanks for your business!"
        paymentMethodInput = "Bank Transfer"
        isPaidInput = false
        
        draftItems.clear()
        // Add one empty row by default for premium feel
        addDraftItem()
        recalculateTotals()
        navigateTo(Screen.CreateReceipt)
    }

    fun startEditReceipt(receiptWithItems: ReceiptWithItems) {
        val r = receiptWithItems.receipt
        receiptIdToEdit = r.id
        receiptNumberInput = r.receiptNumber
        dateTimestampInput = r.dateTimestamp
        customerNameInput = r.customerName
        customerPhoneInput = r.customerPhone
        customerEmailInput = r.customerEmail
        discountInput = if (r.discountAmount > 0) r.discountAmount.toString() else ""
        discountIsPercentageInput = r.discountIsPercentage
        taxRateInput = r.taxRate.toString()
        taxLabelInput = r.taxLabel
        notesInput = r.notes
        paymentMethodInput = r.paymentMethod
        isPaidInput = r.isPaid
        
        draftItems.clear()
        receiptWithItems.items.forEach { item ->
            draftItems.add(
                DraftItem(
                    id = item.id,
                    name = item.name,
                    unitPriceString = item.unitPrice.toString(),
                    quantity = item.quantity,
                    isTaxable = item.isTaxable
                )
            )
        }
        recalculateTotals()
        navigateTo(Screen.CreateReceipt)
    }

    fun addDraftItem() {
        draftItems.add(DraftItem())
        recalculateTotals()
    }

    fun removeDraftItem(tempId: String) {
        draftItems.removeAll { it.tempId == tempId }
        recalculateTotals()
    }

    fun updateDraftItem(tempId: String, updater: (DraftItem) -> DraftItem) {
        val index = draftItems.indexOfFirst { it.tempId == tempId }
        if (index != -1) {
            draftItems[index] = updater(draftItems[index])
            recalculateTotals()
        }
    }

    fun recalculateTotals() {
        // Calculate subtotal
        var currentSubtotal = 0.0
        var taxableSubtotal = 0.0

        draftItems.forEach { item ->
            val price = item.unitPriceString.toDoubleOrNull() ?: 0.0
            val itemTotal = price * item.quantity
            currentSubtotal += itemTotal
            if (item.isTaxable) {
                taxableSubtotal += itemTotal
            }
        }

        // Calculate discount
        val discVal = discountInput.toDoubleOrNull() ?: 0.0
        val currentDiscountTotal = if (discountIsPercentageInput) {
            currentSubtotal * (discVal / 100.0)
        } else {
            discVal
        }

        // Apply discount proportionally to taxable items if needed, or simply on the total.
        // Standard tax is calculated before discount or on discounted taxable subtotal.
        // We will do standard: Tax calculated on taxable subtotal after applying percentage discount.
        val discountFactor = if (currentSubtotal > 0) (currentSubtotal - currentDiscountTotal) / currentSubtotal else 1.0
        val discountedTaxableSubtotal = (taxableSubtotal * discountFactor).coerceAtLeast(0.0)

        // Calculate tax
        val taxRate = taxRateInput.toDoubleOrNull() ?: 0.0
        val currentTaxTotal = discountedTaxableSubtotal * (taxRate / 100.0)

        subtotal = currentSubtotal
        discountTotal = currentDiscountTotal.coerceAtLeast(0.0)
        taxTotal = currentTaxTotal.coerceAtLeast(0.0)
        grandTotal = (currentSubtotal - currentDiscountTotal + currentTaxTotal).coerceAtLeast(0.0)
    }

    fun saveReceipt(onSuccess: () -> Unit) {
        viewModelScope.launch {
            val receipt = Receipt(
                id = receiptIdToEdit ?: 0L,
                receiptNumber = receiptNumberInput.ifBlank { "REC-${System.currentTimeMillis()}" },
                dateTimestamp = dateTimestampInput,
                customerName = customerNameInput.trim(),
                customerPhone = customerPhoneInput.trim(),
                customerEmail = customerEmailInput.trim(),
                discountAmount = discountInput.toDoubleOrNull() ?: 0.0,
                discountIsPercentage = discountIsPercentageInput,
                taxRate = taxRateInput.toDoubleOrNull() ?: 0.0,
                taxLabel = taxLabelInput.ifBlank { "Tax" },
                notes = notesInput.trim(),
                paymentMethod = paymentMethodInput,
                isPaid = isPaidInput,
                subtotal = subtotal,
                taxTotal = taxTotal,
                discountTotal = discountTotal,
                grandTotal = grandTotal
            )

            val items = draftItems.filter { it.name.isNotBlank() }.map { item ->
                ReceiptItem(
                    id = item.id,
                    receiptId = receipt.id,
                    name = item.name.trim(),
                    unitPrice = item.unitPriceString.toDoubleOrNull() ?: 0.0,
                    quantity = item.quantity,
                    isTaxable = item.isTaxable
                )
            }

            val savedId = repository.saveReceiptWithItems(receipt, items)
            
            // Fetch updated from DB to view
            repository.getReceiptWithItemsById(savedId).firstOrNull()?.let {
                selectedReceipt = it
            }
            onSuccess()
        }
    }

    fun deleteReceipt(receiptWithItems: ReceiptWithItems, onSuccess: () -> Unit) {
        viewModelScope.launch {
            repository.deleteReceipt(receiptWithItems.receipt)
            onSuccess()
        }
    }

    fun viewReceiptDetails(receiptWithItems: ReceiptWithItems) {
        selectedReceipt = receiptWithItems
        navigateTo(Screen.ViewReceipt)
    }

    // Business Profile Actions
    fun saveProfile(
        name: String,
        address: String,
        phone: String,
        email: String,
        website: String,
        defaultTaxRate: Double,
        taxLabel: String,
        currencySymbol: String,
        logoType: String,
        logoPresetName: String,
        logoCustomUri: String?,
        logoColorArgb: Int,
        onSuccess: () -> Unit
    ) {
        viewModelScope.launch {
            val updatedProfile = BusinessProfile(
                id = 1,
                name = name.trim(),
                address = address.trim(),
                phone = phone.trim(),
                email = email.trim(),
                website = website.trim(),
                defaultTaxRate = defaultTaxRate,
                taxLabel = taxLabel.trim(),
                currencySymbol = currencySymbol.trim(),
                logoType = logoType,
                logoPresetName = logoPresetName,
                logoCustomUri = logoCustomUri,
                logoColorArgb = logoColorArgb
            )
            repository.saveBusinessProfile(updatedProfile)
            onSuccess()
        }
    }

    // Logo custom URI handler that persists the logo image locally to internal storage
    fun copyAndSaveCustomLogo(uri: Uri, onSaved: (String) -> Unit) {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val context = getApplication().applicationContext
                val inputStream = context.contentResolver.openInputStream(uri)
                if (inputStream != null) {
                    val logoFile = File(context.filesDir, "custom_logo_${System.currentTimeMillis()}.png")
                    // Delete old logo files if existing
                    context.filesDir.listFiles()?.filter { it.name.startsWith("custom_logo_") }?.forEach { it.delete() }
                    FileOutputStream(logoFile).use { output ->
                        inputStream.copyTo(output)
                    }
                    inputStream.close()
                    val savedFileUri = Uri.fromFile(logoFile).toString()
                    withContext(Dispatchers.Main) {
                        onSaved(savedFileUri)
                    }
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}

sealed interface Screen {
    object Dashboard : Screen
    object CreateReceipt : Screen
    object ViewReceipt : Screen
    object BusinessProfileEdit : Screen
}

App Theme (Color, Typography & Material Theme)

A financial/teal color palette, typography, and light/dark + dynamic color theming setup.

Color.kt


package com.alsaeeddev.ui.theme

import androidx.compose.ui.graphics.Color

// Light Theme Financial Colors
val TealPrimary = Color(0xFF00796B)
val TealPrimaryContainer = Color(0xFFE0F2F1)
val TealOnPrimaryContainer = Color(0xFF004D40)
val TealSecondary = Color(0xFF005B4F)
val TealBackground = Color(0xFFF7FBFB)
val TealSurface = Color(0xFFFFFFFF)
val TealSurfaceVariant = Color(0xFFE0ECEB)

// Dark Theme Financial Colors
val TealDarkPrimary = Color(0xFF4DB6AC)
val TealDarkPrimaryContainer = Color(0xFF004D40)
val TealDarkBackground = Color(0xFF121919)
val TealDarkSurface = Color(0xFF1E2727)
val TealDarkSurfaceVariant = Color(0xFF2C3837)
val TealDarkOnPrimary = Color(0xFF00332D)

Type.kt


package com.alsaeeddev.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Set of Material typography styles to start with
val Typography =
  Typography(
    bodyLarge =
      TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp,
      )
    /* Other default text styles to override
    titleLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    labelSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
    */
  )

Theme.kt


package com.alsaeeddev.ui.theme

import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

private val DarkColorScheme =
  darkColorScheme(
    primary = TealDarkPrimary,
    onPrimary = TealDarkOnPrimary,
    primaryContainer = TealDarkPrimaryContainer,
    background = TealDarkBackground,
    surface = TealDarkSurface,
    surfaceVariant = TealDarkSurfaceVariant
  )

private val LightColorScheme =
  lightColorScheme(
    primary = TealPrimary,
    primaryContainer = TealPrimaryContainer,
    onPrimaryContainer = TealOnPrimaryContainer,
    secondary = TealSecondary,
    background = TealBackground,
    surface = TealSurface,
    surfaceVariant = TealSurfaceVariant,
    onPrimary = Color.White,
    onSecondary = Color.White,
    onBackground = Color(0xFF191D1D),
    onSurface = Color(0xFF191D1D)
  )

@Composable
fun MyApplicationTheme(
  darkTheme: Boolean = isSystemInDarkTheme(),
  // Dynamic color is available on Android 12+
  dynamicColor: Boolean = true,
  content: @Composable () -> Unit,
) {
  val colorScheme =
    when {
      dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
        val context = LocalContext.current
        if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
      }

      darkTheme -> DarkColorScheme
      else -> LightColorScheme
    }

  MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}

How to Run the Project

  1. Download the source code ZIP using the button above and extract it
  2. Open the project in Android Studio and let Gradle sync
  3. Connect a device or emulator and press the Run ▶ button

Final Thoughts

This invoice app for freelancers is a small but production-quality example of how Jetpack Compose, Room, and MVVM can be combined the right way to build a genuinely useful offline-first app — without any unnecessary complexity. Whether you’re searching for the best invoice app for freelancer use, want the apk to test on your own device, or just want to study clean Jetpack Compose architecture, the full source code above has you covered. If you prefer traditional Android development with Java, check out this barcode scanner invoice generator app in Java blog as well.

Ad loading...

FAQs

Q1: Is this invoice app for freelancers free?
Yes, it’s completely free. The app works fully offline with no subscription or account required, and the entire source code is free to download and customize.

Q2: What makes this one of the best invoice apps for freelancers?
It’s fully offline, has no subscription, calculates tax and discounts accurately, and exports branded invoices as PDF or PNG in one tap everything a freelancer needs without extra bloat.

Q3: Does it work without an internet connection?
Yes, the app is 100% offline. All data is stored locally on your device using a Room Database, so no internet connection is required.

Q4: Can I customize the app’s branding?
Yes. You can customize your business name, address, contact info, currency, tax label, and logo (monogram, preset, or custom photo) from the business profile settings.

Q5: What tech stack is this app built with?
It’s built with Kotlin, Jetpack Compose, Material 3, Room Database, and Kotlin Coroutines, following modern MVVM architecture ideal for developers exploring Android source code.