Mobile App Development
Invoice App Source Code Android Studio Project
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.
Table of Contents
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)
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.
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
- Download the source code ZIP using the button above and extract it
- Open the project in Android Studio and let Gradle sync
- 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.
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.