Runique is a modern, offline-first, multi-module Android application designed for tracking running activities. This project showcases modern Android development practices, emphasizing a clean, scalable, and maintainable architecture.
- High-Level Architecture
- Technology Stack & Key Libraries
- Module Breakdown
- Technical Deep Dive
- Getting Started
This project follows the principles of Clean Architecture and is structured into multiple modules. The architecture enforces a strict separation of concerns, enhances scalability, and allows for independent development and testing of features.
The architecture is layered into three main parts:
- Data Layer: Responsible for sourcing data. This includes network operations (remote data source) and database access (local data source). It contains repositories that abstract the data sources from the rest of the app.
- Domain Layer: Contains the core business logic, use cases, and domain models. This layer is pure Kotlin, with no dependencies on the Android framework, making it highly testable and reusable.
- Presentation (UI) Layer: Responsible for displaying the data to the user and handling user interactions. It is built entirely with Jetpack Compose and follows the MVVM (Model-View-ViewModel) pattern.

- Kotlin: The official programming language for Android development.
- Jetpack Compose: A modern declarative UI toolkit for building native Android UI.
- Koin: A pragmatic and lightweight dependency injection framework for Kotlin.
- Coroutines & Flow: For asynchronous programming and reactive data streams.
- Ktor: A modern asynchronous HTTP client for making network requests.
- Room: A persistence library that provides an abstraction layer over SQLite.
- WorkManager: For deferrable, asynchronous background tasks, crucial for the offline-first data synchronization strategy.
- Material 3: The latest version of Google's design system.
- Google Maps Compose SDK: For integrating Google Maps directly within Jetpack Compose.
The project is organized into several modules, categorized as app, core, or feature.
- :app: This is the main application module that assembles the entire application. It has dependencies on all other feature and core modules and is responsible for:
- Initializing the Koin dependency graph.
- Setting up the main
Activity. - Hosting the Jetpack Compose navigation graph (
NavigationRoot.kt).
These modules provide shared functionality across all feature modules.
- :core:domain: Contains the essential business logic and models (e.g.,
Run,User,DataError). It defines the repository interfaces (RunRepository,AuthRepository) that the data layer implements. It is a pure Kotlin module. - :core:data: Implements the repository interfaces from the domain layer. It contains the
OfflineFirstRunRepository, which orchestrates data fetching between the local database and the remote network source, providing a single source of truth for the application's data. - :core:database: Manages the local database using Room. It defines the
RunDatabase, Data Access Objects (DAOs), and entity classes. It also provides the Koin module (databaseModule) to inject the database and DAOs. - :core:presentation:designsystem: The application's design system. It contains the Compose theme (
RuniqueTheme), colors, typography, and a library of reusable, stateless UI components (buttons, text fields, etc.) to ensure a consistent look and feel. - :core:presentation:ui: Holds shared UI logic and utility functions, such as
UiTextfor handling string resources,RunDataFormattersfor formatting data for display, andObserveAsEventsfor handling one-off UI events.
Features are self-contained and vertically sliced, often with their own data, domain, and presentation modules.
- Handles user registration and login.
:auth:domain: Defines theAuthRepositoryinterface and validation logic likeUserDataValidator.:auth:data: ImplementsAuthRepositoryby making network calls using Ktor. Includes data transfer objects (DTOs) for requests and responses.:auth:presentation: Contains the Compose screens for Intro, Login, and Register, along with theirViewModels.
- Manages everything related to tracking runs.
:run:domain: Defines run-specific interfaces likeLocationObserver.:run:location: ImplementsLocationObserverusing the Android Fused Location Provider to track the user's location during a run.:run:data: ContainsWorkManagerlogic (SyncRunWorkerScheduler,CreateRunWorker) to sync runs with the backend, ensuring data is not lost even if the user is offline.:run:network: Provides theKtorRemoteRunDataSourcefor run-specific network operations.:run:presentation: The UI for the run feature, including theRunOverviewScreenand theActiveRunScreen, which features a real-time map display.
- A dynamic feature module that can be downloaded on demand.
:analytics:domain: Defines theAnalyticsRepositoryfor fetching aggregated run data.:analytics:data: Implements the repository, reading data directly from the local Room database. --:analytics:presentation: The UI for theAnalyticsDashboardScreen, displaying statistics and charts.
Runique is built with an offline-first approach to provide a reliable and seamless user experience, regardless of network connectivity. This is achieved through a combination of local database caching, a smart data repository, and background synchronization using WorkManager.
-
Local Cache as Single Source of Truth: The UI layers read data exclusively from the local Room database. This ensures that the app is always responsive and can display data even when the device is offline. All data displayed is queried directly from the local database using
Flow, so the UI updates reactively to any changes in the cache. -
Repository-level Orchestration: The
OfflineFirstRunRepositoryis the cornerstone of this strategy. When a request for data is made (e.g., fetching all runs), it first serves the cached data from the Room database. It then triggers a background task to fetch the latest data from the network. -
Queueing Outgoing Operations: When a user performs an action that modifies data (e.g., creating or deleting a run), the operation is immediately applied to the local database for a fast UI update. Simultaneously, the operation is queued in a separate table (
RunPendingSyncEntityfor creations,DeletedRunSyncEntityfor deletions). This ensures no data is lost and provides instant feedback to the user. -
Robust Synchronization with
WorkManager:WorkManageris used to reliably execute these queued operations in the background.CreateRunWorkerandDeleteRunWorkerare responsible for pushing the queued local changes to the remote server.FetchRunsWorkerperiodically fetches fresh data from the server to keep the local cache up-to-date.WorkManagerhandles constraints (e.g., requiring network connectivity), implements backoff and retry policies, and guarantees that the work will be completed, even if the app is closed or the device is restarted.
This strategy guarantees that the user can always interact with the app, and their data will be automatically and efficiently synchronized with the backend whenever a network connection is available.
Koin is used to manage dependencies throughout the app. Each module is responsible for defining its own Koin module, which declares its provided dependencies. The :app module then loads all necessary modules to construct the complete dependency graph.
Example from :core:database:
val databaseModule = module {
single { // Provides a singleton instance of RunDatabase
Room.databaseBuilder(
androidApplication(),
RunDatabase::class.java,
"run.db"
).build()
}
single { get<RunDatabase>().runDao } // Provides the RunDao
// ... other DAOs
}Ktor is configured in the :core:data module via a HttpClientFactory. This factory sets up a reusable Ktor client with default headers, serialization (using kotlinx.serialization), and logging. Feature-specific data sources then use this client to perform network operations.
The local database is managed by Room. The RunDatabase class defines all entities and the version. DAOs expose suspend functions and Flows to interact with the database asynchronously and reactively. For example, runDao.getRuns() returns a Flow<List<RunEntity>>, so the UI can automatically update whenever the list of runs changes.
Coroutines and Flow are used extensively to manage background threads and handle streams of data. This results in non-blocking, responsive UI.
- Repository Layer: Uses
suspendfunctions for one-shot operations (e.g., creating a run) andFlowfor observing data changes (e.g., watching a list of runs). - ViewModel Layer: Collects Flows from the repositories and exposes the data to the UI as
StateFlow, which is a hot, state-holding Flow. - UI Layer: Collects the
StateFlowfrom the ViewModel usingcollectAsStateWithLifecycleto ensure collection is done in a lifecycle-aware manner.
The entire UI is built with Jetpack Compose, following a Unidirectional Data Flow (UDF) pattern. State flows down from the ViewModel to the Composables, and events flow up from the Composables to the ViewModel. This makes the UI predictable and easy to debug.
- Clone the repository:
git clone https://your-repository-url.git
- Open the project in Android Studio.
- Create a
local.propertiesfile in the root directory and add your Google Maps API key:MAPS_API_KEY=YOUR_API_KEY_HERE - Sync the project with Gradle files.
- Build and run the
appmodule on an Android emulator or a physical device. ""