A multi-platform grading scale calculator built with Kotlin Multiplatform and Compose Multiplatform. GradingScale2 handles non-linear grading systems with precision, allowing educators and students to define custom grading scales and perform exact calculations across all major platforms.
- Non-Linear Grade Calculations: Support for complex, non-linear grading scales that accurately reflect various educational systems
- Custom Grade Scale Management: Create, edit, and manage multiple grading scales with different point systems and grade boundaries
- Weighted Grade Calculator: Calculate weighted averages for courses with different component weights (assignments, exams, projects)
- Import Grade Scales: Import pre-defined grading scales from various educational systems
- Real-time Calculations: Instant grade calculations as you input scores
- Multi-Scale Support: Switch between different grading scales seamlessly
- Adaptive UI: Responsive design that adapts to phones, tablets, and desktop screens
- Offline-First: All data stored locally for fast, reliable access
- Material 3 Design: Modern UI following the latest Material Design guidelines
The project follows Layered Architecture principles with clear separation of concerns across three main layers such that the Domain Layer
keeps the business logic and interfaces, which are then implemented by the data layer whenever data framework logic is needed (dependency
inversion principle). The Presentation layer could also be considered a presentation framework layer which however focuses only on the UI
and navigation.
Due the extension of the app, only technological dimension is being used, but no domain feature dimension.
┌────────────────────────-────────────────────────────────┐
│ Domain Layer │
│ (entities module) │
│ • Use Cases (Business Logic) │
│ • Repository Interfaces │
│ • Domain Models │
└────────────────────────┬────────────────────────────────┘
|---------------------------------------------------------------┐
| |
| |
| |
┌─────────────────────────────────────────────────────────┐ ┌────────────────────────┴────────────────────────────────┐
│ Data Layer │ │ Presentation Layer │
│ (data/* submodules) │ │ (composeApp module) │
│ • Repository Implementations │ │ • Compose UI Screens │
│ • Local Database (SQLDelight) │ │ • ViewModels with Molecule State Management │
│ • Remote API (Ktor) │ │ • Platform-specific UI implementations │
│ • Preferences (Multiplatform Settings) │ | |
└─────────────────────────────────────────────────────────┘ └────────────────────────-────────────────────────────────┘
The project uses CashApp's Molecule for state management, revolutionizing how UI state is handled by treating it as a Composable function:
interface UIModel<UIState, UICommand> {
val scope: UIModelScope
val uiState: StateFlow<UIState>
@Composable
fun produceUI(): UIState
fun sendCommand(command: UICommand)
}Benefits of Molecule:
- Reactive by Design: UI state recomposes automatically when dependencies change
- Testable: State logic can be tested without Android framework dependencies
- Composable Logic: Leverage Compose's powerful state management primitives
- Clear Data Flow: Unidirectional data flow with events and state
It also separates the UIModel, from the Android Framework Specific UIModel, allowing easier testing and use in different platforms even without the Navigation/Jetpack ComposeUI.
In case you require to link your UIModels to the Android or Jetpack ComposeUI Navigation/Lifecycle, you can then use a ViewModel and tie its scope into the ViewModel. The functionallity can be easily added via interface implementation with the by class delegation.
class CalculatorViewModel(
calculatorUIModel: CalculatorUIModel,
) : ViewModel(calculatorUIModel.scope),
UIModel<GradeScaleCalculatorUIState, CalculatorUIEvent> by calculatorUIModel
This reduces boilerplate and allows to keep this logic out of the platform :composeApp module.
The adaptive layout system uses a unique approach with single per-destination Scaffolds that maintain navigation state across all screens:
@Composable
fun AnimatedContentScope.PersistentScaffold(
navigationRail: @Composable ScaffoldState.() -> Unit = { DefaultNavigationRail() },
bottomBar: @Composable ScaffoldState.() -> Unit = { DefaultNavigationBar() },
content: @Composable ScaffoldState.(PaddingValues) -> Unit,
) {
val windowSizeClass = calculateWindowSizeClass()
// Automatically adapts between NavigationRail (tablet/desktop)
// and BottomNavigation (mobile)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Mobile: Bottom navigation
BottomNavigationScaffold(bottomBar, content)
}
else -> {
// Tablet/Desktop: Navigation rail
NavigationRailScaffold(navigationRail, content)
}
}
}Key Features:
- Shared Element Transitions: Smooth animations between screens using
SharedTransitionScope - State Persistence: Navigation components maintain their state across screen changes
- Adaptive Components: Automatically switches UI components based on screen size
- Centralized State:
ScaffoldStateacts as a container for bothAnimatedVisibilityScopeandSharedTransitionScope
The project embraces functional programming principles by using immutable data structures throughout:
@Serializable
data class GradeScale(
val id: String,
val gradeScaleName: String,
val totalPoints: Double,
@Serializable(with = PersistentListSerializer::class)
val grades: PersistentList<Grade>,
) {
@Transient
val sortedGrades = grades.sortedByDescending { it.percentage }.toImmutableList()
}Why PersistentList?
- Thread Safety: Immutable collections are inherently thread-safe
- Performance: Structural sharing reduces memory overhead when creating modified copies
- Predictability: Data cannot be accidentally mutated, reducing bugs
- Compose Integration: Works seamlessly with Compose's recomposition system
- Custom Serialization: Handles WasmJS compatibility issues with a custom serializer
The architecture is fully reactive using Kotlin Coroutines Flow:
interface GradeScaleRepository {
fun getGradeScaleById(id: String): SharedFlow<GradeScale?>
fun getGradeScales(): SharedFlow<ImmutableList<GradeScale>>
}
class GradeScaleRepositoryImpl : GradeScaleRepository {
override fun getGradeScales(): SharedFlow<ImmutableList<GradeScale>> =
gradeScaleDao.getGradeScales()
.map { list -> list.toImmutableList() }
.shareIn(
scope = scope,
started = SharingStarted.Lazily,
replay = 1,
)
}Reactive Benefits:
- Real-time Updates: UI automatically updates when data changes
- Efficient Resource Usage:
SharingStarted.Lazilyonly activates flows when collected - Backpressure Handling: Flow operators handle data stream pressure automatically
- Cancellation Support: Proper lifecycle management with structured concurrency
The project uses Arrow for functional error handling, avoiding exceptions in favor of explicit error types:
// Using Option for nullable results
class InsertGradeScaleUseCaseImpl : InsertGradeScaleUseCase {
override suspend operator fun invoke(...): Option<String> = option {
val currentScales = gradeScaleRepository.getGradeScales().firstOrNull()
// Arrow's bind() for monadic composition
gradeScaleRepository.upsertGradeScale(initialGradeScale).bind()
}
}
// Using Either for operations that can fail
interface RemoteSyncRepository {
suspend fun countriesAndGrades(): Either<RemoteError, List<CountryGradingScales>>
}
// Clean error handling in UI
when (val result = getRemoteGradeScales()) {
is Either.Right -> updateUI(result.value)
is Either.Left -> showError(result.value)
}Why Arrow?
- Type-Safe Errors: Compile-time guarantee of error handling
- Composable: Chain operations without nested try-catch blocks
- Explicit: Makes error cases visible in function signatures
- Functional: Leverages monadic composition for clean code
The build system uses a convention plugin approach:
// buildSrc/src/main/kotlin/gs-android-app.gradle.kts
plugins {
id("com.android.application")
id("kotlin-android")
// Common configurations
}
android {
// Standardized Android app configuration
}
// Applied to apps as:
plugins {
id("gs-android-app")
}This approach provides:
- Consistency: All modules follow the same configuration patterns
- Maintainability: Update configurations in one place
- Type Safety: Kotlin DSL provides IDE support and compile-time checking
- Modularity: Different plugins for different module types
The project uses Koin with a modular, platform-aware structure:
// Feature module
val calculatorModule = module {
factory { CalculatorViewModel(get(), get()) }
factory { CalculatorUIModel(get(), get()) }
}
// Platform-specific module
expect val platformModule: Module
// Initialization
fun initKoin() = startKoin {
modules(
calculatorModule,
gradeScaleModule,
platformModule, // Platform-specific implementations
dataModule,
)
}GradingScale2/
├── composeApp/ # UI Layer - Compose Multiplatform app
│ ├── commonMain/ # Shared UI code
│ ├── androidMain/ # Android-specific UI
│ ├── iosMain/ # iOS-specific UI
│ ├── jsMain/ # Web-specific UI
│ └── jvmMain/ # Desktop-specific UI
│
├── entities/ # Domain Layer
│ ├── models/ # Domain models
│ ├── repositories/ # Repository interfaces
│ ├── usecases/ # Business logic
│ └── uimodel/ # UI state models
│
└── data/ # Data Layer
├── network/ # Ktor HTTP client
├── authFirebase/ # Firebase authentication
└── persistance/
├── db/ # SQLDelight database
└── sharedprefs/ # Multiplatform Settings
- Kotlin Multiplatform (2.1.21) - Share code across platforms
- Compose Multiplatform (1.8.1) - Modern declarative UI framework
- Material 3 Adaptive - Adaptive design system
- CashApp Molecule - Compose-based state management
- Navigation Compose - Type-safe navigation
- Ktor - Multiplatform HTTP client
- SQLDelight - Type-safe SQL database
- Kotlinx Serialization - JSON parsing
- Multiplatform Settings - Key-value storage
- Koin - Pragmatic lightweight DI framework
- Firebase - Authentication & Analytics
- Android/iOS: GitLive Firebase SDK
- Web: Firebase JS SDK via CDN
- Desktop: GitLive Firebase SDK
- Conveyor - Desktop app distribution
- Ktlint - Kotlin code style enforcement
- Arrow - Functional programming and error handling
- Kotlin Coroutines - Asynchronous programming
- PersistentList - Immutable collections from Kotlinx Collections
| Platform | Status | Min Version | Notes |
|---|---|---|---|
| Android | ✅ | API 24 (7.0) | Full feature support |
| iOS | ✅ | iOS 14.0 | Native SwiftUI integration |
| Desktop | ✅ | JVM 17 | Windows, macOS, Linux |
| Web | ✅ | Modern browsers | JS/WASM targets |
- JDK 17 or higher
- Android Studio (for Android development)
- Xcode 15+ (for iOS development)
- Node.js (for web development)
git clone https://github.com/yourusername/GradingScale2.git
cd GradingScale2# Build debug APK
./gradlew :composeApp:assembleDebug
# Install on connected device
./gradlew :composeApp:installDebug# Build iOS framework
./build-ios.sh
# Open in Xcode
open iosApp/iosApp.xcodeproj
# Run from Xcode or use:
./gradlew :composeApp:iosSimulatorArm64Test# Run desktop application
./gradlew :composeApp:run
# Create distribution
./gradlew :composeApp:packageDistributionForCurrentOS# Run development server
./gradlew :composeApp:wasmJsBrowserRun
# Build production bundle
./gradlew :composeApp:wasmJsBrowserProductionWebpack̨̨̨̨The WebWasm version can be deployed using a docker container. Inside docker are included the templates to build an nginx docker and configure it to run the deployed wasmjs app.
To ease the deployment, the build-wasmjs.main.kts script automates the whole process to build the app, copy the content into the docker directory and then via ssh transfer the docker to a remote server. Once in the server, it restart the docker with the newly deployed app.̨̨̨̨̨
# Run all tests
./gradlew test
# Run specific module tests
./gradlew :entities:test
./gradlew :data:network:test
# Check code style
./gradlew ktlintCheck
# Auto-format code
./gradlew ktlintFormat# Use the helper script
./clean-and-rebuild.sh
# Or manually
./gradlew clean
./gradlew buildThe project includes comprehensive unit tests focusing on:
- Use Cases: Business logic validation
- ViewModels: UI state management with Molecule
- Repositories: Data layer operations
- Platform-specific: Platform-specific functionality
Example test structure:
class GradeScaleListViewModelTest {
@Test
fun `should update UI state when grade scale is selected`() = runTest {
// Given
val viewModel = GradeScaleListViewModel(...)
// When
viewModel.sendEvent(SelectGradeScale(gradeScaleId))
// Then
assertEquals(gradeScaleId, viewModel.uiState.value.selectedId)
}
}The desktop application is available for download on the Download Section
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.