Skip to main content
This guide covers the development workflow, architecture patterns, and best practices for contributing to Divvy.

Project Structure

Divvy follows a clean architecture pattern organized by feature and layer:
app/src/main/java/com/example/divvy/
├── backend/           # Data layer - repositories and data sources
├── ui/                # Presentation layer - screens and ViewModels
│   ├── home/
│   │   ├── Views/     # Composable UI components
│   │   └── ViewModels/ # State management
│   ├── groups/
│   ├── expenses/
│   ├── navigation/    # App navigation graph
│   └── theme/         # Design system
├── models/            # Domain models and data classes
├── components/        # Reusable UI components
├── di/                # Dependency injection modules
├── MainActivity.kt
├── AuthActivity.kt
└── FeatureFlags.kt

Key Directories

1

backend/ - Data Layer

Contains repository interfaces and Supabase implementations:
  • GroupRepository.kt / SupabaseGroupRepository.kt
  • ExpensesRepository.kt / SupabaseExpensesRepository.kt
  • AuthRepository.kt / SupabaseAuthRepository.kt
  • ProfilesRepository.kt, MemberRepository.kt, BalanceRepository.kt
  • DataResult.kt - Sealed class for async operation states
Example repository pattern:
interface GroupRepository {
    suspend fun listGroups(): Flow<DataResult<List<Group>>>
}

@Singleton
class SupabaseGroupRepository @Inject constructor(
    private val supabaseClient: SupabaseClient
) : GroupRepository {
    // Implementation
}
2

ui/ - Presentation Layer

Organized by feature, each with Views/ and ViewModels/ subdirectories:
  • home/ - Activity feed and quick stats
  • groups/ - Group list and creation
  • groupdetail/ - Group expenses and members
  • expenses/ - Expense list
  • splitexpense/ - Create and split expenses
  • assignitems/ - Itemized receipt splitting
  • ledger/ - Settlement tracking
  • profile/ - User profile settings
  • navigation/ - NavHost and destination definitions
3

models/ - Domain Models

Data classes representing core business entities:
  • Group.kt - Group information and balance
  • Expense.kt - Individual expense records
  • UserProfile.kt - User account data
  • Split.kt - Expense split allocation
  • LedgerEntry.kt - Settlement records
4

components/ - Shared UI

Reusable Compose components:
  • PrimaryButton.kt - Primary CTA button
  • OutlineButton.kt - Secondary button
  • GroupIcon.kt - Group avatar component
  • AuthTextField.kt - Styled input field
5

di/ - Dependency Injection

Hilt modules for dependency management:
  • AppModule.kt - Repository bindings
  • NetworkModule.kt - Supabase client provider

MVVM Architecture

Divvy uses the Model-View-ViewModel (MVVM) pattern with Jetpack Compose:
┌─────────────┐      ┌──────────────┐      ┌────────────┐
│   View      │──────│  ViewModel   │──────│ Repository │
│  (Compose)  │      │   (State)    │      │   (Data)   │
└─────────────┘      └──────────────┘      └────────────┘

Workflow

  1. View - Composable functions observe ViewModel state
  2. ViewModel - Manages UI state and business logic, calls repositories
  3. Repository - Fetches data from Supabase, returns Flow<DataResult<T>>

Example: HomeViewModel Pattern

// 1. Define UI State
data class HomeUiState(
    val groups: List<Group> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

// 2. ViewModel with Hilt injection
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val groupRepository: GroupRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(HomeUiState(isLoading = true))
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            groupRepository.listGroups().collect { result ->
                _uiState.update { current ->
                    when (result) {
                        is DataResult.Loading -> current.copy(isLoading = true)
                        is DataResult.Error -> current.copy(
                            isLoading = false,
                            errorMessage = result.message
                        )
                        is DataResult.Success -> current.copy(
                            groups = result.data,
                            isLoading = false
                        )
                    }
                }
            }
        }
    }
}

// 3. Composable View
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()

    when {
        uiState.isLoading -> LoadingIndicator()
        uiState.errorMessage != null -> ErrorView(uiState.errorMessage!!)
        else -> GroupsList(groups = uiState.groups)
    }
}
See app/src/main/java/com/example/divvy/ui/home/ViewModels/HomeViewModel.kt:1 for the full implementation.

Adding a New Feature

Follow these steps to add a new feature to Divvy:
1

Define the domain model

Create data classes in models/ if needed:
// models/Transaction.kt
package com.example.divvy.models

import kotlinx.serialization.Serializable

@Serializable
data class Transaction(
    val id: String,
    val amount: Long,
    val description: String,
    val userId: String,
    val createdAt: String
)
2

Create repository interface

Define data access in backend/:
// backend/TransactionRepository.kt
interface TransactionRepository {
    suspend fun getTransactions(): Flow<DataResult<List<Transaction>>>
}

// backend/SupabaseTransactionRepository.kt
@Singleton
class SupabaseTransactionRepository @Inject constructor(
    private val supabaseClient: SupabaseClient
) : TransactionRepository {
    override suspend fun getTransactions(): Flow<DataResult<List<Transaction>>> = flow {
        emit(DataResult.Loading)
        try {
            val data = supabaseClient.from("transactions")
                .select()
                .decodeList<Transaction>()
            emit(DataResult.Success(data))
        } catch (e: Exception) {
            emit(DataResult.Error(e.message ?: "Unknown error"))
        }
    }
}
3

Register dependency in AppModule

