UI Components & Theme System
This document covers the Visla GPS Android app's custom design system built on Jetpack Compose, including the VislaTheme, color palettes, typography scale, dimension tokens, glassmorphism effects, and the full component library.
Theme Architectureβ
VislaGPSTheme Compositionβ
The app wraps Material3's MaterialTheme with a custom VislaGPSTheme that provides domain-specific tokens via CompositionLocalProvider. This is the top-level theme composable applied at the app root:
// Theme.kt
@Composable
fun VislaGPSTheme(
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColorScheme else LightColorScheme
val materialColorScheme = if (darkTheme) {
darkColorScheme(
primary = colors.primary,
secondary = colors.secondary,
// ... mapped to material tokens
)
} else {
lightColorScheme(/* ... */)
}
CompositionLocalProvider(
LocalColors provides colors,
LocalDarkTheme provides darkTheme,
LocalTypography provides DefaultTypography
) {
MaterialTheme(
colorScheme = materialColorScheme,
content = content
)
}
}
Three composition locals are provided:
| Local | Type | Purpose |
|---|---|---|
LocalColors | ColorScheme | Full semantic color palette |
LocalDarkTheme | Boolean | Current dark/light state |
LocalTypography | Typography | Custom typography scale |
Components access these through the VislaTheme object:
// Usage in any @Composable
val bgColor = VislaTheme.colors.background
val headlineStyle = VislaTheme.typography.headlineLarge
val isDark = VislaTheme.isDarkTheme
Dark Mode Supportβ
Theme mode is controlled by the ThemeMode enum persisted in AppPreferencesDataStore:
enum class ThemeMode {
SYSTEM, // Follow system dark/light setting
DARK, // Force dark theme
LIGHT; // Force light theme
}
The shouldUseDarkTheme() composable resolves the mode:
@Composable
fun shouldUseDarkTheme(themeMode: ThemeMode): Boolean {
return when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
}
}
Users can switch themes via ThemePickerDialog in settings.
Color Systemβ
Brand Colorsβ
object BrandColors {
val primary = Color(0xFF004AAD) // Visla blue
val secondary = Color(0xFF5DE0E6) // Visla cyan
val gradient = Brush.linearGradient(colors = listOf(primary, secondary))
}
ColorScheme Data Classβ
The ColorScheme data class is marked @Immutable and organizes 50+ semantic color tokens into logical groups:
| Group | Tokens | Purpose |
|---|---|---|
| Brand | primary, primaryVariant, onPrimary, secondary, onSecondary, tertiary | Core brand identity |
| Background | background, backgroundSecondary, backgroundTertiary | Screen/section backgrounds |
| Surface | surface, surfaceElevated, surfaceGlass, surfaceSubtle, onSurface | Card/container surfaces |
| Text | textPrimary, textSecondary, textTertiary, textMuted, textLabel | Text hierarchy (100% β 20% opacity) |
| Status | success, warning, error, info + containers | Feedback indicators |
| Interactive | interactive, interactiveDisabled, interactivePressed | Button/link states |
| Form | inputBackground, inputBorder, inputBorderFocused | Input field states |
| Dividers | divider, border, borderSubtle | Separators and borders |
| Social | socialFacebook, socialGoogle, socialApple + icon variants | OAuth provider branding |
| Device Status | deviceOnline, deviceOffline, deviceSuspended | GPS tracker state |
| Map | mapRoute, mapStart, mapEnd | Route visualization |
| Gradients | gradientStart, gradientEnd | Background gradients |
Dark Paletteβ
primary: #3366FF (bright blue)
background: #0D0D1A (near-black navy)
backgroundSecondary:#1A1A2E (dark navy)
surface: #1A1A2E (dark navy)
surfaceElevated: #252542 (raised purple-navy)
surfaceGlass: #FFFFFF14 (8% white)
textPrimary: #FFFFFF (white)
textSecondary: #FFFFFFB3 (70% white)
textTertiary: #FFFFFF80 (50% white)
deviceOnline: #4CAF50 (green)
deviceOffline: #E53935 (red)
deviceSuspended: #FF9800 (orange)
Light Paletteβ
primary: #004AAD (Visla blue)
background: #FFFBFE (warm white)
backgroundSecondary:#F5F5F7 (light gray)
surface: #FFFFFF (white)
surfaceElevated: #FFFFFF (white)
surfaceGlass: #0000000D (5% black)
textPrimary: #1C1B1F (near-black)
textSecondary: #00000099 (60% black)
textTertiary: #00000066 (40% black)
deviceOnline: #2E7D32 (dark green)
deviceOffline: #C62828 (dark red)
deviceSuspended: #E65100 (dark orange)
Gradientsβ
The Gradients object provides theme-aware gradient brushes:
object Gradients {
@Composable fun backgroundVertical(): Brush // gradientStart β gradientEnd
@Composable fun backgroundVertical3(): Brush // background β secondary β tertiary
@Composable fun primaryHorizontal(): Brush // primary β primary(0.7f)
@Composable fun profileAvatar(): Brush // primary β secondary
fun badgeGold(): Brush // #FFD700 β #FFA500
val brand: Brush = BrandColors.gradient // Visla blue β cyan
}
Typography Scaleβ
The custom Typography data class provides 20 text styles organized into seven categories, all using FontFamily.Default:
| Category | Style | Size | Weight | Line Height | Use Case |
|---|---|---|---|---|---|
| Display | displayLarge | 40sp | Bold | 48sp | Hero numbers, splash |
displayMedium | 32sp | Bold | 40sp | Page titles | |
displaySmall | 28sp | Bold | 36sp | Section titles | |
| Headline | headlineLarge | 24sp | Bold | 32sp | Screen headers |
headlineMedium | 20sp | Bold | 28sp | Card titles | |
headlineSmall | 18sp | Bold | 24sp | Sub-section headers | |
| Title | titleLarge | 16sp | SemiBold | 24sp | List item titles |
titleMedium | 14sp | SemiBold | 20sp | Secondary titles | |
titleSmall | 12sp | SemiBold | 16sp | Compact titles | |
| Body | bodyLarge | 16sp | Normal | 24sp | Primary content |
bodyMedium | 14sp | Normal | 20sp | Standard body text | |
bodySmall | 12sp | Normal | 16sp | Secondary content | |
| Label | labelLarge | 14sp | Medium | 20sp | Button text |
labelMedium | 12sp | Medium | 16sp | Input labels | |
labelSmall | 11sp | Medium | 14sp | Badges, tags | |
| Caption | captionLarge | 12sp | Normal | 16sp | Timestamps |
captionMedium | 11sp | Normal | 14sp | Helper text | |
captionSmall | 10sp | Normal | 12sp | Fine print | |
| Code | codeInput | 24sp | Medium | 32sp | OTP digit input (2sp tracking) |
codeDisplay | 16sp | Medium | 24sp | Code/token display (8sp tracking) |
Usage:
Text(
text = "Device Tracker",
style = VislaTheme.typography.headlineLarge,
color = VislaTheme.colors.textPrimary
)
Dimensionsβ
The Dimensions object is the single source of truth for all spatial values, built on a 4dp grid system with semantic naming:
Spacingβ
Dimensions.Spacing.none // 0.dp
Dimensions.Spacing.xxs // 2.dp β Micro
Dimensions.Spacing.xs // 4.dp β Tight
Dimensions.Spacing.sm // 8.dp β Small
Dimensions.Spacing.md // 12.dp β Medium
Dimensions.Spacing.lg // 16.dp β Standard
Dimensions.Spacing.xl // 20.dp β Large
Dimensions.Spacing.xxl // 24.dp β Extra large
Dimensions.Spacing.xxxl // 32.dp β Section
Dimensions.Spacing.huge // 40.dp β Major section
Dimensions.Spacing.massive// 48.dp β Screen-level
Corner Radiusβ
Dimensions.Radius.none // 0.dp
Dimensions.Radius.xs // 4.dp β Subtle
Dimensions.Radius.sm // 6.dp β Small
Dimensions.Radius.md // 8.dp β Medium
Dimensions.Radius.lg // 12.dp β Standard card
Dimensions.Radius.xl // 14.dp β Large
Dimensions.Radius.xxl // 16.dp β Extra large
Dimensions.Radius.xxxl // 20.dp β Full card
Dimensions.Radius.full // 24.dp β Near-circular
Icon Sizesβ
Dimensions.IconSize.xs // 14.dp β Micro icons
Dimensions.IconSize.sm // 16.dp β Small icons
Dimensions.IconSize.md // 20.dp β Medium icons
Dimensions.IconSize.lg // 24.dp β Standard icons
Dimensions.IconSize.xl // 28.dp β Large icons
Dimensions.IconSize.xxl // 32.dp β Extra large
Dimensions.IconSize.display // 40.dp β Display icons
Dimensions.IconSize.hero // 48.dp β Hero icons
Dimensions.IconSize.avatar // 56.dp β Avatar size
Dimensions.IconSize.illustration // 80.dp β Illustrations
Dimensions.IconSize.feature // 100.dp β Feature icons
Component Heightsβ
Dimensions.Height.buttonSm // 40.dp β Small button
Dimensions.Height.button // 48.dp β Standard button
Dimensions.Height.buttonLg // 56.dp β Large button
Dimensions.Height.buttonXl // 60.dp β Extra large button
Dimensions.Height.input // 56.dp β Input field
Dimensions.Height.inputLg // 60.dp β Large input field
Dimensions.Height.listItem // 56.dp β List item
Dimensions.Height.header // 56.dp β Header/toolbar
Other Token Groupsβ
// Borders
Dimensions.Border.none // 0.dp
Dimensions.Border.thin // 1.dp
Dimensions.Border.medium // 2.dp
Dimensions.Border.thick // 3.dp
// Elevation
Dimensions.Elevation.none // 0.dp
Dimensions.Elevation.sm // 2.dp
Dimensions.Elevation.md // 4.dp
Dimensions.Elevation.lg // 8.dp
Dimensions.Elevation.xl // 16.dp
// Navigation
Dimensions.NavBar.height // 60.dp
// Bottom Sheet
Dimensions.BottomSheet.dragHandleHeight // 24.dp
Dimensions.BottomSheet.dragHandleWidth // 28.dp
Dimensions.BottomSheet.dragHandleThickness // 3.dp
Dimensions.BottomSheet.topCornerRadius // 16.dp
Dimensions.BottomSheet.headerHeight // 40.dp
Dimensions.BottomSheet.EXPANDED_FRACTION // 0.90f
Dimensions.BottomSheet.HALF_FRACTION // 0.50f
// List Item
Dimensions.ListItem.leadingIconSm // 44.dp
Dimensions.ListItem.leadingIconMd // 48.dp
Dimensions.ListItem.leadingIconLg // 50.dp
Dimensions.ListItem.muteBadgeSize // 18.dp
Dimensions.ListItem.muteBadgeIconSize // 12.dp
Dimensions.ListItem.statusDotSize // 6.dp
// Map Markers
Dimensions.Marker.size // 32.dp
Dimensions.Marker.glowSize // 44.dp
Dimensions.Marker.iconSize // 16.dp
Dimensions.Marker.navigationIconSize // 18.dp
GlassSurfaceβ
The GlassSurface composable provides a frosted-glass effect for components that float over busy backgrounds (e.g., map overlays):
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
shape: Shape,
content: @Composable () -> Unit,
)
Implementation details:
- Shadow: 2dp shadow applied to the shape for depth.
- Background:
surfaceElevatedat 92% opacity for the frosted effect. - Border: 0.5dp
borderSubtlestroke for edge definition. - Clipping: Shape is clipped before the caller's modifier so ripples respect the shape bounds.
Usage:
GlassSurface(shape = RoundedCornerShape(Dimensions.Radius.lg)) {
Text("Floating over the map", color = VislaTheme.colors.textPrimary)
}
Map Constantsβ
MapConstants centralizes map behavior values shared across Google Maps and Mapbox providers:
object MapConstants {
const val FOCUS_ZOOM = 16.0 // Device/location focus zoom
const val DEFAULT_ZOOM = 14.0 // General default zoom
const val FIT_ALL_PADDING = 100.0 // Padding when fitting all devices (dp)
const val PUCK_CLICK_THRESHOLD = 60.0 // Tap detection radius (px)
const val NOTIFICATION_ZOOM = 16f // Deep link zoom
const val DEFAULT_LAT = 42.5 // Default center (Italy)
const val DEFAULT_LNG = 12.5
const val DEFAULT_OVERVIEW_ZOOM = 5.5 // Overview zoom
}
Component Inventoryβ
Design System Types (ComponentTypes.kt)β
Shared enums used across the component library:
enum class BackgroundVariant { Default, Premium }
enum class ButtonSize { Small, Medium, Large, XLarge }
enum class LoadingSize { Small, Medium, Large }
Common Components (components/common/)β
| Component | Description |
|---|---|
GradientBackground | Full-screen gradient wrapper supporting Default and Premium background variants |
PrimaryButton | Solid primary action button with loading spinner, 4 size options, optional icon |
SecondaryButton | Outlined secondary button matching PrimaryButton interface |
ActionButton | Compact button with icon for inline actions and empty state CTAs |
CircleIconButton | Circular icon button with configurable container/content colors |
TextInput | Full-featured text field with validation, error state, password masking, keyboard options |
OtpInput | 6-digit code input with individual digit boxes and auto-focus |
StatusBadge | Online/offline/suspended indicator with colored dot and semi-transparent background |
ContentCard | Standard card container with surfaceElevated background and rounded corners |
ElevatedCard | Clickable card variant with optional onClick handler |
VislaListItem | Flexible list row with leading/trailing content, customizable colors and typography |
CircleIconLeading | Helper: circular icon container for VislaListItem leading slot |
ChevronTrailing | Helper: chevron icon for VislaListItem trailing slot |
ScreenHeader | Top-level screen header with displaySmall title and optional actions |
SubScreenHeader | Nested screen header with back button and centered title |
SectionHeader | Section divider with uppercase label, optional indicator dot and action |
EmptyState | Full-screen empty state with icon, title, message, and optional action |
ErrorMessage | Error display with optional retry button |
LoadingIndicator | Centered circular progress in 3 sizes |
AuthLogo | Theme-adaptive Visla logo for auth screens |
AuthSettingsHeader | Auth screen header with theme/language toggles |
DeviceMarker | Unified map marker for devices, geofences, routes, and history points |
DismissKeyboardBox | Container that dismisses keyboard on tap |
RowPosition / getRowShape | Utility enum + function for rounded corners in grouped list items |
Device Components (components/device/)β
| Component | Description |
|---|---|
DeviceOverviewCard | Card showing device name, model, and online/offline status |
PositionCard | Card displaying speed, altitude, direction, and address |
QuickActionsCard | Dynamic action buttons (History, Sharing, Geofences, Settings) based on permissions |
QuickActionButton | Individual quick action with icon and label |
CommandsSection | Available device commands with chip layout |
SendCommandBottomSheet | Categorized command list (Security, Tracking, Diagnostics) |
CommandParameterSheet | Dynamic parameter input for device commands |
NotificationsSection | Push, email, and call notification settings with contact management |
SharingSection | Device sharing information display |
DeviceSettingsSection | Device name editing and detail display |
DetailSection | Container for organizing detail rows with title |
DetailStatRow | Icon + label + value row for device statistics |
CopyableRow | Label-value row with clipboard copy functionality |
DangerButton | Destructive action button (suspend, remove device) |
MiniMapCard | Embedded map preview of device location |
AddDeviceBottomSheet | Modal for claiming devices via QR scan or token input |
Map Components (components/map/)β
| Component | Description |
|---|---|
MapBottomSheet | Draggable bottom sheet with device list, device info, and notification panels |
MapSheetContent | Content views for the bottom sheet (DeviceInfoContent, NotificationInfoContent) |
MapControls | Map control buttons and online device count badge |
EdgeClampedMarkers | Off-screen device indicators clamped to screen edges |
SheetAnchor | Enum defining sheet positions: Collapsed, HalfExpanded, Expanded |
Settings Components (components/settings/)β
| Component | Description |
|---|---|
SettingsSection | Container with uppercase title for grouping settings rows |
SettingsRow | Static row with icon, label, and optional trailing content |
SettingsRowClickable | Clickable settings row with chevron |
SettingsRowWithSubtitle | Settings row with subtitle text |
ActionRow | Destructive/action row (e.g., logout) |
ProfileHeader | User profile section with avatar, name, and email |
LogoutDialog | Confirmation dialog for logout |
LanguagePickerDialog | Language selection bottom sheet |
ThemePickerDialog | Theme mode selection (System, Dark, Light) |
TimezonePickerDialog | Timezone picker with scrollable list |
Geofence Components (components/geofence/)β
| Component | Description |
|---|---|
EnterDetailsPhase | Geofence creation form (name, description, alert settings) |
DrawShapePhase | Map integration for drawing circle/polygon geofences |
ShapeTypeButton | Toggle between circle and polygon shapes |
Notification Components (components/notifications/)β
| Component | Description |
|---|---|
EventCard | Card for event-type notifications with icon and metadata |
NotificationCard | Standard notification card with formatted content |
GeofenceCard | Geofence alert card with enter/exit information |
NotificationHistoryCard | Historical notification entry |
SwipeToDeleteContainer | Swipe gesture wrapper for dismissing notifications |
EmptyEventsView | Empty state for events tab |
EmptyNotificationsView | Empty state for notifications tab |
NotificationsTabBackend | Paginated notification list with swipe-to-dismiss |
Subscription Components (components/subscription/)β
| Component | Description |
|---|---|
PremiumHeader | Subscription flow header with icon and close button |
StatusCard | Current subscription status display |
ActiveSubscriptionContent | Full active subscription layout (status + license + actions) |
LicenseUsageCard | License count with progress bar (active/allowed/available) |
DeviceSelectorCard | Device count selector for plan configuration |
PeriodSelectorCard | Billing period selector with current plan highlighting |
FeaturesCard | Feature list with checkmarks |
PriceSummaryCard | Price display for selected plan |
PurchaseButton | Purchase CTA button |
RestorePurchasesButton | Restore previous purchases |
ActionsCard | Plan modification and cancellation actions |
InfoCard | Subscription information card |
NoSubscriptionContent | Empty state when no active subscription |
Other Componentsβ
| Component | File | Description |
|---|---|---|
DeviceSelectionDialog | components/DeviceSelectionDialog.kt | Modal dialog for selecting a device from a list |
Component Patternsβ
Modifier Parameter Orderingβ
All components follow the Compose convention of accepting modifier as the first optional parameter after required parameters:
@Composable
fun PrimaryButton(
text: String, // Required
onClick: () -> Unit, // Required
modifier: Modifier = Modifier, // Optional, early position
icon: ImageVector? = null,
enabled: Boolean = true,
loading: Boolean = false,
size: ButtonSize = ButtonSize.Large,
fullWidth: Boolean = true,
testTag: String? = null,
)
Theme Accessβ
Components access the theme exclusively through VislaTheme:
@Composable
fun SectionHeader(title: String, ...) {
Text(
text = title.uppercase(),
style = VislaTheme.typography.labelMedium,
color = VislaTheme.colors.textSecondary,
)
}
Never access LocalColors.current or LocalTypography.current directly in components β always go through VislaTheme.colors, VislaTheme.typography, or VislaTheme.isDarkTheme.
Dimensions Usageβ
Spacing and sizing always reference Dimensions tokens rather than raw dp values:
Column(
modifier = Modifier.padding(Dimensions.Spacing.lg),
verticalArrangement = Arrangement.spacedBy(Dimensions.Spacing.sm)
) {
// Content with consistent spacing
}
Status Colorsβ
Device status colors are resolved from the theme for automatic dark/light adaptation:
val color = when {
suspended -> VislaTheme.colors.deviceSuspended
isOnline -> VislaTheme.colors.deviceOnline
else -> VislaTheme.colors.deviceOffline
}
List Item Grouping with RowPositionβ
Grouped list items use RowPosition and getRowShape() for proper corner rounding:
enum class RowPosition { SINGLE, FIRST, MIDDLE, LAST }
// Only SINGLE and FIRST get top corners rounded
// Only SINGLE and LAST get bottom corners rounded
val shape = getRowShape(position, Dimensions.Radius.lg)
Design Decisionsβ
Why Custom VislaTheme Over Raw MaterialThemeβ
Material3's ColorScheme provides generic tokens like primary, surface, and error, but lacks domain-specific semantics. Visla's ColorScheme adds:
- Device status colors (
deviceOnline,deviceOffline,deviceSuspended) β GPS tracker states that have no Material equivalent. - Map colors (
mapRoute,mapStart,mapEnd) β route visualization tokens. - Social provider branding (
socialFacebook,socialGoogle,socialApple) β OAuth button colors. - Fine-grained text hierarchy β five levels (
textPrimarythroughtextMuted) instead of Material's two (onSurface,onSurfaceVariant). - Glass/transparent surfaces (
surfaceGlass,surfaceSubtle) β for map overlays and frosted effects.
The custom theme wraps MaterialTheme so that Material3 components (dialogs, bottom sheets) still receive correct colors, while Visla components use the richer token set.
Why Dimensions Objectβ
Hardcoded dp values scattered across components lead to subtle inconsistencies (one card uses 12.dp padding while another uses 16.dp for the same context). The Dimensions object provides:
- Single source of truth β change
Spacing.lgfrom16.dpto20.dpand every component updates. - Semantic naming β
Spacing.lgcommunicates intent better than16.dp. - 4dp grid enforcement β all values are multiples of 4dp (except
xxs = 2.dp), ensuring alignment. - Categorized tokens β separate objects for
Spacing,Radius,IconSize,Height,Border, andElevationprevent misuse (you won't accidentally use a spacing value for a corner radius).
Why Glassmorphism Effectsβ
The GlassSurface composable uses a semi-transparent surface with subtle border and shadow to create a frosted-glass aesthetic. This matters because:
- Map overlays need visibility without occlusion β fully opaque controls hide the map; fully transparent controls are invisible. The 92% opacity strikes the right balance.
- Modern design language β glassmorphism provides depth cues and visual hierarchy consistent with iOS and contemporary Android apps.
- Theme integration β the effect adapts to dark/light mode via
surfaceElevatedandborderSubtletokens rather than hardcoded colors.
Why Composition Locals for Themeβ
Using CompositionLocalProvider to deliver theme values is the Compose-idiomatic pattern. Compared to alternatives:
- No prop drilling β components read
VislaTheme.colorsanywhere in the tree without passing colors through every intermediate composable. - Automatic recomposition β when the theme changes (dark β light), only affected composables recompose.
staticCompositionLocalOfβ used instead ofcompositionLocalOfbecause theme values rarely change during a session, avoiding unnecessary snapshot overhead.- Familiar pattern β mirrors how Material3 itself provides
MaterialTheme.colorScheme, so the approach is immediately understandable to any Compose developer.