Bind the implementation in di/AppModule.kt:1:
@Module
@InstallIn(SingletonComponent::class)
abstract class AppModule {
    @Binds @Singleton
    abstract fun bindTransactionRepository(
        impl: SupabaseTransactionRepository
    ): TransactionRepository
}
4

Create ViewModel

Add state management in ui/transactions/ViewModels/:
// ui/transactions/ViewModels/TransactionsViewModel.kt
data class TransactionsUiState(
    val transactions: List<Transaction> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

@HiltViewModel
class TransactionsViewModel @Inject constructor(
    private val transactionRepository: TransactionRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(TransactionsUiState(isLoading = true))
    val uiState: StateFlow<TransactionsUiState> = _uiState.asStateFlow()

    init {
        loadTransactions()
    }

    private fun loadTransactions() {
        viewModelScope.launch {
            transactionRepository.getTransactions().collect { result ->
                // Update state based on result
            }
        }
    }
}
5

Build the UI

Create Compose screens in ui/transactions/Views/:
// ui/transactions/Views/TransactionsScreen.kt
@Composable
fun TransactionsScreen(
    viewModel: TransactionsViewModel = hiltViewModel(),
    onTransactionClick: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    Scaffold(
        topBar = { TopAppBar(title = { Text("Transactions") }) }
    ) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            items(uiState.transactions) { transaction ->
                TransactionItem(
                    transaction = transaction,
                    onClick = { onTransactionClick(transaction.id) }
                )
            }
        }
    }
}
6

Add navigation route

Define the destination in ui/navigation/AppDestination.kt:1:
sealed interface AppDestination {
    @Serializable data object Transactions : AppDestination
}
Add to NavHost in ui/navigation/AppNavHost.kt:1:
composable<AppDestination.Transactions> {
    TransactionsScreen(
        onTransactionClick = { id ->
            navController.navigate(AppDestination.TransactionDetail(id))
        }
    )
}

Coding Standards

Kotlin Style

  • Follow Kotlin coding conventions
  • Use meaningful variable names: groupId not gid
  • Prefer val over var for immutability
  • Use data classes for models
  • Leverage Kotlin coroutines for async operations

Compose Best Practices

  • State hoisting: Pass state and events down to composables
  • Stateless composables: Keep UI functions pure when possible
  • Preview annotations: Add @Preview for UI components
  • Modifier ordering: Size → Padding → Visual effects
  • Remember state: Use remember for UI-only state
Example:
@Composable
fun GroupItem(
    group: Group,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        onClick = onClick,
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        // Content
    }
}

Dependency Injection with Hilt

Divvy uses Hilt for dependency injection:
  • Mark Application class with @HiltAndroidApp (see DivvyApplication.kt:1)
  • Annotate ViewModels with @HiltViewModel
  • Use @Inject constructor() for repository dependencies
  • Register bindings in @Module classes under di/

Working with Supabase

Database Schema

Divvy’s Supabase database includes these main tables:
  • profiles - User account information
  • groups - Group metadata
  • group_members - Group membership junction table
  • expenses - Expense records
  • expense_splits - How expenses are divided
  • ledger_entries - Settlement transactions

Row Level Security (RLS)

Supabase RLS policies ensure users can only access their own data:
  • Users can only read groups they’re members of
  • Only group members can create expenses for that group
  • Profiles are readable by authenticated users
When adding new tables or modifying queries, ensure RLS policies are properly configured in the Supabase dashboard to prevent data leaks.

Making Queries

Use the Supabase Kotlin SDK via repositories:
val groups = supabaseClient
    .from("groups")
    .select()
    .decodeList<Group>()

val expense = supabaseClient
    .from("expenses")
    .insert(newExpense)
    .decodeSingle<Expense>()
See app/src/main/java/com/example/divvy/backend/SupabaseGroupRepository.kt:1 for real examples.

Feature Flags

Divvy uses feature flags to enable/disable features during development. Configuration: app/src/main/java/com/example/divvy/FeatureFlags.kt:1
object FeatureFlags {
    val AUTH_BYPASS: Boolean = BuildConfig.AUTH_BYPASS
}
Flags are set in local.properties and injected at build time:
AUTH_BYPASS=true
Usage:
if (FeatureFlags.AUTH_BYPASS) {
    // Use dummy account for testing
} else {
    // Normal authentication flow
}
Add new feature flags to FeatureFlags.kt and app/build.gradle.kts as needed for experimental features.

Team Workflow

Issue Tracking with Linear

The team uses Linear for project management:
  • Pick an issue: Check the current sprint and assign yourself to a task
  • Update status: Move issues through “Todo”, “In Progress”, “In Review”, “Done”
  • Link PRs: Reference Linear issue IDs in commit messages (e.g., DIV-123: Add transaction list)

UI Design with Figma

  • Check designs first: Before implementing UI, review mockups in Figma
  • Match styles: Use the design system defined in ui/theme/ to match Figma specs
  • Ask questions: If designs are unclear, ping the team on Discord

Communication on Discord

  • Daily updates: Share progress and blockers
  • Code reviews: Request reviews in the dev channel
  • Quick questions: Use Discord for synchronous debugging help

Git Workflow

  1. Create a feature branch: git checkout -b feature/transaction-list
  2. Make commits with clear messages: git commit -m "Add transaction repository"
  3. Push and open a PR: git push origin feature/transaction-list
  4. Request review from team members
  5. Merge after approval
Always pull the latest changes from main before starting new work to avoid merge conflicts.

Next Steps