├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── iosApp ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── app-icon-1024.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── iOSApp.swift │ ├── Info.plist │ └── ContentView.swift ├── Configuration │ └── Config.xcconfig ├── .gitignore └── iosApp.xcodeproj │ └── project.pbxproj ├── composeApp ├── src │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── macaosoftware │ │ │ │ └── ui │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── webMain │ │ ├── resources │ │ │ ├── styles.css │ │ │ └── index.html │ │ └── kotlin │ │ │ └── com │ │ │ └── macaosoftware │ │ │ └── ui │ │ │ └── main.kt │ ├── commonMain │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── macaosoftware │ │ │ │ └── ui │ │ │ │ ├── ui │ │ │ │ ├── SlotsViewType.kt │ │ │ │ ├── AlertDialogEventsState.kt │ │ │ │ ├── model │ │ │ │ │ └── AllDayEvent.kt │ │ │ │ ├── BottomSheetEventsState.kt │ │ │ │ ├── DayScheduleAppAlertDialog.kt │ │ │ │ ├── DayScheduleAppActionsBottomView.kt │ │ │ │ ├── DayScheduleApp.kt │ │ │ │ └── DayScheduleAppViewModel.kt │ │ │ │ └── data │ │ │ │ ├── Constants.kt │ │ │ │ ├── TimeSlotsDataSample.kt │ │ │ │ ├── EpgSlotsDataSample.kt │ │ │ │ └── DecimalSlotsDataSample.kt │ │ └── composeResources │ │ │ └── drawable │ │ │ └── compose-multiplatform.xml │ ├── iosMain │ │ └── kotlin │ │ │ └── com │ │ │ └── macaosoftware │ │ │ └── ui │ │ │ └── MainViewController.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── com │ │ │ └── macaosoftware │ │ │ └── ui │ │ │ └── ComposeAppCommonTest.kt │ └── jvmMain │ │ └── kotlin │ │ └── com │ │ └── macaosoftware │ │ └── ui │ │ └── main.kt ├── webpack.config.d │ └── watch.js └── build.gradle.kts ├── daily-agenda-view ├── src │ ├── webMain │ │ └── resources │ │ │ ├── styles.css │ │ │ └── index.html │ ├── androidMain │ │ └── AndroidManifest.xml │ ├── iosMain │ │ └── kotlin │ │ │ └── com │ │ │ └── macaosoftware │ │ │ └── ui │ │ │ └── MainViewController.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── com │ │ │ └── macaosoftware │ │ │ └── ui │ │ │ └── ComposeAppCommonTest.kt │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── macaosoftware │ │ └── ui │ │ └── dailyagenda │ │ ├── epgslots │ │ ├── EpgSlotsDataUpdater.kt │ │ ├── EpgSlotsState.kt │ │ ├── EpgSlotsView.kt │ │ ├── EpgSlotsStateController.kt │ │ └── EpgSlotsLayout.kt │ │ ├── slotslayer │ │ ├── SlotsLayerState.kt │ │ └── SlotsLayer.kt │ │ ├── marker │ │ ├── CurrentTimeMarkerView.kt │ │ └── CurrentTimeMarkerStateController.kt │ │ ├── decimalslots │ │ ├── DecimalSlotsView.kt │ │ ├── RtlCustomRow.kt │ │ ├── DecimalSlotsStateController.kt │ │ ├── DecimalSlotsBaseLayoutState.kt │ │ ├── DecimalSlotsBaseLayoutStateController.kt │ │ ├── DecimalSlotsDataUpdater.kt │ │ ├── DecimalSlotsBaseLayout.kt │ │ └── LayoutUtil.kt │ │ └── timeslots │ │ ├── TimeSlotsView.kt │ │ ├── TimeSlotsDataUpdater.kt │ │ ├── TimeSlotsStateController.kt │ │ └── TimeSlotsState.kt └── build.gradle.kts ├── gradle.properties ├── kotlin-js-store └── wasm │ └── yarn.lock ├── .gitignore ├── settings.gradle.kts ├── .github └── workflows │ └── deploy-page.yaml ├── gradlew.bat ├── gradlew └── README.md /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | DailyAgendaView 3 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /composeApp/src/webMain/resources/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | overflow: hidden; 7 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/SlotsViewType.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | enum class SlotsViewType { 4 | Decimal, Timeline, Epg 5 | } -------------------------------------------------------------------------------- /daily-agenda-view/src/webMain/resources/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | overflow: hidden; 7 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/data/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.data 2 | 3 | object Constants { 4 | 5 | const val EmptyDescription = "" 6 | } -------------------------------------------------------------------------------- /daily-agenda-view/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablichjenkov/daily-agenda-view/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct iOSApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | 3 | PRODUCT_NAME=DailyAgendaView 4 | PRODUCT_BUNDLE_IDENTIFIER=com.macaosoftware.ui.DailyAgendaView$(TEAM_ID) 5 | 6 | CURRENT_PROJECT_VERSION=1 7 | MARKETING_VERSION=1.0 -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /daily-agenda-view/src/iosMain/kotlin/com/macaosoftware/ui/MainViewController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | 5 | fun MainViewController() = ComposeUIViewController { 6 | 7 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/AlertDialogEventsState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | sealed interface AlertDialogEventsState { 4 | object Hidden : AlertDialogEventsState 5 | class ShowingInfo(val text: String) : AlertDialogEventsState 6 | } 7 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/com/macaosoftware/ui/MainViewController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | import com.macaosoftware.ui.ui.DayScheduleApp 5 | 6 | fun MainViewController() = ComposeUIViewController { DayScheduleApp() } -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/com/macaosoftware/ui/ComposeAppCommonTest.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class ComposeAppCommonTest { 7 | 8 | @Test 9 | fun example() { 10 | assertEquals(3, 1 + 2) 11 | } 12 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Kotlin 2 | kotlin.code.style=official 3 | kotlin.daemon.jvmargs=-Xmx4g 4 | 5 | #Gradle 6 | org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 7 | org.gradle.configuration-cache=true 8 | org.gradle.caching=true 9 | 10 | #Android 11 | android.nonTransitiveRClass=true 12 | android.useAndroidX=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonTest/kotlin/com/macaosoftware/ui/ComposeAppCommonTest.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class ComposeAppCommonTest { 7 | 8 | @Test 9 | fun example() { 10 | assertEquals(3, 1 + 2) 11 | } 12 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/webMain/kotlin/com/macaosoftware/ui/main.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import androidx.compose.ui.ExperimentalComposeUiApi 4 | import androidx.compose.ui.window.ComposeViewport 5 | import com.macaosoftware.ui.ui.DayScheduleApp 6 | 7 | @OptIn(ExperimentalComposeUiApi::class) 8 | fun main() { 9 | ComposeViewport { 10 | DayScheduleApp() 11 | } 12 | } -------------------------------------------------------------------------------- /kotlin-js-store/wasm/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@js-joda/core@3.2.0": 6 | version "3.2.0" 7 | resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" 8 | integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== 9 | -------------------------------------------------------------------------------- /composeApp/src/webMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DailyAgendaView 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/com/macaosoftware/ui/main.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import androidx.compose.ui.window.Window 4 | import androidx.compose.ui.window.application 5 | import com.macaosoftware.ui.ui.DayScheduleApp 6 | 7 | fun main() = application { 8 | Window( 9 | onCloseRequest = ::exitApplication, 10 | title = "DailyAgendaView", 11 | ) { 12 | DayScheduleApp() 13 | } 14 | } -------------------------------------------------------------------------------- /daily-agenda-view/src/webMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DailyAgendaView 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | struct ContentView: View { 14 | var body: some View { 15 | ComposeView() 16 | .ignoresSafeArea() 17 | } 18 | } 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | .kotlin 12 | 13 | **/build/ 14 | xcuserdata 15 | !src/**/build/ 16 | local.properties 17 | .idea 18 | .DS_Store 19 | captures 20 | .externalNativeBuild 21 | .cxx 22 | *.xcodeproj/* 23 | !*.xcodeproj/project.pbxproj 24 | !*.xcodeproj/xcshareddata/ 25 | !*.xcodeproj/project.xcworkspace/ 26 | !*.xcworkspace/contents.xcworkspacedata 27 | **/xcshareddata/WorkspaceSettings.xcsettings 28 | node_modules/ 29 | 30 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/epgslots/EpgSlotsDataUpdater.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.epgslots 2 | 3 | class EpgSlotsDataUpdater internal constructor( 4 | val epgSlotsStateController: EpgSlotsStateController 5 | ) { 6 | 7 | fun addChannel(epgChannel: EpgChannel): Boolean { 8 | return epgSlotsStateController.epgChannels.add(epgChannel) 9 | } 10 | 11 | internal fun commit() { 12 | epgSlotsStateController.updateState() 13 | } 14 | 15 | fun postUpdate(block: EpgSlotsDataUpdater.() -> Unit) { 16 | this.block() 17 | commit() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/model/AllDayEvent.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui.model 2 | 3 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 4 | import kotlin.uuid.ExperimentalUuidApi 5 | import kotlin.uuid.Uuid 6 | 7 | @OptIn(ExperimentalUuidApi::class) 8 | data class AllDayEvent( 9 | val uuid: Uuid, 10 | val title: String, 11 | val description: String 12 | ) { 13 | 14 | override fun equals(other: Any?): Boolean { 15 | if (this === other) return true 16 | if (other !is LocalTimeEvent) return false 17 | return other.uuid == uuid 18 | } 19 | 20 | override fun hashCode(): Int { 21 | return uuid.hashCode() 22 | } 23 | } -------------------------------------------------------------------------------- /composeApp/webpack.config.d/watch.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Temporary workaround for [KT-80582](https://youtrack.jetbrains.com/issue/KT-80582) 3 | * 4 | * This file should be safe to be removed once the ticket is closed and the project is updated to Kotlin version which solves that issue. 5 | */ 6 | config.watchOptions = config.watchOptions || { 7 | ignored: ["**/*.kt", "**/node_modules"] 8 | } 9 | 10 | if (config.devServer) { 11 | config.devServer.static = config.devServer.static.map(file => { 12 | if (typeof file === "string") { 13 | return { 14 | directory: file, 15 | watch: false, 16 | } 17 | } else { 18 | return file 19 | } 20 | }) 21 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/macaosoftware/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import com.macaosoftware.ui.ui.DayScheduleApp 10 | 11 | class MainActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | enableEdgeToEdge() 14 | super.onCreate(savedInstanceState) 15 | setContent { 16 | DayScheduleApp() 17 | } 18 | } 19 | } 20 | 21 | @Preview 22 | @Composable 23 | fun DayScheduleAppAndroidPreview() { 24 | DayScheduleApp() 25 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "idiom" : "universal", 17 | "platform" : "ios", 18 | "size" : "1024x1024" 19 | }, 20 | { 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "tinted" 25 | } 26 | ], 27 | "idiom" : "universal", 28 | "platform" : "ios", 29 | "size" : "1024x1024" 30 | } 31 | ], 32 | "info" : { 33 | "author" : "xcode", 34 | "version" : 1 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/slotslayer/SlotsLayerState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.slotslayer 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsBaseLayoutState 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 5 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgSlotsState 6 | 7 | data class SlotsLayerState( 8 | val slots: List, 9 | val slotHeight: Int 10 | ) 11 | 12 | internal fun DecimalSlotsBaseLayoutState.getSlotsLayerState(): SlotsLayerState { 13 | return SlotsLayerState( 14 | slots = slots, 15 | slotHeight = config.slotHeight 16 | ) 17 | } 18 | 19 | internal fun EpgSlotsState.getSlotsLayerState(): SlotsLayerState { 20 | return SlotsLayerState( 21 | slots = slots, 22 | slotHeight = epgChannelSlotConfig.timeSlotConfig.slotHeight 23 | ) 24 | } -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/marker/CurrentTimeMarkerView.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.marker 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.offset 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.unit.dp 12 | 13 | @Composable 14 | internal fun CurrentTimeMarkerView( 15 | modifier: Modifier = Modifier, 16 | currentTimeMarkerStateController: CurrentTimeMarkerStateController 17 | ) { 18 | 19 | val state = currentTimeMarkerStateController.state 20 | 21 | Box(modifier = modifier 22 | .offset(y = state.value.offsetY) 23 | .height(height = 1.dp) 24 | .fillMaxWidth() 25 | .background(Color.Red) 26 | ) 27 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | mavenContent { 5 | includeGroupAndSubgroups("androidx") 6 | includeGroupAndSubgroups("com.android") 7 | includeGroupAndSubgroups("com.google") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | 15 | dependencyResolutionManagement { 16 | repositories { 17 | google { 18 | mavenContent { 19 | includeGroupAndSubgroups("androidx") 20 | includeGroupAndSubgroups("com.android") 21 | includeGroupAndSubgroups("com.google") 22 | } 23 | } 24 | mavenCentral() 25 | } 26 | } 27 | 28 | plugins { 29 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 30 | } 31 | 32 | rootProject.name = "daily-agenda-view" 33 | // enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") // not working on github actions 34 | include(":composeApp") 35 | include(":daily-agenda-view") -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/BottomSheetEventsState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalEvent 4 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 5 | 6 | sealed interface BottomSheetEventsState { 7 | object Hidden : BottomSheetEventsState 8 | 9 | class ShowTimedEventRequested(val localTimeEvent: LocalTimeEvent) : 10 | BottomSheetEventsState 11 | 12 | object AddTimedEventRequested : BottomSheetEventsState 13 | 14 | class RemoveTimedEventRequested(val localTimeEvent: LocalTimeEvent) : 15 | BottomSheetEventsState 16 | 17 | class ShowDecimalEventRequested(val decimalEvent: DecimalEvent) : BottomSheetEventsState 18 | 19 | object AddDecimalEventRequested : BottomSheetEventsState 20 | 21 | class RemoveDecimalEventRequested(val decimalEvent: DecimalEvent) : BottomSheetEventsState 22 | 23 | class ShowEpgEventRequested(val epgEvent: LocalTimeEvent) : BottomSheetEventsState 24 | 25 | object AddEpgEventRequested : BottomSheetEventsState 26 | 27 | class RemoveEpgEventRequested(val epgEvent: LocalTimeEvent) : BottomSheetEventsState 28 | } -------------------------------------------------------------------------------- /.github/workflows/deploy-page.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy KMP Web App 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up JDK 17 20 | uses: actions/setup-java@v4 21 | with: 22 | distribution: temurin 23 | java-version: '17' 24 | 25 | - name: Setup Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | 28 | - name: Build KMP Web artifact 29 | run: ./gradlew wasmJsBrowserDistribution 30 | 31 | - name: Configure GitHub Pages 32 | id: pages 33 | uses: actions/configure-pages@v5 34 | 35 | - name: Upload Pages artifact 36 | uses: actions/upload-pages-artifact@v3 37 | with: 38 | path: composeApp/build/dist/wasmJs/productionExecutable 39 | 40 | deploy: 41 | needs: build 42 | runs-on: ubuntu-latest 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | steps: 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/epgslots/EpgSlotsState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.epgslots 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotConfig 5 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 6 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotConfig 7 | import com.macaosoftware.ui.dailyagenda.timeslots.toSlotConfig 8 | import kotlinx.datetime.LocalTime 9 | 10 | data class EpgSlotsState( 11 | val slots: List, 12 | val epgChannels: List, 13 | val epgChannelSlotConfig: EpgChannelSlotConfig, 14 | val epgChannelHeight: Float 15 | ) 16 | 17 | data class EpgChannel( 18 | val name: String, 19 | val events: List 20 | ) 21 | 22 | data class EpgChannelSlotConfig( 23 | val channelWidth: Int = 88, 24 | val topHeaderHeight: Int = 56, 25 | val timeSlotConfig: TimeSlotConfig = TimeSlotConfig( 26 | startSlotTime = LocalTime(0, 0), 27 | endSlotTime = LocalTime(23, 59), 28 | useAmPm = true, 29 | slotScale = 2, 30 | slotHeight = 48, 31 | timelineLeftPadding = 72 32 | ) 33 | ) 34 | 35 | internal fun EpgChannelSlotConfig.toSlotConfig(): DecimalSlotConfig { 36 | return timeSlotConfig.toSlotConfig() 37 | } 38 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsView.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import com.macaosoftware.ui.dailyagenda.slotslayer.SlotsLayer 10 | import com.macaosoftware.ui.dailyagenda.slotslayer.getSlotsLayerState 11 | 12 | @Composable 13 | fun DecimalSlotsView( 14 | decimalSlotsStateController: DecimalSlotsStateController, 15 | eventContentProvider: @Composable (decimalEvent: DecimalEvent) -> Unit 16 | ) { 17 | val dailyAgendaState = 18 | decimalSlotsStateController.decimalSlotsBaseLayoutStateController.state.value ?: return 19 | val scrollState = rememberScrollState() 20 | Box( 21 | modifier = Modifier 22 | .fillMaxSize() 23 | .verticalScroll(scrollState) 24 | ) { 25 | SlotsLayer(slotsLayerState = dailyAgendaState.getSlotsLayerState()) 26 | DecimalSlotsBaseLayout( 27 | decimalSlotsBaseLayoutState = dailyAgendaState, 28 | eventContentProvider = eventContentProvider 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/RtlCustomRow.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.Layout 6 | 7 | @Composable 8 | internal fun RtlCustomRow( 9 | modifier: Modifier = Modifier, 10 | content: @Composable () -> Unit 11 | ) { 12 | Layout( 13 | modifier = modifier, 14 | content = content 15 | ) { measurables, constraints -> 16 | 17 | // 1. Measure children 18 | val placeables = measurables.map { measurable -> 19 | measurable.measure(constraints) 20 | } 21 | 22 | // 2. Calculate layout size (e.g., sum of widths for a horizontal row) 23 | var currentX = 0 24 | var maxHeight = 0 25 | placeables.forEach { placeable -> 26 | currentX += placeable.width 27 | if (placeable.height > maxHeight) { 28 | maxHeight = placeable.height 29 | } 30 | } 31 | 32 | // 3. Place children 33 | layout(width = currentX, height = maxHeight) { 34 | var xPosition = constraints.maxWidth 35 | placeables.forEach { placeable -> 36 | 37 | xPosition -= placeable.width 38 | placeable.placeRelative(x = xPosition, y = 0) 39 | //xPosition += placeable.width 40 | 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/marker/CurrentTimeMarkerStateController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.marker 2 | 3 | import androidx.compose.runtime.mutableStateOf 4 | import androidx.compose.ui.unit.Dp 5 | import androidx.compose.ui.unit.dp 6 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotConfig 7 | import com.macaosoftware.ui.dailyagenda.timeslots.fromLocalTimeToValue 8 | import kotlinx.datetime.LocalTime 9 | import kotlinx.datetime.TimeZone 10 | import kotlinx.datetime.toLocalDateTime 11 | import kotlin.time.Clock 12 | import kotlin.time.ExperimentalTime 13 | 14 | data class CurrentTimeMarkerState(val offsetY: Dp) 15 | 16 | internal class CurrentTimeMarkerStateController( 17 | decimalSlotConfig: DecimalSlotConfig 18 | ) { 19 | 20 | val state = mutableStateOf(value = CurrentTimeMarkerState(offsetY = 0.dp)) 21 | 22 | init { 23 | val localTime = getCurrentLocalTime() 24 | val currentTimeAsDecimal = fromLocalTimeToValue(localTime) 25 | val offsetY = 26 | (currentTimeAsDecimal - decimalSlotConfig.initialSlotValue) * (decimalSlotConfig.slotScale * decimalSlotConfig.slotHeight) 27 | 28 | 29 | state.value = CurrentTimeMarkerState(offsetY = offsetY.dp) 30 | } 31 | 32 | @OptIn(ExperimentalTime::class) 33 | fun getCurrentLocalTime(): LocalTime { 34 | val now = Clock.System.now() 35 | val timeZone = TimeZone.currentSystemDefault() 36 | val localDateTime = now.toLocalDateTime(timeZone) 37 | return localDateTime.time 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/slotslayer/SlotsLayer.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.slotslayer 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.HorizontalDivider 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.unit.dp 15 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 16 | 17 | 18 | @Composable 19 | internal fun SlotsLayer( 20 | modifier: Modifier = Modifier, 21 | slotsLayerState: SlotsLayerState 22 | ) { 23 | Column( 24 | modifier = modifier.fillMaxSize() 25 | ) { 26 | slotsLayerState.slots.forEach { slot -> 27 | SlotLine(slot = slot, slotHeight = slotsLayerState.slotHeight) 28 | } 29 | } 30 | } 31 | 32 | @Composable 33 | private fun SlotLine(slot: Slot, slotHeight: Int) { 34 | Box( 35 | modifier = Modifier 36 | .fillMaxWidth() 37 | .height(height = slotHeight.dp) 38 | ) { 39 | HorizontalDivider( 40 | modifier = Modifier 41 | .fillMaxWidth() 42 | .padding(horizontal = 8.dp), 43 | color = Color.Black 44 | ) 45 | Text(text = slot.title) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 14 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/DayScheduleAppAlertDialog.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.BasicAlertDialog 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.Card 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | 15 | @OptIn(ExperimentalMaterial3Api::class) 16 | @Composable 17 | internal fun DayScheduleAppAlertDialog( 18 | alertDialogEventsState: AlertDialogEventsState, 19 | alertDialogUiActionListener: DayScheduleAppViewModel.AlertDialogUiActionListener 20 | ) { 21 | when (alertDialogEventsState) { 22 | AlertDialogEventsState.Hidden -> { 23 | // no-op 24 | } 25 | 26 | is AlertDialogEventsState.ShowingInfo -> { 27 | BasicAlertDialog( 28 | onDismissRequest = { alertDialogUiActionListener.hideAlert() } 29 | ) { 30 | Card( 31 | modifier = Modifier.fillMaxWidth() 32 | ) { 33 | Column(modifier = Modifier.padding(16.dp)) { 34 | Text(text = alertDialogEventsState.text) 35 | Button(onClick = { alertDialogUiActionListener.hideAlert() }) { 36 | Text("Dismiss") 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsStateController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | open class DecimalSlotsStateController( 4 | val decimalSlotConfig: DecimalSlotConfig, 5 | eventsArrangement: EventsArrangement = EventsArrangement.MixedDirections() 6 | ) { 7 | 8 | val slotScale = decimalSlotConfig.slotScale 9 | val slotHeight = decimalSlotConfig.slotHeight 10 | val slotUnit = 1.0F / slotScale 11 | val firstSlotIndex = (slotScale * decimalSlotConfig.initialSlotValue.toInt()) 12 | 13 | private val lastSlotIndex = decimalSlotConfig.lastSlotValue.toInt() * slotScale 14 | 15 | 16 | internal val decimalSlotsBaseLayoutStateController = DecimalSlotsBaseLayoutStateController( 17 | decimalSlotConfig = decimalSlotConfig, 18 | slots = createSlots(firstSlotIndex, lastSlotIndex), 19 | eventsArrangement = eventsArrangement 20 | ) 21 | 22 | val decimalSlotsDataUpdater = DecimalSlotsDataUpdater(decimalSlotsBaseLayoutStateController) 23 | 24 | fun createSlots( 25 | firstSlotIndex: Int, 26 | lastSlotIndex: Int 27 | ): List { 28 | val slots = mutableListOf() 29 | for (i in firstSlotIndex..lastSlotIndex) { 30 | val slotStartValue = i * slotUnit 31 | slots.add( 32 | Slot( 33 | title = "$slotStartValue", 34 | startValue = slotStartValue, 35 | endValue = slotStartValue + slotUnit 36 | ) 37 | ) 38 | } 39 | return slots 40 | } 41 | 42 | fun getTimeSlotsData(): Map> { 43 | return decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/timeslots/TimeSlotsView.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.timeslots 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsBaseLayout 11 | import com.macaosoftware.ui.dailyagenda.marker.CurrentTimeMarkerStateController 12 | import com.macaosoftware.ui.dailyagenda.marker.CurrentTimeMarkerView 13 | import com.macaosoftware.ui.dailyagenda.slotslayer.SlotsLayer 14 | import com.macaosoftware.ui.dailyagenda.slotslayer.getSlotsLayerState 15 | 16 | @Composable 17 | fun TimeSlotsView( 18 | timeSlotsStateController: TimeSlotsStateController, 19 | eventContentProvider: @Composable (event: LocalTimeEvent) -> Unit 20 | ) { 21 | val dailyAgendaState = 22 | timeSlotsStateController.decimalSlotsBaseLayoutStateController.state.value ?: return 23 | val scrollState = rememberScrollState() 24 | val currentTimeMarkerStateController = remember { 25 | CurrentTimeMarkerStateController(decimalSlotConfig = timeSlotsStateController.slotConfig) 26 | } 27 | Box( 28 | modifier = Modifier.fillMaxSize().verticalScroll(scrollState) 29 | ) { 30 | SlotsLayer(slotsLayerState = dailyAgendaState.getSlotsLayerState()) 31 | DecimalSlotsBaseLayout( 32 | decimalSlotsBaseLayoutState = dailyAgendaState, 33 | eventContentProvider = { event -> 34 | // Intercept the event to apply the toLocalTimeEvent() transformation 35 | eventContentProvider.invoke(event.toLocalTimeEvent()) 36 | } 37 | ) 38 | CurrentTimeMarkerView( 39 | currentTimeMarkerStateController = currentTimeMarkerStateController 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/timeslots/TimeSlotsDataUpdater.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.timeslots 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsBaseLayoutStateController 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsDataUpdater 5 | import kotlinx.datetime.LocalTime 6 | import kotlin.uuid.ExperimentalUuidApi 7 | import kotlin.uuid.Uuid 8 | 9 | class TimeSlotsDataUpdater internal constructor( 10 | decimalSlotsBaseLayoutStateController: DecimalSlotsBaseLayoutStateController 11 | ) { 12 | 13 | private val decimalSlotsDataUpdater = 14 | DecimalSlotsDataUpdater(decimalSlotsBaseLayoutStateController) 15 | 16 | @OptIn(ExperimentalUuidApi::class) 17 | fun addEvent( 18 | uuid: Uuid = Uuid.random(), 19 | title: String, 20 | description: String, 21 | startTime: LocalTime, 22 | endTime: LocalTime 23 | ): Boolean { 24 | return decimalSlotsDataUpdater.addDecimalEvent( 25 | uuid = uuid, 26 | title = title, 27 | description = description, 28 | startValue = fromLocalTimeToValue(localTime = startTime), 29 | endValue = fromLocalTimeToValue(localTime = endTime) 30 | ) 31 | } 32 | 33 | fun addEvent(event: LocalTimeEvent): Boolean { 34 | return decimalSlotsDataUpdater.addDecimalEvent(decimalEvent = event.toDecimalSegment()) 35 | } 36 | 37 | fun addEventList(startTime: LocalTime, events: List) { 38 | return decimalSlotsDataUpdater.addDecimalEventList( 39 | startValue = fromLocalTimeToValue(localTime = startTime), 40 | segments = events.map { it.toDecimalSegment() } 41 | ) 42 | } 43 | 44 | fun removeEvent(event: LocalTimeEvent): Boolean { 45 | return decimalSlotsDataUpdater.removeDecimalEvent(decimalEvent = event.toDecimalSegment()) 46 | } 47 | 48 | fun removeEventByTitle(eventTitle: String): Boolean { 49 | return decimalSlotsDataUpdater.removeDecimalEventByTittle(eventTitle = eventTitle) 50 | } 51 | 52 | fun postUpdate(block: TimeSlotsDataUpdater.() -> Unit) { 53 | this.block() 54 | decimalSlotsDataUpdater.commit() 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/epgslots/EpgSlotsView.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.epgslots 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.derivedStateOf 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import com.macaosoftware.ui.dailyagenda.decimalslots.toDp 15 | import com.macaosoftware.ui.dailyagenda.marker.CurrentTimeMarkerStateController 16 | import com.macaosoftware.ui.dailyagenda.marker.CurrentTimeMarkerView 17 | import com.macaosoftware.ui.dailyagenda.slotslayer.SlotsLayer 18 | import com.macaosoftware.ui.dailyagenda.slotslayer.getSlotsLayerState 19 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 20 | 21 | @Composable 22 | fun EpgSlotsView( 23 | epgSlotsStateController: EpgSlotsStateController, 24 | eventContentProvider: @Composable (localTimeEvent: LocalTimeEvent) -> Unit 25 | ) { 26 | val epgSlotsState = epgSlotsStateController.state.value ?: return 27 | val scrollState = rememberScrollState() 28 | val currentTimeMarkerStateController = remember { 29 | CurrentTimeMarkerStateController(decimalSlotConfig = epgSlotsState.epgChannelSlotConfig.toSlotConfig()) 30 | } 31 | val scrollOffset by remember { derivedStateOf { scrollState.value } } 32 | Box( 33 | modifier = Modifier.fillMaxSize().verticalScroll(scrollState) 34 | ) { 35 | SlotsLayer( 36 | modifier = Modifier.padding(top = epgSlotsState.epgChannelSlotConfig.topHeaderHeight.dp), 37 | slotsLayerState = epgSlotsState.getSlotsLayerState() 38 | ) 39 | EpgSlotsLayout( 40 | epgSlotsState = epgSlotsState, 41 | eventContentProvider = eventContentProvider, 42 | scrollOffset = scrollOffset.toDp() 43 | ) 44 | CurrentTimeMarkerView( 45 | modifier = Modifier.padding(top = epgSlotsState.epgChannelSlotConfig.topHeaderHeight.dp), 46 | currentTimeMarkerStateController = currentTimeMarkerStateController 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | dailyAgendaView = "1.5.1" 3 | kotlin = "2.2.21" 4 | agp = "8.13.1" 5 | android-compileSdk = "36" 6 | android-minSdk = "24" 7 | android-targetSdk = "36" 8 | composeMultiplatform = "1.9.3" 9 | composeHotReload = "1.0.0" 10 | 11 | androidx-activity = "1.12.0" 12 | androidx-core = "1.17.0" 13 | androidx-lifecycle = "2.9.6" 14 | 15 | kotlinx-coroutines = "1.10.2" 16 | kotlinx-datetime = "0.7.1" 17 | 18 | vanniktechMavenPublish = "0.35.0" 19 | 20 | junit = "4.13.2" 21 | androidx-testExt = "1.3.0" 22 | androidx-espresso = "3.7.0" 23 | 24 | [libraries] 25 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 26 | kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 27 | junit = { module = "junit:junit", version.ref = "junit" } 28 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } 29 | androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } 30 | androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } 31 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } 32 | androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } 33 | androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 34 | kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } 35 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 36 | 37 | [plugins] 38 | androidApplication = { id = "com.android.application", version.ref = "agp" } 39 | androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } 40 | composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } 41 | composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } 42 | composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 43 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 44 | vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } 45 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/timeslots/TimeSlotsStateController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.timeslots 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsBaseLayoutStateController 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.EventsArrangement 5 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 6 | 7 | class TimeSlotsStateController( 8 | val timeSlotConfig: TimeSlotConfig = TimeSlotConfig(), 9 | eventsArrangement: EventsArrangement = EventsArrangement.MixedDirections() 10 | ) { 11 | 12 | val slotConfig = timeSlotConfig.toSlotConfig() 13 | val slotScale = slotConfig.slotScale 14 | val slotHeight = slotConfig.slotHeight 15 | val slotUnit = 1.0F / slotScale 16 | val firstSlotIndex = (slotScale * slotConfig.initialSlotValue.toInt()) 17 | private val lastSlotIndex = (slotConfig.lastSlotValue * slotScale).toInt() 18 | 19 | internal val decimalSlotsBaseLayoutStateController = DecimalSlotsBaseLayoutStateController( 20 | decimalSlotConfig = slotConfig, 21 | slots = createSlots(firstSlotIndex, lastSlotIndex), 22 | eventsArrangement = eventsArrangement 23 | ) 24 | 25 | val timeSlotsDataUpdater = TimeSlotsDataUpdater( 26 | decimalSlotsBaseLayoutStateController = decimalSlotsBaseLayoutStateController 27 | ) 28 | 29 | fun createSlots( 30 | firstSlotIndex: Int, 31 | lastSlotIndex: Int 32 | ): List { 33 | val slots = mutableListOf() 34 | for (i in firstSlotIndex..lastSlotIndex) { 35 | val slotStartValue = i * slotUnit 36 | val title = fromDecimalValueToTimeText(slotStartValue, timeSlotConfig.useAmPm) 37 | 38 | slots.add( 39 | Slot( 40 | title = title, 41 | startValue = slotStartValue, 42 | endValue = slotStartValue + slotUnit 43 | ) 44 | ) 45 | } 46 | return slots 47 | } 48 | 49 | fun getTimeSlotsData(): Map> { 50 | val result = mutableMapOf>() 51 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted.forEach { entry -> 52 | result.put( 53 | key = entry.key.toLocalTimeSlot(), 54 | value = entry.value.map { it.toLocalTimeEvent() } 55 | ) 56 | } 57 | return result 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/epgslots/EpgSlotsStateController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.epgslots 2 | 3 | import androidx.compose.runtime.mutableStateOf 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 5 | import com.macaosoftware.ui.dailyagenda.timeslots.fromDecimalValueToTimeText 6 | import com.macaosoftware.ui.dailyagenda.timeslots.toSlotConfig 7 | 8 | class EpgSlotsStateController( 9 | val epgChannelSlotConfig: EpgChannelSlotConfig 10 | ) { 11 | 12 | val timeSlotConfig = epgChannelSlotConfig.timeSlotConfig 13 | val slotConfig = timeSlotConfig.toSlotConfig() 14 | val slotScale = slotConfig.slotScale 15 | val slotHeight = slotConfig.slotHeight 16 | val slotUnit = 1.0F / slotScale 17 | val firstSlotIndex = (slotScale * slotConfig.initialSlotValue.toInt()) 18 | private val lastSlotIndex = (slotConfig.lastSlotValue * slotScale).toInt() 19 | internal val epgChannelHeight = 20 | ((slotConfig.lastSlotValue - slotConfig.initialSlotValue) * slotScale * slotHeight) + 21 | epgChannelSlotConfig.topHeaderHeight 22 | 23 | private val slots = createSlots(firstSlotIndex, lastSlotIndex) 24 | 25 | internal val epgChannels: MutableList = mutableListOf() 26 | 27 | val state = mutableStateOf(value = null) 28 | 29 | val epgSlotsDataUpdater = EpgSlotsDataUpdater( 30 | epgSlotsStateController = this 31 | ) 32 | 33 | fun createSlots( 34 | firstSlotIndex: Int, 35 | lastSlotIndex: Int 36 | ): List { 37 | val slots = mutableListOf() 38 | for (i in firstSlotIndex..lastSlotIndex) { 39 | val slotStartValue = i * slotUnit 40 | val title = fromDecimalValueToTimeText(slotStartValue, timeSlotConfig.useAmPm) 41 | slots.add( 42 | Slot( 43 | title = title, 44 | startValue = slotStartValue, 45 | endValue = slotStartValue + slotUnit 46 | ) 47 | ) 48 | } 49 | return slots 50 | } 51 | 52 | private fun computeNextState(): EpgSlotsState { 53 | return EpgSlotsState( 54 | slots = slots, 55 | epgChannels = epgChannels, 56 | epgChannelSlotConfig = epgChannelSlotConfig, 57 | epgChannelHeight = epgChannelHeight 58 | ) 59 | } 60 | 61 | internal fun updateState() { 62 | state.value = computeNextState() 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsBaseLayoutState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.ui.unit.Dp 4 | import androidx.compose.ui.unit.dp 5 | import kotlin.uuid.ExperimentalUuidApi 6 | import kotlin.uuid.Uuid 7 | 8 | internal data class DecimalSlotsBaseLayoutState( 9 | val slots: List, 10 | val slotToDecimalEventMap: Map>, 11 | val slotInfoMap: Map, 12 | val maxColumns: Int, 13 | val config: Config 14 | ) 15 | 16 | @ConsistentCopyVisibility 17 | data class Slot internal constructor( 18 | val title: String, 19 | val startValue: Float, 20 | val endValue: Float 21 | ) 22 | 23 | // TODO: Change var with val SlotInfo.copy() 24 | @ConsistentCopyVisibility 25 | data class SlotInfo internal constructor( 26 | var numberOfContainingEvents: Int, 27 | var numberOfColumnsLeft: Int, 28 | var numberOfColumnsRight: Int, 29 | ) { 30 | fun getTotalColumnSpans() = numberOfColumnsLeft + numberOfColumnsRight 31 | } 32 | 33 | @OptIn(ExperimentalUuidApi::class) 34 | data class DecimalEvent( 35 | val uuid: Uuid, 36 | val title: String, 37 | val description: String, 38 | val startValue: Float, 39 | val endValue: Float 40 | ) { 41 | 42 | override fun equals(other: Any?): Boolean { 43 | if (this === other) return true 44 | if (other !is DecimalEvent) return false 45 | return other.uuid == uuid 46 | } 47 | 48 | override fun hashCode(): Int { 49 | return uuid.hashCode() 50 | } 51 | } 52 | 53 | internal class OffsetInfo( 54 | var leftStartOffset: Dp = 0.dp, 55 | var leftAccumulated: Dp = 0.dp, 56 | var rightStartOffset: Dp = 0.dp, 57 | var rightAccumulated: Dp = 0.dp 58 | ) { 59 | fun getTotalLeftOffset() = leftStartOffset + leftAccumulated 60 | 61 | fun getTotalRightOffset() = rightStartOffset + rightAccumulated 62 | } 63 | 64 | data class DecimalSlotConfig( 65 | val initialSlotValue: Float = 0.0F, 66 | val lastSlotValue: Float = 20.0F, 67 | val slotScale: Int = 2, 68 | val slotHeight: Int = 64, 69 | val timelineLeftPadding: Int = 72 70 | ) 71 | 72 | @ConsistentCopyVisibility 73 | data class Config internal constructor( 74 | val eventsArrangement: EventsArrangement = EventsArrangement.MixedDirections(), 75 | val initialSlotValue: Float, 76 | val lastSlotValue: Float, 77 | val slotScale: Int, 78 | val slotHeight: Int, 79 | val timelineLeftPadding: Int 80 | ) 81 | 82 | sealed interface EventsArrangement { 83 | 84 | class MixedDirections( 85 | val eventWidthType: EventWidthType = EventWidthType.FixedSizeFillLastEvent, 86 | ) : EventsArrangement 87 | 88 | class LeftToRight( 89 | val lastEventFillRow: Boolean = true 90 | ) : EventsArrangement 91 | 92 | class RightToLeft( 93 | val lastEventFillRow: Boolean = true 94 | ) : EventsArrangement 95 | } 96 | 97 | enum class EventWidthType { MaxVariableSize, FixedSize, FixedSizeFillLastEvent } 98 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /daily-agenda-view/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.androidLibrary 2 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | alias(libs.plugins.kotlinMultiplatform) 7 | alias(libs.plugins.androidKmpLibrary) 8 | alias(libs.plugins.composeMultiplatform) 9 | alias(libs.plugins.composeCompiler) 10 | alias(libs.plugins.vanniktech.mavenPublish) 11 | } 12 | 13 | kotlin { 14 | androidLibrary { 15 | namespace = "com.macaosoftware.ui.dailyagenda" 16 | compileSdk = libs.versions.android.compileSdk.get().toInt() 17 | minSdk = libs.versions.android.minSdk.get().toInt() 18 | 19 | withJava() // enable java compilation support 20 | withHostTestBuilder {}.configure {} 21 | withDeviceTestBuilder { 22 | sourceSetTreeName = "test" 23 | } 24 | 25 | compilations.configureEach { 26 | compilerOptions.configure { 27 | jvmTarget.set( 28 | JvmTarget.JVM_17 29 | ) 30 | } 31 | } 32 | } 33 | 34 | listOf( 35 | iosArm64(), 36 | iosSimulatorArm64() 37 | ).forEach { iosTarget -> 38 | iosTarget.binaries.framework { 39 | baseName = "ComposeApp" 40 | isStatic = true 41 | } 42 | } 43 | 44 | jvm() 45 | 46 | js { 47 | browser() 48 | } 49 | 50 | @OptIn(ExperimentalWasmDsl::class) 51 | wasmJs { 52 | browser() 53 | } 54 | 55 | sourceSets { 56 | commonMain.dependencies { 57 | implementation(compose.runtime) 58 | implementation(compose.ui) 59 | implementation(compose.foundation) 60 | implementation(compose.material3) 61 | implementation(compose.components.uiToolingPreview) 62 | implementation(libs.kotlinx.datetime) 63 | } 64 | commonTest.dependencies { 65 | implementation(libs.kotlin.test) 66 | } 67 | androidMain.dependencies { 68 | implementation(compose.preview) 69 | } 70 | jvmMain.dependencies { 71 | implementation(compose.desktop.currentOs) 72 | implementation(libs.kotlinx.coroutinesSwing) 73 | } 74 | } 75 | } 76 | 77 | mavenPublishing { 78 | publishToMavenCentral(automaticRelease = true) 79 | signAllPublications() 80 | 81 | coordinates( 82 | groupId = "io.github.pablichjenkov", 83 | artifactId = "daily-agenda-view", 84 | version = libs.versions.dailyAgendaView.get() 85 | ) 86 | 87 | pom { 88 | val projectGitUrl = "https://github.com/pablichjenkov/daily-agenda-view" 89 | name = "daily-agenda-view" 90 | description = "Ui component that displays daily events along a vertical timeline" 91 | inceptionYear = "2025" 92 | url = projectGitUrl 93 | licenses { 94 | license { 95 | name.set("The Unlicense") 96 | url.set("https://unlicense.org") 97 | } 98 | } 99 | developers { 100 | developer { 101 | id = "pablichjenkov" 102 | } 103 | } 104 | scm { 105 | connection.set("scm:git:$projectGitUrl") 106 | developerConnection.set("scm:git:$projectGitUrl") 107 | url.set(projectGitUrl) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | alias(libs.plugins.kotlinMultiplatform) 7 | alias(libs.plugins.composeMultiplatform) 8 | alias(libs.plugins.composeCompiler) 9 | alias(libs.plugins.androidApplication) 10 | alias(libs.plugins.composeHotReload) 11 | } 12 | 13 | kotlin { 14 | androidTarget { 15 | compilerOptions { 16 | jvmTarget.set(JvmTarget.JVM_17) 17 | } 18 | } 19 | 20 | listOf( 21 | iosArm64(), 22 | iosSimulatorArm64() 23 | ).forEach { iosTarget -> 24 | iosTarget.binaries.framework { 25 | baseName = "ComposeApp" 26 | isStatic = true 27 | } 28 | } 29 | 30 | jvm() 31 | 32 | js { 33 | browser() 34 | binaries.executable() 35 | } 36 | 37 | @OptIn(ExperimentalWasmDsl::class) 38 | wasmJs { 39 | browser() 40 | binaries.executable() 41 | } 42 | 43 | sourceSets { 44 | commonMain.dependencies { 45 | // implementation(projects.dailyAgendaView); not working on github actions 46 | implementation(project(":daily-agenda-view")) 47 | implementation(compose.runtime) 48 | implementation(compose.ui) 49 | implementation(compose.foundation) 50 | implementation(compose.material3) 51 | implementation(compose.components.resources) 52 | implementation(compose.components.uiToolingPreview) 53 | implementation(libs.androidx.lifecycle.viewmodelCompose) 54 | implementation(libs.androidx.lifecycle.runtimeCompose) 55 | implementation(libs.kotlinx.datetime) 56 | } 57 | commonTest.dependencies { 58 | implementation(libs.kotlin.test) 59 | } 60 | androidMain.dependencies { 61 | implementation(compose.preview) 62 | implementation(libs.androidx.activity.compose) 63 | } 64 | jvmMain.dependencies { 65 | implementation(compose.desktop.currentOs) 66 | implementation(libs.kotlinx.coroutinesSwing) 67 | } 68 | } 69 | } 70 | 71 | android { 72 | namespace = "com.macaosoftware.ui" 73 | compileSdk = libs.versions.android.compileSdk.get().toInt() 74 | 75 | defaultConfig { 76 | applicationId = "com.macaosoftware.ui" 77 | minSdk = libs.versions.android.minSdk.get().toInt() 78 | targetSdk = libs.versions.android.targetSdk.get().toInt() 79 | versionCode = 1 80 | versionName = "1.0" 81 | } 82 | packaging { 83 | resources { 84 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 85 | } 86 | } 87 | buildTypes { 88 | getByName("release") { 89 | isMinifyEnabled = false 90 | } 91 | } 92 | compileOptions { 93 | sourceCompatibility = JavaVersion.VERSION_17 94 | targetCompatibility = JavaVersion.VERSION_17 95 | } 96 | } 97 | 98 | dependencies { 99 | debugImplementation(compose.uiTooling) 100 | } 101 | 102 | compose.desktop { 103 | application { 104 | mainClass = "com.macaosoftware.ui.MainKt" 105 | 106 | nativeDistributions { 107 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 108 | packageName = "com.macaosoftware.ui" 109 | packageVersion = "1.0.0" 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/DayScheduleAppActionsBottomView.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.wrapContentSize 8 | import androidx.compose.material3.DropdownMenu 9 | import androidx.compose.material3.DropdownMenuItem 10 | import androidx.compose.material3.FloatingActionButton 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.unit.dp 20 | 21 | @Composable 22 | internal fun BoxScope.DayScheduleAppActionsBottomView( 23 | slotsViewType: SlotsViewType, 24 | uiActionListener: DayScheduleAppViewModel.UiActionListener 25 | ) { 26 | Row( 27 | modifier = Modifier 28 | .align(Alignment.BottomEnd) 29 | .padding(8.dp) 30 | .wrapContentSize(), 31 | horizontalArrangement = Arrangement.spacedBy(16.dp) 32 | ) { 33 | FloatingActionButton( 34 | modifier = Modifier.wrapContentSize(), 35 | onClick = { 36 | uiActionListener.showAddEventForm(slotsViewType) 37 | } 38 | ) { 39 | Text( 40 | modifier = Modifier.padding(16.dp), 41 | text = "Add Event" 42 | ) 43 | } 44 | var expanded by remember { mutableStateOf(false) } 45 | FloatingActionButton( 46 | modifier = Modifier.wrapContentSize(), 47 | onClick = { 48 | expanded = true 49 | } 50 | ) { 51 | DropdownMenu( 52 | expanded = expanded, 53 | onDismissRequest = { 54 | expanded = false 55 | } 56 | ) { 57 | DropdownMenuItem( 58 | text = { Text("Timeline Axis") }, 59 | onClick = { 60 | expanded = false 61 | uiActionListener.toggleAxisType(SlotsViewType.Timeline) 62 | } 63 | ) 64 | DropdownMenuItem( 65 | text = { Text("Decimal Axis") }, 66 | onClick = { 67 | expanded = false 68 | uiActionListener.toggleAxisType(SlotsViewType.Decimal) 69 | } 70 | ) 71 | DropdownMenuItem( 72 | text = { Text("Epg Axis") }, 73 | onClick = { 74 | expanded = false 75 | uiActionListener.toggleAxisType(SlotsViewType.Epg) 76 | } 77 | ) 78 | } 79 | val axisText = when (slotsViewType) { 80 | SlotsViewType.Decimal -> { 81 | "Decimal Axis" 82 | } 83 | 84 | SlotsViewType.Timeline -> { 85 | "Timeline Axis" 86 | } 87 | 88 | SlotsViewType.Epg -> { 89 | "Epg Axis" 90 | } 91 | } 92 | Text( 93 | modifier = Modifier.padding(16.dp), 94 | text = axisText 95 | ) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/epgslots/EpgSlotsLayout.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.epgslots 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.offset 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.foundation.lazy.LazyRow 13 | import androidx.compose.foundation.lazy.items 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.zIndex 22 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 23 | import com.macaosoftware.ui.dailyagenda.timeslots.fromLocalTimeToValue 24 | import com.macaosoftware.ui.dailyagenda.timeslots.toSlotConfig 25 | 26 | @Composable 27 | internal fun EpgSlotsLayout( 28 | epgSlotsState: EpgSlotsState, 29 | scrollOffset: Dp, 30 | eventContentProvider: @Composable (localTimeEvent: LocalTimeEvent) -> Unit 31 | ) { 32 | val timelineLeftPadding = epgSlotsState.epgChannelSlotConfig.timeSlotConfig.timelineLeftPadding 33 | val channelWidth = epgSlotsState.epgChannelSlotConfig.channelWidth 34 | val topHeaderHeight = epgSlotsState.epgChannelSlotConfig.topHeaderHeight 35 | val totalHeight = epgSlotsState.epgChannelHeight 36 | LazyRow( 37 | modifier = Modifier.height(height = totalHeight.dp) 38 | .padding(start = timelineLeftPadding.dp) 39 | ) { 40 | items( 41 | items = epgSlotsState.epgChannels, 42 | key = { epgChannel -> 43 | epgChannel.name 44 | } 45 | ) { epgChannel -> 46 | Column( 47 | modifier = Modifier.width(width = channelWidth.dp) 48 | .padding(all = 1.dp).background(color = Color.Gray.copy(alpha = 0.5f)) 49 | ) { 50 | Box( 51 | Modifier.height(height = topHeaderHeight.dp) 52 | .width(width = epgSlotsState.epgChannelSlotConfig.channelWidth.dp) 53 | .offset(y = scrollOffset) 54 | .padding(all = 1.dp) 55 | .background(color = Color.Gray) 56 | .zIndex(zIndex = 1f) 57 | ) { 58 | Text( 59 | modifier = Modifier.align(Alignment.Center), 60 | text = epgChannel.name 61 | ) 62 | } 63 | ChannelColumn( 64 | epgChannel = epgChannel, 65 | epgChannelSlotConfig = epgSlotsState.epgChannelSlotConfig, 66 | eventContentProvider = eventContentProvider 67 | ) 68 | } 69 | } 70 | } 71 | } 72 | 73 | @Composable 74 | fun ChannelColumn( 75 | epgChannel: EpgChannel, 76 | epgChannelSlotConfig: EpgChannelSlotConfig, 77 | eventContentProvider: @Composable (localTimeEvent: LocalTimeEvent) -> Unit 78 | ) { 79 | val slotsConfig = epgChannelSlotConfig.timeSlotConfig.toSlotConfig() 80 | val initialSlotValue = slotsConfig.initialSlotValue 81 | Box(modifier = Modifier.fillMaxSize()) { 82 | val scaleMultiplier = slotsConfig.slotHeight * slotsConfig.slotScale 83 | epgChannel.events.forEach { localTimeEvent -> 84 | val startValue = fromLocalTimeToValue(localTimeEvent.startTime) 85 | val endValue = fromLocalTimeToValue(localTimeEvent.endTime) 86 | val offsetY = (startValue - initialSlotValue) * scaleMultiplier 87 | val programHeight = (endValue - startValue) * scaleMultiplier 88 | Box( 89 | modifier = Modifier.offset(y = offsetY.dp) 90 | .fillMaxWidth() 91 | .height(height = programHeight.dp) 92 | 93 | ) { eventContentProvider.invoke(localTimeEvent) } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/timeslots/TimeSlotsState.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.timeslots 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.Config 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalEvent 5 | import com.macaosoftware.ui.dailyagenda.decimalslots.Slot 6 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotConfig 7 | import kotlinx.datetime.LocalTime 8 | import kotlin.uuid.ExperimentalUuidApi 9 | import kotlin.uuid.Uuid 10 | 11 | private const val HOUR_AM = "AM" 12 | private const val HOUR_PM = "PM" 13 | private const val MINUTES_IN_ONE_HOUR = 60 14 | 15 | @OptIn(ExperimentalUuidApi::class) 16 | data class LocalTimeEvent( 17 | val uuid: Uuid, 18 | val title: String, 19 | val description: String, 20 | val startTime: LocalTime, 21 | val endTime: LocalTime 22 | ) { 23 | 24 | override fun equals(other: Any?): Boolean { 25 | if (this === other) return true 26 | if (other !is LocalTimeEvent) return false 27 | return other.uuid == uuid 28 | } 29 | 30 | override fun hashCode(): Int { 31 | return uuid.hashCode() 32 | } 33 | } 34 | 35 | data class LocalTimeSlot( 36 | val title: String, 37 | val startTime: LocalTime, 38 | val endTime: LocalTime 39 | ) 40 | 41 | @OptIn(ExperimentalUuidApi::class) 42 | fun DecimalEvent.toLocalTimeEvent(): LocalTimeEvent { 43 | val startLocalTime = fromValueToLocalTime(value = startValue) 44 | val endLocalTime = fromValueToLocalTime(value = endValue) 45 | return LocalTimeEvent( 46 | uuid = uuid, 47 | title = title, 48 | description = description, 49 | startTime = startLocalTime, 50 | endTime = endLocalTime 51 | ) 52 | } 53 | 54 | @OptIn(ExperimentalUuidApi::class) 55 | fun LocalTimeEvent.toDecimalSegment(): DecimalEvent { 56 | val startTimeValue = fromLocalTimeToValue(localTime = startTime) 57 | val endTimeValue = fromLocalTimeToValue(localTime = endTime) 58 | return DecimalEvent( 59 | uuid = uuid, 60 | title = title, 61 | description = description, 62 | startValue = startTimeValue, 63 | endValue = endTimeValue 64 | ) 65 | } 66 | 67 | fun Slot.toLocalTimeSlot(): LocalTimeSlot { 68 | return LocalTimeSlot( 69 | title = title, 70 | startTime = fromValueToLocalTime(value = startValue), 71 | endTime = fromValueToLocalTime(value = endValue), 72 | ) 73 | } 74 | 75 | fun LocalTimeSlot.toSlot(): Slot { 76 | return Slot( 77 | title = title, 78 | startValue = fromLocalTimeToValue(localTime = startTime), 79 | endValue = fromLocalTimeToValue(localTime = endTime) 80 | ) 81 | } 82 | 83 | data class TimeSlotConfig( 84 | val startSlotTime: LocalTime = LocalTime(0, 0), 85 | val endSlotTime: LocalTime = LocalTime(23, 59), 86 | val useAmPm: Boolean = true, 87 | val slotScale: Int = 2, 88 | val slotHeight: Int = 48, 89 | val timelineLeftPadding: Int = 72 90 | ) 91 | 92 | internal fun fromLocalTimeToValue(localTime: LocalTime): Float { 93 | val minuteFraction = localTime.minute.toFloat() / MINUTES_IN_ONE_HOUR 94 | return localTime.hour.toFloat() + minuteFraction 95 | } 96 | 97 | fun fromValueToLocalTime(value: Float): LocalTime { 98 | val remaining = value % 1 99 | val minutes = (remaining * MINUTES_IN_ONE_HOUR).toInt() 100 | val hours = value.toInt() 101 | return LocalTime(hour = hours, minute = minutes) 102 | } 103 | 104 | fun fromDecimalValueToTimeText( 105 | slotStartValue: Float, 106 | useAmPm: Boolean 107 | ): String { 108 | val remaining = slotStartValue % 1 109 | val minutes = (remaining * MINUTES_IN_ONE_HOUR).toInt() 110 | val minutesTwoDigitFormat = if (minutes > 9) { 111 | minutes.toString() 112 | } else { 113 | "0$minutes" 114 | } 115 | 116 | if (!useAmPm) { 117 | val units = slotStartValue.toInt() 118 | return "$units:$minutesTwoDigitFormat" 119 | } 120 | 121 | val slotStartValueInt = slotStartValue.toInt() 122 | val hourUnits = slotStartValueInt % 12 123 | val hourUnitsFormatted = hourUnits.takeIf { it != 0 } ?: 12 124 | val amPmSuffix = if (slotStartValue < 12F) HOUR_AM else HOUR_PM 125 | return "$hourUnitsFormatted:$minutesTwoDigitFormat:$amPmSuffix" 126 | } 127 | 128 | fun TimeSlotConfig.toSlotConfig(): DecimalSlotConfig { 129 | return DecimalSlotConfig( 130 | initialSlotValue = fromLocalTimeToValue(startSlotTime), 131 | lastSlotValue = fromLocalTimeToValue(endSlotTime), 132 | slotScale = slotScale, 133 | slotHeight = slotHeight, 134 | timelineLeftPadding = timelineLeftPadding 135 | ) 136 | } 137 | 138 | fun Config.toSlotConfig(): DecimalSlotConfig { 139 | return DecimalSlotConfig( 140 | initialSlotValue = initialSlotValue, 141 | lastSlotValue = lastSlotValue, 142 | slotScale = slotScale, 143 | slotHeight = slotHeight, 144 | timelineLeftPadding = timelineLeftPadding 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /iosApp/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swift,xcode,objective-c,osx 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode,objective-c,osx 3 | 4 | ### Objective-C ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | *.o 29 | *.LinkFileList 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | 34 | ## App packaging 35 | *.ipa 36 | *.dSYM.zip 37 | *.dSYM 38 | 39 | # CocoaPods 40 | # We recommend against adding the Pods directory to your .gitignore. However 41 | # you should judge for yourself, the pros and cons are mentioned at: 42 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 43 | # Pods/ 44 | # Add this line if you want to avoid checking in source code from the Xcode workspace 45 | # *.xcworkspace 46 | 47 | # Carthage 48 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 49 | # Carthage/Checkouts 50 | 51 | Carthage/Build/ 52 | 53 | # fastlane 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | # Code Injection 65 | # After new code Injection tools there's a generated folder /iOSInjectionProject 66 | # https://github.com/johnno1962/injectionforxcode 67 | 68 | iOSInjectionProject/ 69 | 70 | ### Objective-C Patch ### 71 | 72 | ### OSX ### 73 | # General 74 | .DS_Store 75 | .AppleDouble 76 | .LSOverride 77 | 78 | # Icon must end with two \r 79 | Icon 80 | 81 | 82 | # Thumbnails 83 | ._* 84 | 85 | # Files that might appear in the root of a volume 86 | .DocumentRevisions-V100 87 | .fseventsd 88 | .Spotlight-V100 89 | .TemporaryItems 90 | .Trashes 91 | .VolumeIcon.icns 92 | .com.apple.timemachine.donotpresent 93 | 94 | # Directories potentially created on remote AFP share 95 | .AppleDB 96 | .AppleDesktop 97 | Network Trash Folder 98 | Temporary Items 99 | .apdisk 100 | 101 | ### Swift ### 102 | # Xcode 103 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 104 | 105 | 106 | 107 | 108 | 109 | 110 | ## Playgrounds 111 | timeline.xctimeline 112 | playground.xcworkspace 113 | 114 | # Swift Package Manager 115 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 116 | # Packages/ 117 | # Package.pins 118 | # Package.resolved 119 | # *.xcodeproj 120 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 121 | # hence it is not needed unless you have added a package configuration file to your project 122 | # .swiftpm 123 | 124 | .build/ 125 | 126 | # CocoaPods 127 | # We recommend against adding the Pods directory to your .gitignore. However 128 | # you should judge for yourself, the pros and cons are mentioned at: 129 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 130 | # Pods/ 131 | # Add this line if you want to avoid checking in source code from the Xcode workspace 132 | # *.xcworkspace 133 | 134 | # Carthage 135 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 136 | # Carthage/Checkouts 137 | 138 | 139 | # Accio dependency management 140 | Dependencies/ 141 | .accio/ 142 | 143 | # fastlane 144 | # It is recommended to not store the screenshots in the git repo. 145 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 146 | # For more information about the recommended setup visit: 147 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 148 | 149 | 150 | # Code Injection 151 | # After new code Injection tools there's a generated folder /iOSInjectionProject 152 | # https://github.com/johnno1962/injectionforxcode 153 | 154 | 155 | ### Xcode ### 156 | 157 | ## Xcode 8 and earlier 158 | 159 | ### Xcode Patch ### 160 | *.xcodeproj/* 161 | !*.xcodeproj/project.pbxproj 162 | !*.xcodeproj/xcshareddata/ 163 | !*.xcodeproj/project.xcworkspace/ 164 | !*.xcworkspace/contents.xcworkspacedata 165 | /*.gcno 166 | **/xcshareddata/WorkspaceSettings.xcsettings 167 | 168 | # Xcode per-user config 169 | *.mode1 170 | *.perspective 171 | *.xcworkspace 172 | xcuserdata 173 | 174 | # End of https://www.toptal.com/developers/gitignore/api/swift,xcode,objective-c,osx -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsBaseLayoutStateController.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.runtime.mutableStateOf 4 | import kotlin.math.abs 5 | 6 | class DecimalSlotsBaseLayoutStateController( 7 | val slots: List, 8 | decimalSlotConfig: DecimalSlotConfig, 9 | private val eventsArrangement: EventsArrangement 10 | ) { 11 | 12 | val slotUnit = 1.0F / decimalSlotConfig.slotScale 13 | 14 | private val config = Config( 15 | eventsArrangement = eventsArrangement, 16 | initialSlotValue = slots[0].startValue, 17 | lastSlotValue = slots[slots.lastIndex].startValue, 18 | slotScale = decimalSlotConfig.slotScale, 19 | slotHeight = decimalSlotConfig.slotHeight, 20 | timelineLeftPadding = 72 21 | ) 22 | 23 | internal val slotToDecimalEventMapSorted: MutableMap> = mutableMapOf() 24 | 25 | internal val state = mutableStateOf(value = null) 26 | 27 | init { 28 | slots.forEach { slot -> slotToDecimalEventMapSorted[slot] = mutableListOf() } 29 | } 30 | 31 | fun getSlotForValue(startValue: Float): Slot { 32 | return slots.find { abs(x = startValue - it.startValue) < slotUnit } 33 | ?: error("startTime: $startValue must be between ${config.initialSlotValue} and ${config.lastSlotValue}") 34 | } 35 | 36 | private fun computeNextState(): DecimalSlotsBaseLayoutState { 37 | val result = computeSlotInfo( 38 | slots = slots, 39 | slotToDecimalEventMap = slotToDecimalEventMapSorted, 40 | config = config 41 | ) 42 | return DecimalSlotsBaseLayoutState( 43 | slots = slots, 44 | slotToDecimalEventMap = slotToDecimalEventMapSorted, 45 | slotInfoMap = result.slotInfoMap, 46 | maxColumns = result.maxColumns, 47 | config = config 48 | ) 49 | } 50 | 51 | internal fun updateState() { 52 | state.value = computeNextState() 53 | } 54 | 55 | /** 56 | * Computes the amount of events that are contained by the given slop. The events will be the 57 | * sum of earlier slots events plus this slot's events. 58 | * */ 59 | private fun computeSlotInfo( 60 | slots: List, 61 | slotToDecimalEventMap: Map>, 62 | config: Config 63 | ): ComputeSlotInfoResult { 64 | 65 | val slotInfoMap = mutableMapOf() 66 | var maxColumns = 1 67 | 68 | var isLeftIter = when (config.eventsArrangement) { 69 | is EventsArrangement.LeftToRight, 70 | is EventsArrangement.MixedDirections -> true 71 | 72 | is EventsArrangement.RightToLeft -> false 73 | } 74 | 75 | slots.forEachIndexed { idx, slotIter -> 76 | 77 | val slotLeftColumnMap = mutableMapOf() 78 | val slotRightColumnMap = mutableMapOf() 79 | 80 | slotToDecimalEventMap[slotIter]?.forEachIndexed { idx, event -> 81 | 82 | val eventSlot = getSlotForValue(startValue = event.startValue) 83 | 84 | getSlotsIncludeStartSlot(event, eventSlot, slots).forEach { containingSlot -> 85 | // Update column mark 86 | if (isLeftIter) { 87 | slotLeftColumnMap.put(containingSlot, idx + 1) 88 | } else { 89 | slotRightColumnMap.put(containingSlot, idx + 1) 90 | } 91 | 92 | // Update events counter 93 | val currentSlotInfo = 94 | slotInfoMap.getOrPut(containingSlot) { SlotInfo(0, 0, 0) } 95 | currentSlotInfo.numberOfContainingEvents++ 96 | } 97 | } 98 | 99 | val iterSlotInfo = slotInfoMap.getOrPut(slotIter) { SlotInfo(0, 0, 0) } 100 | if (isLeftIter) { 101 | slotLeftColumnMap.entries.forEach { entry -> 102 | if (entry.key.title != slotIter.title) { 103 | val entrySlotInfo = slotInfoMap.getOrPut(entry.key) { SlotInfo(0, 0, 0) } 104 | entrySlotInfo.numberOfColumnsLeft = 105 | iterSlotInfo.numberOfColumnsLeft + entry.value 106 | } 107 | } 108 | iterSlotInfo.numberOfColumnsLeft += slotLeftColumnMap.entries.firstOrNull()?.value 109 | ?: 0 110 | } else { 111 | slotRightColumnMap.entries.forEach { entry -> 112 | if (entry.key.title != slotIter.title) { 113 | val entrySlotInfo = slotInfoMap.getOrPut(entry.key) { SlotInfo(0, 0, 0) } 114 | entrySlotInfo.numberOfColumnsRight = 115 | iterSlotInfo.numberOfColumnsRight + entry.value 116 | } 117 | } 118 | iterSlotInfo.numberOfColumnsRight += slotRightColumnMap.entries.firstOrNull()?.value 119 | ?: 0 120 | } 121 | 122 | // In the case of eventsArrangement == EventsArrangement.MixedDirections, then 123 | // lets change the layout direction. 124 | if (config.eventsArrangement is EventsArrangement.MixedDirections) { 125 | isLeftIter = !isLeftIter 126 | } 127 | } 128 | 129 | slotInfoMap.entries.fold(1) { acc, entry -> 130 | val slotColumns = entry.value.getTotalColumnSpans() 131 | if (slotColumns > maxColumns) maxColumns = slotColumns 132 | maxColumns 133 | } 134 | println("DailyAgendaState: maxColumns: $maxColumns") 135 | return ComputeSlotInfoResult(slotInfoMap, maxColumns) 136 | } 137 | 138 | } 139 | 140 | private class ComputeSlotInfoResult( 141 | val slotInfoMap: Map, 142 | val maxColumns: Int 143 | ) 144 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsDataUpdater.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import kotlin.uuid.ExperimentalUuidApi 4 | import kotlin.uuid.Uuid 5 | 6 | open class DecimalSlotsDataUpdater internal constructor( 7 | val decimalSlotsBaseLayoutStateController: DecimalSlotsBaseLayoutStateController 8 | ) { 9 | 10 | private var isListOperation = false 11 | private val slotToDecimalEventMapSortedTemp: MutableMap> = 12 | mutableMapOf() 13 | 14 | @OptIn(ExperimentalUuidApi::class) 15 | fun addDecimalEvent( 16 | uuid: Uuid = Uuid.random(), 17 | title: String, 18 | description: String, 19 | startValue: Float, 20 | endValue: Float, 21 | ): Boolean = addDecimalEvent( 22 | DecimalEvent( 23 | uuid = uuid, 24 | startValue = startValue, 25 | endValue = endValue, 26 | title = title, 27 | description = description 28 | ) 29 | ) 30 | 31 | @OptIn(ExperimentalUuidApi::class) 32 | fun addDecimalEvent(decimalEvent: DecimalEvent): Boolean { 33 | val eventSlot = 34 | decimalSlotsBaseLayoutStateController.getSlotForValue(startValue = decimalEvent.startValue) 35 | val siblingEvents = 36 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted[eventSlot] ?: mutableListOf() 37 | 38 | var insertionIndex = 0 39 | for (idx in siblingEvents.lastIndex downTo 0) { 40 | if (siblingEvents[idx].endValue >= decimalEvent.endValue) { 41 | insertionIndex = idx + 1; break 42 | } 43 | } 44 | siblingEvents.add(insertionIndex, decimalEvent) 45 | return true 46 | } 47 | 48 | fun addDecimalEventList(startValue: Float, segments: List) { 49 | isListOperation = true 50 | val slot = decimalSlotsBaseLayoutStateController.getSlotForValue(startValue = startValue) 51 | 52 | if (slotToDecimalEventMapSortedTemp.contains(slot)) { 53 | slotToDecimalEventMapSortedTemp[slot]!!.addAll(elements = segments) 54 | } else { 55 | slotToDecimalEventMapSortedTemp.put(slot, segments.toMutableList()) 56 | } 57 | } 58 | 59 | fun traverseSlotsForDecimalEvent(predicate: (DecimalEvent) -> Boolean): SlotTraversalResult? { 60 | var decimalEventMatching: DecimalEvent? = null 61 | val entryMatching = 62 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted.entries.find { entry -> 63 | decimalEventMatching = entry.value.find { event -> predicate.invoke(event) } 64 | decimalEventMatching != null 65 | } 66 | return decimalEventMatching?.let { 67 | SlotTraversalResult(entryMatching!!, decimalEventMatching) 68 | } 69 | } 70 | 71 | fun removeDecimalEventByTittle(eventTitle: String): Boolean { 72 | val decimalEventMatchingResult = traverseSlotsForDecimalEvent { event -> 73 | eventTitle == event.title 74 | } ?: return false 75 | 76 | val entry = decimalEventMatchingResult.entry 77 | val decimalEvent = decimalEventMatchingResult.decimalEvent 78 | 79 | return entry.value.remove(decimalEvent) 80 | } 81 | 82 | fun removeDecimalEvent(decimalEvent: DecimalEvent): Boolean { 83 | val eventSlot = 84 | decimalSlotsBaseLayoutStateController.getSlotForValue(startValue = decimalEvent.startValue) 85 | val siblingEvents = 86 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted[eventSlot] ?: return false 87 | 88 | return siblingEvents.remove(decimalEvent) 89 | } 90 | 91 | internal fun commit() { 92 | 93 | if (isListOperation) { 94 | val slotToDecimalEventMapSortedMerge: MutableMap> = 95 | mutableMapOf() 96 | 97 | for (slot in decimalSlotsBaseLayoutStateController.slots) { 98 | val addedSegments: MutableList? = 99 | slotToDecimalEventMapSortedTemp[slot] 100 | val existingSegments: MutableList = 101 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted[slot]!! 102 | 103 | if (addedSegments != null) { 104 | addedSegments.addAll(existingSegments) 105 | slotToDecimalEventMapSortedMerge.put(slot, addedSegments) 106 | } else { 107 | slotToDecimalEventMapSortedMerge.put(slot, existingSegments) 108 | } 109 | } 110 | 111 | /** 112 | * Sort the events to maximize spacing when the layout runs. 113 | * */ 114 | val endTimeComparator = 115 | Comparator { decimalEvent1: DecimalEvent, decimalEvent2: DecimalEvent -> 116 | val diff = decimalEvent2.endValue - decimalEvent1.endValue 117 | when { 118 | (diff > 0F) -> 1 119 | (diff < 0F) -> -1 120 | else -> 0 121 | } 122 | } 123 | slotToDecimalEventMapSortedMerge.entries.forEach { entry -> 124 | val eventsSortedByEndTime = 125 | entry.value.sortedWith(endTimeComparator).toMutableList() 126 | decimalSlotsBaseLayoutStateController.slotToDecimalEventMapSorted.put( 127 | entry.key, 128 | eventsSortedByEndTime 129 | ) 130 | } 131 | isListOperation = false 132 | } 133 | 134 | decimalSlotsBaseLayoutStateController.updateState() 135 | } 136 | 137 | fun postUpdate(block: DecimalSlotsDataUpdater.() -> Unit) { 138 | this.block() 139 | commit() 140 | } 141 | 142 | } 143 | 144 | class SlotTraversalResult( 145 | val entry: MutableMap.MutableEntry>, 146 | val decimalEvent: DecimalEvent 147 | ) 148 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/data/TimeSlotsDataSample.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.data 2 | 3 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 4 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotsStateController 5 | import kotlinx.datetime.LocalTime 6 | import kotlin.uuid.ExperimentalUuidApi 7 | import kotlin.uuid.Uuid 8 | 9 | @OptIn(ExperimentalUuidApi::class) 10 | class TimeSlotsDataSample(timeSlotsStateController: TimeSlotsStateController) { 11 | 12 | init { 13 | timeSlotsStateController.timeSlotsDataUpdater.postUpdate { 14 | addEvent( 15 | uuid = Uuid.random(), 16 | title = "EV0", 17 | description = Constants.EmptyDescription, 18 | startTime = LocalTime(hour = 8, minute = 0), 19 | endTime = LocalTime(hour = 8, minute = 30), 20 | ) 21 | addEventList( 22 | startTime = LocalTime(hour = 8, minute = 0), 23 | events = createLocalTimeEventsFor800AM() 24 | ) 25 | addEventList( 26 | startTime = LocalTime(hour = 8, minute = 30), 27 | events = createLocalTimeEventsFor830AM() 28 | ) 29 | addEventList( 30 | startTime = LocalTime(hour = 9, minute = 0), 31 | events = createLocalTimeEventsFor900AM() 32 | ) 33 | addEventList( 34 | startTime = LocalTime(hour = 9, minute = 30), 35 | events = createLocalTimeEventsFor930AM() 36 | ) 37 | addEvent( 38 | uuid = Uuid.random(), 39 | title = "EVL", 40 | description = Constants.EmptyDescription, 41 | startTime = LocalTime(hour = 8, minute = 0), 42 | endTime = LocalTime(hour = 9, minute = 0), 43 | ) 44 | } 45 | } 46 | 47 | fun createLocalTimeEventsFor800AM(): List { 48 | return listOf( 49 | LocalTimeEvent( 50 | uuid = Uuid.random(), 51 | title = "Ev1", 52 | description = Constants.EmptyDescription, 53 | startTime = LocalTime(8, 15), 54 | endTime = LocalTime(11, 0) 55 | ), 56 | LocalTimeEvent( 57 | uuid = Uuid.random(), 58 | title = "Ev2", 59 | description = Constants.EmptyDescription, 60 | startTime = LocalTime(8, 0), 61 | endTime = LocalTime(8, 45) 62 | ), 63 | LocalTimeEvent( 64 | uuid = Uuid.random(), 65 | title = "Ev3", 66 | description = Constants.EmptyDescription, 67 | startTime = LocalTime(8, 6), 68 | endTime = LocalTime(8, 25) 69 | ), 70 | ) 71 | } 72 | 73 | fun createLocalTimeEventsFor830AM(): List { 74 | return listOf( 75 | LocalTimeEvent( 76 | uuid = Uuid.random(), 77 | title = "Ev4", 78 | description = Constants.EmptyDescription, 79 | startTime = LocalTime(8, 40), 80 | endTime = LocalTime(11, 5) 81 | ), 82 | LocalTimeEvent( 83 | uuid = Uuid.random(), 84 | title = "Ev5", 85 | description = Constants.EmptyDescription, 86 | startTime = LocalTime(8, 50), 87 | endTime = LocalTime(9, 30) 88 | ), 89 | LocalTimeEvent( 90 | uuid = Uuid.random(), 91 | title = "Ev6", 92 | description = Constants.EmptyDescription, 93 | startTime = LocalTime(8, 30), 94 | endTime = LocalTime(9, 30) 95 | ), 96 | LocalTimeEvent( 97 | uuid = Uuid.random(), 98 | title = "Ev7", 99 | description = Constants.EmptyDescription, 100 | startTime = LocalTime(8, 30), 101 | endTime = LocalTime(9, 0) 102 | ) 103 | ) 104 | } 105 | 106 | fun createLocalTimeEventsFor900AM(): List { 107 | return listOf( 108 | LocalTimeEvent( 109 | uuid = Uuid.random(), 110 | title = "Ev8", 111 | description = Constants.EmptyDescription, 112 | startTime = LocalTime(9, 0), 113 | endTime = LocalTime(9, 30) 114 | ), 115 | LocalTimeEvent( 116 | uuid = Uuid.random(), 117 | title = "Ev9", 118 | description = Constants.EmptyDescription, 119 | startTime = LocalTime(9, 12), 120 | endTime = LocalTime(10, 0) 121 | ), 122 | LocalTimeEvent( 123 | uuid = Uuid.random(), 124 | title = "Ev10", 125 | description = Constants.EmptyDescription, 126 | startTime = LocalTime(9, 15), 127 | endTime = LocalTime(10, 0) 128 | ) 129 | ) 130 | } 131 | 132 | fun createLocalTimeEventsFor930AM(): List { 133 | return listOf( 134 | LocalTimeEvent( 135 | uuid = Uuid.random(), 136 | title = "Ev11", 137 | description = Constants.EmptyDescription, 138 | startTime = LocalTime(9, 30), 139 | endTime = LocalTime(11, 0) 140 | ), 141 | LocalTimeEvent( 142 | uuid = Uuid.random(), 143 | title = "Ev12", 144 | description = Constants.EmptyDescription, 145 | startTime = LocalTime(9, 30), 146 | endTime = LocalTime(10, 30) 147 | ), 148 | LocalTimeEvent( 149 | uuid = Uuid.random(), 150 | title = "Ev13", 151 | description = Constants.EmptyDescription, 152 | startTime = LocalTime(9, 50), 153 | endTime = LocalTime(10, 25) 154 | ), 155 | LocalTimeEvent( 156 | uuid = Uuid.random(), 157 | title = "Ev14", 158 | description = Constants.EmptyDescription, 159 | startTime = LocalTime(9, 40), 160 | endTime = LocalTime(10, 0) 161 | ), 162 | LocalTimeEvent( 163 | uuid = Uuid.random(), 164 | title = "Ev15", 165 | description = Constants.EmptyDescription, 166 | startTime = LocalTime(9, 55), 167 | endTime = LocalTime(10, 12) 168 | ) 169 | ) 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/data/EpgSlotsDataSample.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.data 2 | 3 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgChannel 4 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgSlotsStateController 5 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 6 | import kotlinx.datetime.LocalTime 7 | import kotlin.uuid.ExperimentalUuidApi 8 | import kotlin.uuid.Uuid 9 | 10 | @OptIn(ExperimentalUuidApi::class) 11 | class EpgSlotsDataSample(epgSlotsStateController: EpgSlotsStateController) { 12 | 13 | init { 14 | epgSlotsStateController.epgSlotsDataUpdater.postUpdate { 15 | addChannel( 16 | EpgChannel( 17 | name = "Ch1", 18 | events = listOf( 19 | LocalTimeEvent( 20 | uuid = Uuid.random(), 21 | title = "Ev1", 22 | description = Constants.EmptyDescription, 23 | startTime = LocalTime(9, 0), 24 | endTime = LocalTime(10, 0) 25 | ), 26 | LocalTimeEvent( 27 | uuid = Uuid.random(), 28 | title = "Ev2", 29 | description = Constants.EmptyDescription, 30 | startTime = LocalTime(10, 0), 31 | endTime = LocalTime(11, 30) 32 | ) 33 | ) 34 | ) 35 | ) 36 | 37 | addChannel( 38 | EpgChannel( 39 | name = "Ch2", 40 | events = listOf( 41 | LocalTimeEvent( 42 | uuid = Uuid.random(), 43 | title = "Ev3", 44 | description = Constants.EmptyDescription, 45 | startTime = LocalTime(9, 30), 46 | endTime = LocalTime(10, 15) 47 | ), 48 | LocalTimeEvent( 49 | uuid = Uuid.random(), 50 | title = "Ev4", 51 | description = Constants.EmptyDescription, 52 | startTime = LocalTime(10, 30), 53 | endTime = LocalTime(11, 0) 54 | ) 55 | ) 56 | ) 57 | ) 58 | addChannel( 59 | EpgChannel( 60 | name = "Ch3", 61 | events = listOf( 62 | LocalTimeEvent( 63 | uuid = Uuid.random(), 64 | title = "Ev5", 65 | description = Constants.EmptyDescription, 66 | startTime = LocalTime(10, 0), 67 | endTime = LocalTime(11, 0) 68 | ), 69 | LocalTimeEvent( 70 | uuid = Uuid.random(), 71 | title = "Ev6", 72 | description = Constants.EmptyDescription, 73 | startTime = LocalTime(12, 0), 74 | endTime = LocalTime(13, 30) 75 | ) 76 | ) 77 | ) 78 | ) 79 | addChannel( 80 | EpgChannel( 81 | name = "Ch4", 82 | events = listOf( 83 | LocalTimeEvent( 84 | uuid = Uuid.random(), 85 | title = "Ev6", 86 | description = Constants.EmptyDescription, 87 | startTime = LocalTime(9, 30), 88 | endTime = LocalTime(11, 0) 89 | ), 90 | LocalTimeEvent( 91 | uuid = Uuid.random(), 92 | title = "Ev7", 93 | description = Constants.EmptyDescription, 94 | startTime = LocalTime(11, 0), 95 | endTime = LocalTime(11, 45) 96 | ) 97 | ) 98 | ) 99 | ) 100 | addChannel( 101 | EpgChannel( 102 | name = "Ch5", 103 | events = listOf( 104 | LocalTimeEvent( 105 | uuid = Uuid.random(), 106 | title = "Ev8", 107 | description = Constants.EmptyDescription, 108 | startTime = LocalTime(8, 30), 109 | endTime = LocalTime(10, 0) 110 | ), 111 | LocalTimeEvent( 112 | uuid = Uuid.random(), 113 | title = "Ev9", 114 | description = Constants.EmptyDescription, 115 | startTime = LocalTime(11, 0), 116 | endTime = LocalTime(11, 30) 117 | ) 118 | ) 119 | ) 120 | ) 121 | addChannel( 122 | EpgChannel( 123 | name = "Ch6", 124 | events = listOf( 125 | LocalTimeEvent( 126 | uuid = Uuid.random(), 127 | title = "Ev10", 128 | description = Constants.EmptyDescription, 129 | startTime = LocalTime(7, 0), 130 | endTime = LocalTime(8, 0) 131 | ), 132 | LocalTimeEvent( 133 | uuid = Uuid.random(), 134 | title = "Ev11", 135 | description = Constants.EmptyDescription, 136 | startTime = LocalTime(8, 0), 137 | endTime = LocalTime(9, 30) 138 | ), 139 | LocalTimeEvent( 140 | uuid = Uuid.random(), 141 | title = "Ev12", 142 | description = Constants.EmptyDescription, 143 | startTime = LocalTime(9, 30), 144 | endTime = LocalTime(10, 30) 145 | ), 146 | LocalTimeEvent( 147 | uuid = Uuid.random(), 148 | title = "Ev13", 149 | description = Constants.EmptyDescription, 150 | startTime = LocalTime(10, 30), 151 | endTime = LocalTime(11, 0) 152 | ), 153 | LocalTimeEvent( 154 | uuid = Uuid.random(), 155 | title = "Ev14", 156 | description = Constants.EmptyDescription, 157 | startTime = LocalTime(11, 0), 158 | endTime = LocalTime(12, 0) 159 | ) 160 | ) 161 | ) 162 | ) 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/DecimalSlotsBaseLayout.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.offset 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.foundation.layout.wrapContentSize 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalDensity 15 | import androidx.compose.ui.platform.LocalWindowInfo 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | internal fun DecimalSlotsBaseLayout( 21 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 22 | eventContentProvider: @Composable (decimalEvent: DecimalEvent) -> Unit 23 | ) { 24 | Box( 25 | modifier = Modifier 26 | .padding(start = decimalSlotsBaseLayoutState.config.timelineLeftPadding.dp) 27 | .fillMaxSize() 28 | ) { 29 | 30 | val containerSize = LocalWindowInfo.current.containerSize 31 | val density = LocalDensity.current.density 32 | 33 | val eventContainerWidth = 34 | (containerSize.width.dp / density) - decimalSlotsBaseLayoutState.config.timelineLeftPadding.dp 35 | 36 | LeftThenRightLayout( 37 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 38 | eventContainerWidth = eventContainerWidth, 39 | eventContentProvider = eventContentProvider 40 | ) 41 | } 42 | } 43 | 44 | @Composable 45 | private fun LeftThenRightLayout( 46 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 47 | eventContainerWidth: Dp, 48 | eventContentProvider: @Composable (decimalEvent: DecimalEvent) -> Unit 49 | ) { 50 | val config = decimalSlotsBaseLayoutState.config 51 | val minimumWidth = eventContainerWidth / decimalSlotsBaseLayoutState.maxColumns 52 | 53 | var arrangeToTheLeft = remember(key1 = decimalSlotsBaseLayoutState, key2 = eventContainerWidth) { 54 | when (config.eventsArrangement) { 55 | is EventsArrangement.LeftToRight, 56 | is EventsArrangement.MixedDirections -> true 57 | 58 | is EventsArrangement.RightToLeft -> false 59 | } 60 | } 61 | 62 | val offsetInfoMap = remember(key1 = decimalSlotsBaseLayoutState, key2 = eventContainerWidth) { 63 | val offsetMap = mutableMapOf() 64 | for (i in 0..decimalSlotsBaseLayoutState.slots.lastIndex) { 65 | offsetMap.put(decimalSlotsBaseLayoutState.slots[i], OffsetInfo()) 66 | } 67 | offsetMap 68 | } 69 | 70 | decimalSlotsBaseLayoutState.slotToDecimalEventMap.entries.forEach { entry -> 71 | val slot = entry.key 72 | val numbersOfSlots = (slot.startValue - config.initialSlotValue) * config.slotScale 73 | val offsetY = (numbersOfSlots * config.slotHeight).dp 74 | 75 | var offsetXAbsolute: Dp = 0.dp 76 | var offsetInfo: OffsetInfo 77 | val offsetX = if (arrangeToTheLeft) { 78 | offsetInfo = offsetInfoMap[slot] ?: OffsetInfo() 79 | offsetXAbsolute = offsetInfo.getTotalLeftOffset() 80 | offsetXAbsolute 81 | } else { 82 | offsetInfo = offsetInfoMap[slot] ?: OffsetInfo() 83 | offsetXAbsolute = offsetInfo.getTotalRightOffset() 84 | -offsetXAbsolute 85 | } 86 | 87 | val slotRemainingWidth = eventContainerWidth - offsetXAbsolute 88 | 89 | if (arrangeToTheLeft) { 90 | Row( 91 | modifier = Modifier 92 | .offset(y = offsetY, x = offsetX) 93 | .wrapContentSize() 94 | ) { 95 | entry.value.forEachIndexed { idx, event -> 96 | val eventTranslation = getEventTranslationInSlot(event, slot, config) 97 | val eventHeight = getEventHeight(event, config) 98 | val eventWidth = getEventWidthFromLeft( 99 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 100 | decimalEvent = event, 101 | eventSlot = slot, 102 | amountOfEventsInSameSlot = entry.value.size, 103 | currentEventIndex = idx, 104 | eventContainerWidth = eventContainerWidth, 105 | offsetInfo = offsetInfo, 106 | slotRemainingWidth = slotRemainingWidth, 107 | minimumWidth = minimumWidth 108 | ) 109 | updateEventOffsetX( 110 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 111 | decimalEvent = event, 112 | eventSlot = slot, 113 | slotOffsetInfoMap = offsetInfoMap, 114 | eventWidth = eventWidth, 115 | isLeft = true 116 | ) 117 | Box( 118 | modifier = Modifier 119 | .offset(y = eventTranslation) 120 | .height(height = eventHeight) 121 | .width(width = eventWidth) 122 | ) { eventContentProvider.invoke(event) } 123 | } 124 | } 125 | 126 | } else { 127 | RtlCustomRow( 128 | modifier = Modifier 129 | .offset(y = offsetY, x = offsetX) 130 | .wrapContentSize(), 131 | ) { 132 | entry.value.forEachIndexed { idx, event -> 133 | val eventTranslation = getEventTranslationInSlot(event, slot, config) 134 | val eventHeight = getEventHeight(event, config) 135 | val eventWidth = getEventWidthFromRight( 136 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 137 | decimalEvent = event, 138 | eventSlot = slot, 139 | amountOfEventsInSameSlot = entry.value.size, 140 | currentEventIndex = idx, 141 | eventContainerWidth = eventContainerWidth, 142 | offsetInfo = offsetInfo, 143 | slotRemainingWidth = slotRemainingWidth, 144 | minimumWidth = minimumWidth 145 | ) 146 | updateEventOffsetX( 147 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 148 | decimalEvent = event, 149 | eventSlot = slot, 150 | slotOffsetInfoMap = offsetInfoMap, 151 | eventWidth = eventWidth, 152 | isLeft = false 153 | ) 154 | Box( 155 | modifier = Modifier 156 | .offset(y = eventTranslation) 157 | .height(height = eventHeight) 158 | .width(width = eventWidth) 159 | ) { eventContentProvider.invoke(event) } 160 | } 161 | } 162 | } 163 | 164 | if (decimalSlotsBaseLayoutState.config.eventsArrangement is EventsArrangement.MixedDirections) { 165 | arrangeToTheLeft = !arrangeToTheLeft 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/data/DecimalSlotsDataSample.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.data 2 | 3 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsStateController 4 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalEvent 5 | import kotlin.uuid.ExperimentalUuidApi 6 | import kotlin.uuid.Uuid 7 | 8 | class DecimalSlotsDataSample(decimalSlotsStateController: DecimalSlotsStateController) { 9 | 10 | init { 11 | decimalSlotsStateController.decimalSlotsDataUpdater.postUpdate { 12 | addDecimalEventList( 13 | startValue = 8.0F, 14 | segments = createSegmentsFor8() 15 | ) 16 | addDecimalEventList( 17 | startValue = 8.5F, 18 | segments = createEventsFor8_5() 19 | ) 20 | addDecimalEventList( 21 | startValue = 9.0F, 22 | segments = createEventsFor9() 23 | ) 24 | addDecimalEventList( 25 | startValue = 9.5F, 26 | segments = createEventsFor9_5() 27 | ) 28 | addDecimalEventList( 29 | startValue = 10.0F, 30 | segments = createEventsFor10() 31 | ) 32 | addDecimalEventList( 33 | startValue = 10.5F, 34 | segments = createEventsFor10_5() 35 | ) 36 | } 37 | } 38 | 39 | @OptIn(ExperimentalUuidApi::class) 40 | private fun createSegmentsFor8(): List { 41 | return listOf( 42 | DecimalEvent( 43 | uuid = Uuid.random(), 44 | title = "Ev1", 45 | description = Constants.EmptyDescription, 46 | startValue = 8.0F, 47 | endValue = 10.0F 48 | ), 49 | DecimalEvent( 50 | uuid = Uuid.random(), 51 | title = "Ev2", 52 | description = Constants.EmptyDescription, 53 | startValue = 8.0F, 54 | endValue = 9.5F 55 | ), 56 | DecimalEvent( 57 | uuid = Uuid.random(), 58 | title = "Ev3", 59 | description = Constants.EmptyDescription, 60 | startValue = 8.0F, 61 | endValue = 9.0F 62 | ), 63 | DecimalEvent( 64 | uuid = Uuid.random(), 65 | title = "Ev4", 66 | description = Constants.EmptyDescription, 67 | startValue = 8.0F, 68 | endValue = 8.75F 69 | ), 70 | DecimalEvent( 71 | uuid = Uuid.random(), 72 | title = "Ev5", 73 | description = Constants.EmptyDescription, 74 | startValue = 8.0F, 75 | endValue = 8.25F 76 | ), 77 | ) 78 | } 79 | 80 | @OptIn(ExperimentalUuidApi::class) 81 | private fun createEventsFor8_5(): List { 82 | return listOf( 83 | DecimalEvent( 84 | uuid = Uuid.random(), 85 | title = "Ev6", 86 | description = Constants.EmptyDescription, 87 | startValue = 8.5F, 88 | endValue = 11.0F 89 | ), 90 | DecimalEvent( 91 | uuid = Uuid.random(), 92 | title = "Ev7", 93 | description = Constants.EmptyDescription, 94 | startValue = 8.5F, 95 | endValue = 9.5F 96 | ), 97 | DecimalEvent( 98 | uuid = Uuid.random(), 99 | title = "Ev8", 100 | description = Constants.EmptyDescription, 101 | startValue = 8.5F, 102 | endValue = 9.0F 103 | ), 104 | ) 105 | } 106 | 107 | @OptIn(ExperimentalUuidApi::class) 108 | private fun createEventsFor9(): List { 109 | return listOf( 110 | DecimalEvent( 111 | uuid = Uuid.random(), 112 | title = "Ev9", 113 | description = Constants.EmptyDescription, 114 | startValue = 9.0F, 115 | endValue = 10.0F 116 | ), 117 | DecimalEvent( 118 | uuid = Uuid.random(), 119 | title = "Ev10", 120 | description = Constants.EmptyDescription, 121 | startValue = 9.0F, 122 | endValue = 10.0F 123 | ) 124 | ) 125 | } 126 | 127 | @OptIn(ExperimentalUuidApi::class) 128 | private fun createEventsFor9_5(): List { 129 | return listOf( 130 | DecimalEvent( 131 | uuid = Uuid.random(), 132 | title = "Ev11", 133 | description = Constants.EmptyDescription, 134 | startValue = 9.5F, 135 | endValue = 11.0F 136 | ), 137 | DecimalEvent( 138 | uuid = Uuid.random(), 139 | title = "Ev12", 140 | description = Constants.EmptyDescription, 141 | startValue = 9.5F, 142 | endValue = 10.5F 143 | ), 144 | DecimalEvent( 145 | uuid = Uuid.random(), 146 | title = "Ev13", 147 | description = Constants.EmptyDescription, 148 | startValue = 9.5F, 149 | endValue = 10.0F 150 | ), 151 | DecimalEvent( 152 | uuid = Uuid.random(), 153 | title = "Ev14", 154 | description = Constants.EmptyDescription, 155 | startValue = 9.5F, 156 | endValue = 10.0F 157 | ), 158 | DecimalEvent( 159 | uuid = Uuid.random(), 160 | title = "Ev15", 161 | description = Constants.EmptyDescription, 162 | startValue = 9.5F, 163 | endValue = 10.0F 164 | ), 165 | DecimalEvent( 166 | uuid = Uuid.random(), 167 | title = "Ev16", 168 | description = Constants.EmptyDescription, 169 | startValue = 9.5F, 170 | endValue = 10.0F 171 | ) 172 | ) 173 | } 174 | 175 | @OptIn(ExperimentalUuidApi::class) 176 | private fun createEventsFor10(): List { 177 | return listOf( 178 | DecimalEvent( 179 | uuid = Uuid.random(), 180 | title = "Ev17", 181 | description = Constants.EmptyDescription, 182 | startValue = 10.0F, 183 | endValue = 11.5F 184 | ), 185 | DecimalEvent( 186 | uuid = Uuid.random(), 187 | title = "Ev18", 188 | description = Constants.EmptyDescription, 189 | startValue = 10.0F, 190 | endValue = 11.0F 191 | ), 192 | DecimalEvent( 193 | uuid = Uuid.random(), 194 | title = "Ev19", 195 | description = Constants.EmptyDescription, 196 | startValue = 10.0F, 197 | endValue = 10.5F 198 | ) 199 | ) 200 | } 201 | 202 | @OptIn(ExperimentalUuidApi::class) 203 | private fun createEventsFor10_5(): List { 204 | return listOf( 205 | DecimalEvent( 206 | uuid = Uuid.random(), 207 | title = "Ev20", 208 | description = Constants.EmptyDescription, 209 | startValue = 10.5F, 210 | endValue = 11.5F 211 | ), 212 | DecimalEvent( 213 | uuid = Uuid.random(), 214 | title = "Ev21", 215 | description = Constants.EmptyDescription, 216 | startValue = 10.5F, 217 | endValue = 11.0F 218 | ) 219 | ) 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /daily-agenda-view/src/commonMain/kotlin/com/macaosoftware/ui/dailyagenda/decimalslots/LayoutUtil.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.dailyagenda.decimalslots 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.LocalDensity 5 | import androidx.compose.ui.unit.Dp 6 | import androidx.compose.ui.unit.dp 7 | import androidx.compose.ui.unit.max 8 | 9 | /** 10 | * Returns the Event Y axis offset in Dp from the slot start time. 11 | * */ 12 | internal fun getEventTranslationInSlot(decimalEvent: DecimalEvent, eventSlot: Slot, config: Config): Dp { 13 | val fractionOfSlots = (decimalEvent.startValue - eventSlot.startValue) * config.slotScale 14 | return (fractionOfSlots * config.slotHeight).dp 15 | } 16 | 17 | /** 18 | * Returns the height in Dp for a given Event based on the amount of slots it touches. 19 | * */ 20 | internal fun getEventHeight(decimalEvent: DecimalEvent, config: Config): Dp { 21 | val numberOfSlots = (decimalEvent.endValue - decimalEvent.startValue) * config.slotScale 22 | return (numberOfSlots * config.slotHeight).dp 23 | } 24 | 25 | /** 26 | * For a given event, it returns the maximum number of sibling events, across all the slots the 27 | * event touches. 28 | * */ 29 | // TODO: This information can be computed in the data setup and saved in a 30 | // Map. Avoiding the iterations 31 | // during composition. 32 | private fun getMaximumNumberOfSiblingsInContainingSlots( 33 | decimalEvent: DecimalEvent, 34 | eventSlot: Slot, 35 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState 36 | ): Int { 37 | val containingSlots = getSlotsIncludeStartSlot(decimalEvent, eventSlot, decimalSlotsBaseLayoutState.slots) 38 | val maxNumberOfEvents = containingSlots.fold(initial = 0) { maxNumberOfEvents, slot -> 39 | val numberOfEvents = decimalSlotsBaseLayoutState.slotInfoMap[slot]?.getTotalColumnSpans() ?: 0 40 | if (numberOfEvents > maxNumberOfEvents) { 41 | numberOfEvents 42 | } else maxNumberOfEvents 43 | } 44 | println("LayoutUtil: maxNumberOfEvents: $maxNumberOfEvents") 45 | return maxNumberOfEvents 46 | } 47 | 48 | /** 49 | * Returns a list of the slots touched by the given event. Including its own start slot. 50 | * */ 51 | internal fun getSlotsIncludeStartSlot( 52 | decimalEvent: DecimalEvent, 53 | eventSlot: Slot, 54 | slots: List 55 | ): List { 56 | val slotIndex = slots.indexOf(eventSlot) 57 | val eventSlots = slots.subList(slotIndex, slots.size) 58 | val containingSlots = mutableListOf() 59 | eventSlots.forEach { slot -> 60 | if (decimalEvent.endValue > slot.startValue + 0.0001) { 61 | containingSlots.add(slot) 62 | } 63 | } 64 | return containingSlots 65 | } 66 | 67 | /** 68 | * Returns a list of the slots touched by the given event. Excluding its own start slot. 69 | * */ 70 | internal fun getSlotsIgnoreStartSlot( 71 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 72 | decimalEvent: DecimalEvent, 73 | eventSlot: Slot 74 | ): List { 75 | val slotIndex = decimalSlotsBaseLayoutState.slots.indexOf(eventSlot) 76 | val laterSlots = decimalSlotsBaseLayoutState.slots.subList(slotIndex + 1, decimalSlotsBaseLayoutState.slots.size) 77 | val containingSlots = mutableListOf() 78 | laterSlots.forEach { slot -> 79 | if (decimalEvent.endValue > slot.startValue + 0.0001) { 80 | containingSlots.add(slot) 81 | } 82 | } 83 | return containingSlots 84 | } 85 | 86 | internal fun updateEventOffsetX( 87 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 88 | decimalEvent: DecimalEvent, 89 | eventSlot: Slot, 90 | slotOffsetInfoMap: Map, 91 | eventWidth: Dp, 92 | isLeft: Boolean 93 | ) { 94 | 95 | val eventSlops = getSlotsIgnoreStartSlot( 96 | decimalSlotsBaseLayoutState = decimalSlotsBaseLayoutState, 97 | decimalEvent = decimalEvent, 98 | eventSlot = eventSlot 99 | ) 100 | val currentSlotOffsetInfo = slotOffsetInfoMap[eventSlot]!! 101 | 102 | if (isLeft) { 103 | currentSlotOffsetInfo.leftAccumulated += eventWidth 104 | } else { 105 | currentSlotOffsetInfo.rightAccumulated += eventWidth 106 | } 107 | 108 | eventSlops.forEach { slot -> 109 | val offsetInfo = slotOffsetInfoMap[slot]!! 110 | if (isLeft) { 111 | offsetInfo.leftStartOffset = currentSlotOffsetInfo.getTotalLeftOffset() 112 | println("LayoutUtil: slot: ${slot.title} has a new Left offset of: ${offsetInfo.leftAccumulated}") 113 | } else { 114 | offsetInfo.rightStartOffset = currentSlotOffsetInfo.getTotalRightOffset() 115 | println("LayoutUtil: slot: ${slot.title} has a new Right offset of: ${offsetInfo.rightAccumulated}") 116 | } 117 | } 118 | } 119 | 120 | internal fun DecimalEvent.isSingleSlot(eventSlot: Slot): Boolean { 121 | return eventSlot.endValue >= endValue 122 | } 123 | 124 | internal fun getEventWidthFromLeft( 125 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 126 | decimalEvent: DecimalEvent, 127 | eventSlot: Slot, 128 | amountOfEventsInSameSlot: Int, 129 | currentEventIndex: Int, 130 | eventContainerWidth: Dp, 131 | offsetInfo: OffsetInfo, 132 | slotRemainingWidth: Dp, 133 | minimumWidth: Dp 134 | ): Dp { 135 | if (shouldReturnMinimumAllowedWidth(decimalSlotsBaseLayoutState.config, decimalEvent, eventSlot)) { 136 | return minimumWidth 137 | } 138 | 139 | var singleSlotWidth: Dp? = null 140 | 141 | val eventWidth = if (decimalEvent.isSingleSlot(eventSlot)) { 142 | singleSlotWidth ?: run { 143 | val amountOfSingleSlotEvents = (amountOfEventsInSameSlot - currentEventIndex) 144 | ((eventContainerWidth - offsetInfo.getTotalLeftOffset() - offsetInfo.rightStartOffset) / amountOfSingleSlotEvents).also { 145 | singleSlotWidth = it 146 | } 147 | } 148 | } else { 149 | val widthNumber = getMaximumNumberOfSiblingsInContainingSlots( 150 | decimalEvent, 151 | eventSlot, 152 | decimalSlotsBaseLayoutState 153 | ) 154 | (slotRemainingWidth / widthNumber) 155 | } 156 | 157 | return max(minimumWidth, eventWidth) 158 | } 159 | 160 | internal fun getEventWidthFromRight( 161 | decimalSlotsBaseLayoutState: DecimalSlotsBaseLayoutState, 162 | decimalEvent: DecimalEvent, 163 | eventSlot: Slot, 164 | amountOfEventsInSameSlot: Int, 165 | currentEventIndex: Int, 166 | eventContainerWidth: Dp, 167 | offsetInfo: OffsetInfo, 168 | slotRemainingWidth: Dp, 169 | minimumWidth: Dp 170 | ): Dp { 171 | if (shouldReturnMinimumAllowedWidth(decimalSlotsBaseLayoutState.config, decimalEvent, eventSlot)) { 172 | return minimumWidth 173 | } 174 | 175 | var singleSlotWidth: Dp? = null 176 | 177 | val eventWidth: Dp = if (decimalEvent.isSingleSlot(eventSlot)) { 178 | singleSlotWidth ?: run { 179 | val amountOfSingleSlotEvents = (amountOfEventsInSameSlot - currentEventIndex) 180 | ((eventContainerWidth - offsetInfo.leftStartOffset - offsetInfo.getTotalRightOffset()) / amountOfSingleSlotEvents).also { 181 | singleSlotWidth = it 182 | } 183 | } 184 | } else { 185 | val widthNumber = getMaximumNumberOfSiblingsInContainingSlots( 186 | decimalEvent, 187 | eventSlot, 188 | decimalSlotsBaseLayoutState 189 | ) 190 | (slotRemainingWidth / widthNumber) 191 | } 192 | 193 | return max(minimumWidth, eventWidth) 194 | } 195 | 196 | private fun shouldReturnMinimumAllowedWidth( 197 | config: Config, 198 | decimalEvent: DecimalEvent, 199 | eventSlot: Slot 200 | ): Boolean { 201 | when (val eventsArrangement = config.eventsArrangement) { 202 | is EventsArrangement.LeftToRight -> { 203 | if (!eventsArrangement.lastEventFillRow) { 204 | return true 205 | } 206 | if (!decimalEvent.isSingleSlot(eventSlot)) { 207 | return true 208 | } 209 | return false 210 | } 211 | 212 | is EventsArrangement.MixedDirections -> { 213 | return when (eventsArrangement.eventWidthType) { 214 | EventWidthType.MaxVariableSize -> false 215 | EventWidthType.FixedSize -> true 216 | EventWidthType.FixedSizeFillLastEvent -> !decimalEvent.isSingleSlot(eventSlot) 217 | } 218 | 219 | } 220 | 221 | is EventsArrangement.RightToLeft -> { 222 | if (!eventsArrangement.lastEventFillRow) { 223 | return true 224 | } 225 | if (!decimalEvent.isSingleSlot(eventSlot)) { 226 | return true 227 | } 228 | return false 229 | } 230 | } 231 | } 232 | 233 | @Composable 234 | internal fun Int.toDp(): Dp = with(receiver = LocalDensity.current) { toDp() } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/DayScheduleApp.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Scaffold 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.unit.sp 21 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotConfig 22 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsStateController 23 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsView 24 | import com.macaosoftware.ui.dailyagenda.decimalslots.EventWidthType 25 | import com.macaosoftware.ui.dailyagenda.decimalslots.EventsArrangement 26 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgChannelSlotConfig 27 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgSlotsStateController 28 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgSlotsView 29 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotConfig 30 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotsStateController 31 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotsView 32 | import com.macaosoftware.ui.data.DecimalSlotsDataSample 33 | import com.macaosoftware.ui.data.EpgSlotsDataSample 34 | import com.macaosoftware.ui.ui.DayScheduleAppViewModel.DecimalSlotsUiActionListener 35 | import com.macaosoftware.ui.ui.DayScheduleAppViewModel.EpgSlotsUiActionListener 36 | import com.macaosoftware.ui.ui.DayScheduleAppViewModel.TimeSlotsUiActionListener 37 | import com.macaosoftware.ui.ui.model.AllDayEvent 38 | import kotlinx.datetime.LocalTime 39 | import org.jetbrains.compose.ui.tooling.preview.Preview 40 | import kotlin.random.Random 41 | import kotlin.uuid.ExperimentalUuidApi 42 | import kotlin.uuid.Uuid 43 | 44 | @Composable 45 | @Preview 46 | fun DayScheduleApp() { 47 | val viewModel = remember { DayScheduleAppViewModel() } 48 | MaterialTheme { 49 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 50 | Box( 51 | modifier = Modifier 52 | .fillMaxSize() 53 | .padding(paddingValues = innerPadding) 54 | ) { 55 | when (viewModel.slotsViewType) { 56 | SlotsViewType.Decimal -> { 57 | DecimalSlotExample( 58 | decimalSlotsStateController = viewModel.decimalSlotsStateController, 59 | decimalSlotsUiActionListener = viewModel.decimalSlotsUiActionListener 60 | ) 61 | } 62 | 63 | SlotsViewType.Timeline -> { 64 | TimeSlotExample( 65 | allDayEvents = viewModel.allDayEvents, 66 | timeSlotsStateController = viewModel.timeSlotsStateController, 67 | timeSlotsUiActionListener = viewModel.timeSlotsUiActionListener 68 | ) 69 | } 70 | 71 | SlotsViewType.Epg -> { 72 | EpgSlotExample( 73 | epgSlotsStateController = viewModel.epgSlotsStateController, 74 | epgSlotsUiActionListener = viewModel.epgSlotsUiActionListener 75 | ) 76 | } 77 | } 78 | DayScheduleAppActionsBottomView( 79 | slotsViewType = viewModel.slotsViewType, 80 | uiActionListener = viewModel.uiActionListener 81 | ) 82 | DayScheduleAppBottomSheet( 83 | bottomSheetEventsState = viewModel.bottomSheetEventsState.value, 84 | timeSlotsUiActionListener = viewModel.timeSlotsUiActionListener, 85 | decimalSlotsUiActionListener = viewModel.decimalSlotsUiActionListener, 86 | epgSlotsUiActionListener = viewModel.epgSlotsUiActionListener, 87 | uiActionListener = viewModel.uiActionListener, 88 | alertDialogUiActionListener = viewModel.alertDialogUiActionListener 89 | ) 90 | DayScheduleAppAlertDialog( 91 | alertDialogEventsState = viewModel.alertDialogEventsState.value, 92 | alertDialogUiActionListener = viewModel.alertDialogUiActionListener 93 | ) 94 | } 95 | } 96 | } 97 | } 98 | 99 | @OptIn(ExperimentalUuidApi::class) 100 | @Composable 101 | private fun TimeSlotExample( 102 | allDayEvents: List, 103 | timeSlotsStateController: TimeSlotsStateController, 104 | timeSlotsUiActionListener: TimeSlotsUiActionListener 105 | ) { 106 | Column(modifier = Modifier.fillMaxSize()) { 107 | Box( 108 | modifier = Modifier.height(56.dp).fillMaxWidth().background(Color.LightGray) 109 | ) { 110 | Text( 111 | modifier = Modifier.align(Alignment.Center), 112 | text = "All Day Events = ${allDayEvents.size}" 113 | ) 114 | } 115 | TimeSlotsView( 116 | timeSlotsStateController = timeSlotsStateController 117 | ) { localTimeEvent -> 118 | Box( 119 | modifier = Modifier.fillMaxSize() 120 | .padding(all = 1.dp) 121 | .background(color = generateRandomColor(localTimeEvent.uuid)) 122 | .combinedClickable( 123 | onClick = { 124 | timeSlotsUiActionListener.onTimeEventClicked(localTimeEvent) 125 | }, 126 | onDoubleClick = { 127 | timeSlotsUiActionListener.onTimeEventDoubleClicked(localTimeEvent) 128 | }, 129 | onLongClick = { 130 | timeSlotsUiActionListener.onTimeEventLongClicked(localTimeEvent) 131 | } 132 | ) 133 | ) { 134 | Text( 135 | text = "${localTimeEvent.title}: ${localTimeEvent.startTime}-${localTimeEvent.endTime}", 136 | fontSize = 12.sp 137 | ) 138 | } 139 | } 140 | } 141 | } 142 | 143 | @OptIn(ExperimentalUuidApi::class) 144 | @Composable 145 | private fun DecimalSlotExample( 146 | decimalSlotsStateController: DecimalSlotsStateController, 147 | decimalSlotsUiActionListener: DecimalSlotsUiActionListener 148 | ) { 149 | Box(modifier = Modifier.fillMaxSize()) { 150 | DecimalSlotsView( 151 | decimalSlotsStateController = decimalSlotsStateController 152 | ) { decimalEvent -> 153 | Box( 154 | modifier = Modifier.fillMaxSize() 155 | .padding(all = 1.dp) 156 | .background(color = generateRandomColor(decimalEvent.uuid)) 157 | .combinedClickable( 158 | onClick = { 159 | decimalSlotsUiActionListener.onDecimalEventClicked(decimalEvent) 160 | }, 161 | onDoubleClick = { 162 | decimalSlotsUiActionListener.onDecimalEventDoubleClicked(decimalEvent) 163 | }, 164 | onLongClick = { 165 | decimalSlotsUiActionListener.onDecimalEventLongClicked(decimalEvent) 166 | } 167 | ) 168 | ) { 169 | Text( 170 | text = "${decimalEvent.title}: ${decimalEvent.startValue}-${decimalEvent.endValue}", 171 | fontSize = 12.sp 172 | ) 173 | } 174 | } 175 | 176 | } 177 | } 178 | 179 | @OptIn(ExperimentalUuidApi::class) 180 | @Composable 181 | private fun EpgSlotExample( 182 | epgSlotsStateController: EpgSlotsStateController, 183 | epgSlotsUiActionListener: EpgSlotsUiActionListener 184 | ) { 185 | EpgSlotsView( 186 | epgSlotsStateController = epgSlotsStateController 187 | ) { localTimeEvent -> 188 | Box( 189 | modifier = Modifier.fillMaxSize().padding(1.dp) 190 | .background(generateRandomColor(localTimeEvent.uuid)) 191 | .combinedClickable( 192 | onClick = { 193 | epgSlotsUiActionListener.onEpgEventClicked(localTimeEvent) 194 | }, 195 | onDoubleClick = { 196 | epgSlotsUiActionListener.onEpgEventDoubleClicked(localTimeEvent) 197 | }, 198 | onLongClick = { 199 | epgSlotsUiActionListener.onEpgEventLongClicked(localTimeEvent) 200 | } 201 | ) 202 | ) { 203 | Text( 204 | text = "${localTimeEvent.title}: ${localTimeEvent.startTime}-${localTimeEvent.endTime}", 205 | fontSize = 12.sp 206 | ) 207 | } 208 | } 209 | } 210 | 211 | @OptIn(ExperimentalUuidApi::class) 212 | private val colorPerEventMap = mutableMapOf() 213 | 214 | @OptIn(ExperimentalUuidApi::class) 215 | private fun generateRandomColor(uuid: Uuid): Color { 216 | colorPerEventMap[uuid]?.let { return it } 217 | val red = Random.nextInt(256) 218 | val green = Random.nextInt(256) 219 | val blue = Random.nextInt(256) 220 | return Color(red, green, blue).also { colorPerEventMap[uuid] = it } 221 | } 222 | 223 | @Preview( 224 | showBackground = true 225 | ) 226 | @OptIn(ExperimentalUuidApi::class) 227 | @Composable 228 | fun DecimalSlotsViewPreview() { 229 | val decimalSlotsStateController = remember { 230 | DecimalSlotsStateController( 231 | decimalSlotConfig = DecimalSlotConfig(slotScale = 2), 232 | eventsArrangement = EventsArrangement.MixedDirections(EventWidthType.FixedSizeFillLastEvent) 233 | ).apply { 234 | // Prepare the initial data 235 | DecimalSlotsDataSample(decimalSlotsStateController = this) 236 | } 237 | } 238 | MaterialTheme { 239 | Box(modifier = Modifier.fillMaxSize()) { 240 | DecimalSlotsView(decimalSlotsStateController = decimalSlotsStateController) { event -> 241 | Box( 242 | modifier = Modifier.fillMaxSize() 243 | .padding(all = 1.dp) 244 | .background(color = generateRandomColor(event.uuid)) 245 | ) { 246 | Text( 247 | text = "${event.title}: ${event.startValue}-${event.endValue}", 248 | fontSize = 12.sp 249 | ) 250 | } 251 | } 252 | } 253 | } 254 | } 255 | 256 | @OptIn(ExperimentalUuidApi::class) 257 | @Preview( 258 | showBackground = true 259 | ) 260 | @Composable 261 | fun EpgSlotsViewPreview() { 262 | val epgSlotsStateController = remember { 263 | EpgSlotsStateController( 264 | EpgChannelSlotConfig( 265 | timeSlotConfig = TimeSlotConfig( 266 | startSlotTime = LocalTime(6, 0), 267 | endSlotTime = LocalTime(23, 59) 268 | ) 269 | ) 270 | ).apply { 271 | EpgSlotsDataSample(epgSlotsStateController = this) 272 | } 273 | } 274 | MaterialTheme { 275 | Box(modifier = Modifier.fillMaxSize()) { 276 | EpgSlotsView(epgSlotsStateController = epgSlotsStateController) { localEvent -> 277 | Box( 278 | modifier = Modifier.fillMaxSize() 279 | .padding(all = 1.dp) 280 | .background(color = generateRandomColor(localEvent.uuid)) 281 | ) { 282 | Text( 283 | text = "${localEvent.title}: ${localEvent.startTime}-${localEvent.endTime}", 284 | fontSize = 12.sp 285 | ) 286 | } 287 | } 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/macaosoftware/ui/ui/DayScheduleAppViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.macaosoftware.ui.ui 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalEvent 7 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotConfig 8 | import com.macaosoftware.ui.dailyagenda.decimalslots.DecimalSlotsStateController 9 | import com.macaosoftware.ui.dailyagenda.decimalslots.EventWidthType 10 | import com.macaosoftware.ui.dailyagenda.decimalslots.EventsArrangement 11 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgChannel 12 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgChannelSlotConfig 13 | import com.macaosoftware.ui.dailyagenda.epgslots.EpgSlotsStateController 14 | import com.macaosoftware.ui.dailyagenda.timeslots.LocalTimeEvent 15 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotConfig 16 | import com.macaosoftware.ui.dailyagenda.timeslots.TimeSlotsStateController 17 | import com.macaosoftware.ui.data.Constants 18 | import com.macaosoftware.ui.data.DecimalSlotsDataSample 19 | import com.macaosoftware.ui.data.EpgSlotsDataSample 20 | import com.macaosoftware.ui.data.TimeSlotsDataSample 21 | import com.macaosoftware.ui.ui.model.AllDayEvent 22 | import kotlinx.datetime.LocalTime 23 | import kotlin.uuid.ExperimentalUuidApi 24 | import kotlin.uuid.Uuid 25 | 26 | class DayScheduleAppViewModel { 27 | 28 | var slotsViewType by mutableStateOf(value = SlotsViewType.Timeline) 29 | 30 | // region: AllDayEvent 31 | 32 | val allDayEvents = mutableListOf() 33 | 34 | @OptIn(ExperimentalUuidApi::class) 35 | fun addAllDayEvent( 36 | uuid: Uuid = Uuid.random(), 37 | title: String, 38 | description: String 39 | ): Boolean { 40 | return addAllDayEvent( 41 | AllDayEvent( 42 | uuid = uuid, 43 | title = title, 44 | description = description 45 | ) 46 | ) 47 | } 48 | 49 | fun addAllDayEvent(allDayEvent: AllDayEvent): Boolean { 50 | return allDayEvents.add(allDayEvent) 51 | } 52 | 53 | // endregion 54 | 55 | // region: timeSlotsStateController 56 | 57 | val timeSlotsStateController by lazy { 58 | TimeSlotsStateController( 59 | timeSlotConfig = TimeSlotConfig( 60 | startSlotTime = LocalTime(7, 0), // 7:00 AM 61 | endSlotTime = LocalTime(19, 0), // 7:00 PM 62 | useAmPm = true, 63 | slotScale = 2, 64 | slotHeight = 48 65 | ), 66 | eventsArrangement = EventsArrangement.MixedDirections(EventWidthType.FixedSizeFillLastEvent) 67 | ).apply { 68 | TimeSlotsDataSample(timeSlotsStateController = this) 69 | } 70 | } 71 | 72 | internal interface TimeSlotsUiActionListener { 73 | fun confirmedAddTimeEvent( 74 | title: String, 75 | description: String, 76 | startLocalTime: LocalTime, 77 | endLocalTime: LocalTime 78 | ) 79 | 80 | fun showRemoveTimeEventForm(timeEvent: LocalTimeEvent) 81 | fun confirmedRemoveTimeEvent(eventTitle: String) 82 | fun onTimeEventClicked(timeEvent: LocalTimeEvent) 83 | fun onTimeEventDoubleClicked(timeEvent: LocalTimeEvent) 84 | fun onTimeEventLongClicked(timeEvent: LocalTimeEvent) 85 | } 86 | 87 | @OptIn(ExperimentalUuidApi::class) 88 | internal val timeSlotsUiActionListener = object : TimeSlotsUiActionListener { 89 | 90 | override fun confirmedAddTimeEvent( 91 | title: String, 92 | description: String, 93 | startLocalTime: LocalTime, 94 | endLocalTime: LocalTime 95 | ) { 96 | uiActionListener.dismissInputForm() 97 | timeSlotsStateController.timeSlotsDataUpdater.postUpdate { 98 | addEvent( 99 | title = title, 100 | description = description, 101 | startTime = startLocalTime, 102 | endTime = endLocalTime 103 | ) 104 | } 105 | } 106 | 107 | override fun showRemoveTimeEventForm(timeEvent: LocalTimeEvent) { 108 | bottomSheetEventsState.value = 109 | BottomSheetEventsState.RemoveTimedEventRequested(localTimeEvent = timeEvent) 110 | } 111 | 112 | override fun confirmedRemoveTimeEvent(eventTitle: String) { 113 | uiActionListener.dismissInputForm() 114 | timeSlotsStateController.timeSlotsDataUpdater.postUpdate { 115 | removeEventByTitle(eventTitle = eventTitle) 116 | } 117 | } 118 | 119 | override fun onTimeEventClicked(timeEvent: LocalTimeEvent) { 120 | bottomSheetEventsState.value = 121 | BottomSheetEventsState.ShowTimedEventRequested(localTimeEvent = timeEvent) 122 | } 123 | 124 | override fun onTimeEventDoubleClicked(timeEvent: LocalTimeEvent) { 125 | showRemoveTimeEventForm(timeEvent) 126 | } 127 | 128 | override fun onTimeEventLongClicked(timeEvent: LocalTimeEvent) { 129 | showRemoveTimeEventForm(timeEvent) 130 | } 131 | } 132 | 133 | // endregion 134 | 135 | // region: DecimalSlotsStateController 136 | 137 | val decimalSlotsStateController by lazy { 138 | DecimalSlotsStateController( 139 | decimalSlotConfig = DecimalSlotConfig( 140 | initialSlotValue = 7.0F, 141 | lastSlotValue = 19.0F, 142 | slotScale = 2, 143 | slotHeight = 48 144 | ), 145 | eventsArrangement = EventsArrangement.MixedDirections(EventWidthType.FixedSizeFillLastEvent) 146 | ).apply { 147 | DecimalSlotsDataSample(decimalSlotsStateController = this) 148 | } 149 | } 150 | 151 | internal interface DecimalSlotsUiActionListener { 152 | fun confirmedAddDecimalSegment(title: String, startValue: Float, endValue: Float) 153 | fun showRemoveDecimalEventForm(decimalEvent: DecimalEvent) 154 | fun confirmedRemoveDecimalEvent(eventTitle: String) 155 | fun onDecimalEventClicked(decimalEvent: DecimalEvent) 156 | fun onDecimalEventDoubleClicked(decimalEvent: DecimalEvent) 157 | fun onDecimalEventLongClicked(decimalEvent: DecimalEvent) 158 | } 159 | 160 | @OptIn(ExperimentalUuidApi::class) 161 | internal val decimalSlotsUiActionListener = object : DecimalSlotsUiActionListener { 162 | 163 | override fun confirmedAddDecimalSegment( 164 | title: String, 165 | startValue: Float, 166 | endValue: Float 167 | ) { 168 | uiActionListener.dismissInputForm() 169 | decimalSlotsStateController.decimalSlotsDataUpdater.postUpdate { 170 | addDecimalEvent( 171 | title = title, 172 | description = Constants.EmptyDescription, 173 | startValue = startValue, 174 | endValue = endValue 175 | ) 176 | } 177 | } 178 | 179 | override fun showRemoveDecimalEventForm(decimalEvent: DecimalEvent) { 180 | bottomSheetEventsState.value = 181 | BottomSheetEventsState.RemoveDecimalEventRequested(decimalEvent) 182 | } 183 | 184 | override fun confirmedRemoveDecimalEvent(eventTitle: String) { 185 | uiActionListener.dismissInputForm() 186 | decimalSlotsStateController.decimalSlotsDataUpdater.postUpdate { 187 | removeDecimalEventByTittle(eventTitle = eventTitle) 188 | } 189 | } 190 | 191 | override fun onDecimalEventClicked(decimalEvent: DecimalEvent) { 192 | bottomSheetEventsState.value = 193 | BottomSheetEventsState.ShowDecimalEventRequested(decimalEvent) 194 | } 195 | 196 | override fun onDecimalEventDoubleClicked(decimalEvent: DecimalEvent) { 197 | showRemoveDecimalEventForm(decimalEvent) 198 | } 199 | 200 | override fun onDecimalEventLongClicked(decimalEvent: DecimalEvent) { 201 | showRemoveDecimalEventForm(decimalEvent) 202 | } 203 | } 204 | 205 | // endregion 206 | 207 | // region EpgSlotsStateController 208 | 209 | @OptIn(ExperimentalUuidApi::class) 210 | val epgSlotsStateController by lazy { 211 | EpgSlotsStateController( 212 | EpgChannelSlotConfig( 213 | topHeaderHeight = 48, 214 | channelWidth = 96, 215 | timeSlotConfig = TimeSlotConfig( 216 | startSlotTime = LocalTime(6, 0), 217 | endSlotTime = LocalTime(23, 59) 218 | ) 219 | ) 220 | ).apply { 221 | EpgSlotsDataSample(epgSlotsStateController = this) 222 | } 223 | } 224 | 225 | internal interface EpgSlotsUiActionListener { 226 | fun confirmedAddEpgEvent( 227 | title: String, 228 | description: String, 229 | startLocalTime: LocalTime, 230 | endLocalTime: LocalTime 231 | ) 232 | 233 | fun confirmedRemoveEpgEvent(eventTitle: String) 234 | fun onEpgEventClicked(localTimeEvent: LocalTimeEvent) 235 | fun onEpgEventDoubleClicked(localTimeEvent: LocalTimeEvent) 236 | fun onEpgEventLongClicked(localTimeEvent: LocalTimeEvent) 237 | } 238 | 239 | @OptIn(ExperimentalUuidApi::class) 240 | internal val epgSlotsUiActionListener = object : EpgSlotsUiActionListener { 241 | 242 | override fun confirmedAddEpgEvent( 243 | title: String, 244 | description: String, 245 | startLocalTime: LocalTime, 246 | endLocalTime: LocalTime 247 | ) { 248 | uiActionListener.dismissInputForm() 249 | epgSlotsStateController.epgSlotsDataUpdater.postUpdate { 250 | addChannel( 251 | EpgChannel( 252 | name = "New Channel", 253 | events = listOf( 254 | LocalTimeEvent( 255 | uuid = Uuid.random(), 256 | title = title, 257 | description = description, 258 | startTime = startLocalTime, 259 | endTime = endLocalTime 260 | ) 261 | ) 262 | ) 263 | ) 264 | } 265 | } 266 | 267 | override fun confirmedRemoveEpgEvent(eventTitle: String) { 268 | TODO("Not yet implemented") 269 | } 270 | 271 | override fun onEpgEventClicked(localTimeEvent: LocalTimeEvent) { 272 | bottomSheetEventsState.value = 273 | BottomSheetEventsState.ShowEpgEventRequested(epgEvent = localTimeEvent) 274 | } 275 | 276 | override fun onEpgEventDoubleClicked(localTimeEvent: LocalTimeEvent) { 277 | bottomSheetEventsState.value = 278 | BottomSheetEventsState.RemoveEpgEventRequested(epgEvent = localTimeEvent) 279 | } 280 | 281 | override fun onEpgEventLongClicked(localTimeEvent: LocalTimeEvent) { 282 | bottomSheetEventsState.value = 283 | BottomSheetEventsState.RemoveEpgEventRequested(epgEvent = localTimeEvent) 284 | } 285 | } 286 | 287 | // endregion 288 | 289 | // region: Global screen events 290 | 291 | var bottomSheetEventsState = 292 | mutableStateOf(BottomSheetEventsState.Hidden) 293 | 294 | internal interface UiActionListener { 295 | fun showAddEventForm(slotsViewType: SlotsViewType) 296 | fun toggleAxisType(slotsViewType: SlotsViewType) 297 | fun dismissInputForm() 298 | } 299 | 300 | @OptIn(ExperimentalUuidApi::class) 301 | internal val uiActionListener = object : UiActionListener { 302 | 303 | override fun showAddEventForm(slotsViewType: SlotsViewType) { 304 | when (slotsViewType) { 305 | SlotsViewType.Decimal -> { 306 | bottomSheetEventsState.value = 307 | BottomSheetEventsState.AddDecimalEventRequested 308 | } 309 | 310 | SlotsViewType.Timeline -> { 311 | bottomSheetEventsState.value = 312 | BottomSheetEventsState.AddTimedEventRequested 313 | } 314 | 315 | SlotsViewType.Epg -> { 316 | bottomSheetEventsState.value = 317 | BottomSheetEventsState.AddEpgEventRequested 318 | } 319 | } 320 | } 321 | 322 | override fun toggleAxisType(slotsViewType: SlotsViewType) { 323 | if (this@DayScheduleAppViewModel.slotsViewType == slotsViewType) return 324 | this@DayScheduleAppViewModel.slotsViewType = slotsViewType 325 | } 326 | 327 | override fun dismissInputForm() { 328 | bottomSheetEventsState.value = BottomSheetEventsState.Hidden 329 | } 330 | } 331 | 332 | var alertDialogEventsState = 333 | mutableStateOf(value = AlertDialogEventsState.Hidden) 334 | 335 | internal interface AlertDialogUiActionListener { 336 | fun hideAlert() 337 | fun showAlert(text: String) 338 | } 339 | 340 | internal val alertDialogUiActionListener = object : AlertDialogUiActionListener { 341 | override fun hideAlert() { 342 | alertDialogEventsState.value = AlertDialogEventsState.Hidden 343 | } 344 | 345 | override fun showAlert(text: String) { 346 | alertDialogEventsState.value = AlertDialogEventsState.ShowingInfo(text = text) 347 | } 348 | } 349 | 350 | // endregion 351 | 352 | } 353 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | 2492D66DC8CF0569CAF0A973 /* DailyAgendaView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DailyAgendaView.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 14 | BCE932F6EF25107CD5961435 /* Exceptions for "iosApp" folder in "iosApp" target */ = { 15 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 16 | membershipExceptions = ( 17 | Info.plist, 18 | ); 19 | target = F982EA1EC9ABF5958AC64DE4 /* iosApp */; 20 | }; 21 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 22 | 23 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 24 | 1BC351C78DD103874EF9FF96 /* iosApp */ = { 25 | isa = PBXFileSystemSynchronizedRootGroup; 26 | exceptions = ( 27 | BCE932F6EF25107CD5961435 /* Exceptions for "iosApp" folder in "iosApp" target */, 28 | ); 29 | path = iosApp; 30 | sourceTree = ""; 31 | }; 32 | 801CDE462448F91F21DD7C45 /* Configuration */ = { 33 | isa = PBXFileSystemSynchronizedRootGroup; 34 | path = Configuration; 35 | sourceTree = ""; 36 | }; 37 | /* End PBXFileSystemSynchronizedRootGroup section */ 38 | 39 | /* Begin PBXFrameworksBuildPhase section */ 40 | 3E194DBDBBF44A91885822E2 /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | ); 45 | runOnlyForDeploymentPostprocessing = 0; 46 | }; 47 | /* End PBXFrameworksBuildPhase section */ 48 | 49 | /* Begin PBXGroup section */ 50 | 5FF200269E908102C3E747B8 /* Products */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | 2492D66DC8CF0569CAF0A973 /* DailyAgendaView.app */, 54 | ); 55 | name = Products; 56 | sourceTree = ""; 57 | }; 58 | CF6DF17C606835B04744A5C5 = { 59 | isa = PBXGroup; 60 | children = ( 61 | 801CDE462448F91F21DD7C45 /* Configuration */, 62 | 1BC351C78DD103874EF9FF96 /* iosApp */, 63 | 5FF200269E908102C3E747B8 /* Products */, 64 | ); 65 | sourceTree = ""; 66 | }; 67 | /* End PBXGroup section */ 68 | 69 | /* Begin PBXNativeTarget section */ 70 | F982EA1EC9ABF5958AC64DE4 /* iosApp */ = { 71 | isa = PBXNativeTarget; 72 | buildConfigurationList = BD96A41A8776DBF81ECBC33B /* Build configuration list for PBXNativeTarget "iosApp" */; 73 | buildPhases = ( 74 | EA8008CEF9B051BD19FB04B3 /* Compile Kotlin Framework */, 75 | CACF98B5141808CD9BBB0109 /* Sources */, 76 | 3E194DBDBBF44A91885822E2 /* Frameworks */, 77 | AB8044EEC2408D64EACBF532 /* Resources */, 78 | ); 79 | buildRules = ( 80 | ); 81 | dependencies = ( 82 | ); 83 | fileSystemSynchronizedGroups = ( 84 | 1BC351C78DD103874EF9FF96 /* iosApp */, 85 | ); 86 | name = iosApp; 87 | packageProductDependencies = ( 88 | ); 89 | productName = iosApp; 90 | productReference = 2492D66DC8CF0569CAF0A973 /* DailyAgendaView.app */; 91 | productType = "com.apple.product-type.application"; 92 | }; 93 | /* End PBXNativeTarget section */ 94 | 95 | /* Begin PBXProject section */ 96 | D5F201B6F85CDD904C09510F /* Project object */ = { 97 | isa = PBXProject; 98 | attributes = { 99 | BuildIndependentTargetsInParallel = 1; 100 | LastSwiftUpdateCheck = 1620; 101 | LastUpgradeCheck = 1620; 102 | TargetAttributes = { 103 | F982EA1EC9ABF5958AC64DE4 = { 104 | CreatedOnToolsVersion = 16.2; 105 | }; 106 | }; 107 | }; 108 | buildConfigurationList = D68920B7399172027C7782D8 /* Build configuration list for PBXProject "iosApp" */; 109 | developmentRegion = en; 110 | hasScannedForEncodings = 0; 111 | knownRegions = ( 112 | en, 113 | Base, 114 | ); 115 | mainGroup = CF6DF17C606835B04744A5C5; 116 | minimizedProjectReferenceProxies = 1; 117 | preferredProjectObjectVersion = 77; 118 | productRefGroup = 5FF200269E908102C3E747B8 /* Products */; 119 | projectDirPath = ""; 120 | projectRoot = ""; 121 | targets = ( 122 | F982EA1EC9ABF5958AC64DE4 /* iosApp */, 123 | ); 124 | }; 125 | /* End PBXProject section */ 126 | 127 | /* Begin PBXResourcesBuildPhase section */ 128 | AB8044EEC2408D64EACBF532 /* Resources */ = { 129 | isa = PBXResourcesBuildPhase; 130 | buildActionMask = 2147483647; 131 | files = ( 132 | ); 133 | runOnlyForDeploymentPostprocessing = 0; 134 | }; 135 | /* End PBXResourcesBuildPhase section */ 136 | 137 | /* Begin PBXShellScriptBuildPhase section */ 138 | EA8008CEF9B051BD19FB04B3 /* Compile Kotlin Framework */ = { 139 | isa = PBXShellScriptBuildPhase; 140 | alwaysOutOfDate = 1; 141 | buildActionMask = 2147483647; 142 | files = ( 143 | ); 144 | inputFileListPaths = ( 145 | ); 146 | inputPaths = ( 147 | ); 148 | name = "Compile Kotlin Framework"; 149 | outputFileListPaths = ( 150 | ); 151 | outputPaths = ( 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | shellPath = /bin/sh; 155 | shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; 156 | }; 157 | /* End PBXShellScriptBuildPhase section */ 158 | 159 | /* Begin PBXSourcesBuildPhase section */ 160 | CACF98B5141808CD9BBB0109 /* Sources */ = { 161 | isa = PBXSourcesBuildPhase; 162 | buildActionMask = 2147483647; 163 | files = ( 164 | ); 165 | runOnlyForDeploymentPostprocessing = 0; 166 | }; 167 | /* End PBXSourcesBuildPhase section */ 168 | 169 | /* Begin XCBuildConfiguration section */ 170 | 3BD7E2027F84D99E3F09838A /* Release */ = { 171 | isa = XCBuildConfiguration; 172 | buildSettings = { 173 | ARCHS = arm64; 174 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 175 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 176 | CODE_SIGN_IDENTITY = "Apple Development"; 177 | CODE_SIGN_STYLE = Automatic; 178 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 179 | DEVELOPMENT_TEAM = "${TEAM_ID}"; 180 | ENABLE_PREVIEWS = YES; 181 | GENERATE_INFOPLIST_FILE = YES; 182 | INFOPLIST_FILE = iosApp/Info.plist; 183 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 184 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 185 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 186 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 187 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 188 | LD_RUNPATH_SEARCH_PATHS = ( 189 | "$(inherited)", 190 | "@executable_path/Frameworks", 191 | ); 192 | SWIFT_EMIT_LOC_STRINGS = YES; 193 | SWIFT_VERSION = 5.0; 194 | TARGETED_DEVICE_FAMILY = "1,2"; 195 | }; 196 | name = Release; 197 | }; 198 | 74592164CA65220A1D63E77A /* Debug */ = { 199 | isa = XCBuildConfiguration; 200 | baseConfigurationReferenceAnchor = 801CDE462448F91F21DD7C45 /* Configuration */; 201 | baseConfigurationReferenceRelativePath = Config.xcconfig; 202 | buildSettings = { 203 | ALWAYS_SEARCH_USER_PATHS = NO; 204 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 205 | CLANG_ANALYZER_NONNULL = YES; 206 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 207 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 208 | CLANG_ENABLE_MODULES = YES; 209 | CLANG_ENABLE_OBJC_ARC = YES; 210 | CLANG_ENABLE_OBJC_WEAK = YES; 211 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 212 | CLANG_WARN_BOOL_CONVERSION = YES; 213 | CLANG_WARN_COMMA = YES; 214 | CLANG_WARN_CONSTANT_CONVERSION = YES; 215 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 216 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 217 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 218 | CLANG_WARN_EMPTY_BODY = YES; 219 | CLANG_WARN_ENUM_CONVERSION = YES; 220 | CLANG_WARN_INFINITE_RECURSION = YES; 221 | CLANG_WARN_INT_CONVERSION = YES; 222 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 223 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 224 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 225 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 226 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 227 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 228 | CLANG_WARN_STRICT_PROTOTYPES = YES; 229 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 230 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 231 | CLANG_WARN_UNREACHABLE_CODE = YES; 232 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 233 | COPY_PHASE_STRIP = NO; 234 | DEBUG_INFORMATION_FORMAT = dwarf; 235 | ENABLE_STRICT_OBJC_MSGSEND = YES; 236 | ENABLE_TESTABILITY = YES; 237 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 238 | GCC_C_LANGUAGE_STANDARD = gnu17; 239 | GCC_DYNAMIC_NO_PIC = NO; 240 | GCC_NO_COMMON_BLOCKS = YES; 241 | GCC_OPTIMIZATION_LEVEL = 0; 242 | GCC_PREPROCESSOR_DEFINITIONS = ( 243 | "DEBUG=1", 244 | "$(inherited)", 245 | ); 246 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 247 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 248 | GCC_WARN_UNDECLARED_SELECTOR = YES; 249 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 250 | GCC_WARN_UNUSED_FUNCTION = YES; 251 | GCC_WARN_UNUSED_VARIABLE = YES; 252 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 253 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 254 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 255 | MTL_FAST_MATH = YES; 256 | ONLY_ACTIVE_ARCH = YES; 257 | SDKROOT = iphoneos; 258 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 259 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 260 | }; 261 | name = Debug; 262 | }; 263 | A7E5ECB33D2A9216FE4B5806 /* Release */ = { 264 | isa = XCBuildConfiguration; 265 | baseConfigurationReferenceAnchor = 801CDE462448F91F21DD7C45 /* Configuration */; 266 | baseConfigurationReferenceRelativePath = Config.xcconfig; 267 | buildSettings = { 268 | ALWAYS_SEARCH_USER_PATHS = NO; 269 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 270 | CLANG_ANALYZER_NONNULL = YES; 271 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 272 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 273 | CLANG_ENABLE_MODULES = YES; 274 | CLANG_ENABLE_OBJC_ARC = YES; 275 | CLANG_ENABLE_OBJC_WEAK = YES; 276 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 277 | CLANG_WARN_BOOL_CONVERSION = YES; 278 | CLANG_WARN_COMMA = YES; 279 | CLANG_WARN_CONSTANT_CONVERSION = YES; 280 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 281 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 282 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 283 | CLANG_WARN_EMPTY_BODY = YES; 284 | CLANG_WARN_ENUM_CONVERSION = YES; 285 | CLANG_WARN_INFINITE_RECURSION = YES; 286 | CLANG_WARN_INT_CONVERSION = YES; 287 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 288 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 289 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 290 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 291 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 292 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 293 | CLANG_WARN_STRICT_PROTOTYPES = YES; 294 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 295 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 296 | CLANG_WARN_UNREACHABLE_CODE = YES; 297 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 298 | COPY_PHASE_STRIP = NO; 299 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 300 | ENABLE_NS_ASSERTIONS = NO; 301 | ENABLE_STRICT_OBJC_MSGSEND = YES; 302 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 303 | GCC_C_LANGUAGE_STANDARD = gnu17; 304 | GCC_NO_COMMON_BLOCKS = YES; 305 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 306 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 307 | GCC_WARN_UNDECLARED_SELECTOR = YES; 308 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 309 | GCC_WARN_UNUSED_FUNCTION = YES; 310 | GCC_WARN_UNUSED_VARIABLE = YES; 311 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 312 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 313 | MTL_ENABLE_DEBUG_INFO = NO; 314 | MTL_FAST_MATH = YES; 315 | SDKROOT = iphoneos; 316 | SWIFT_COMPILATION_MODE = wholemodule; 317 | VALIDATE_PRODUCT = YES; 318 | }; 319 | name = Release; 320 | }; 321 | C6358A1BC15C2EEDB11E256D /* Debug */ = { 322 | isa = XCBuildConfiguration; 323 | buildSettings = { 324 | ARCHS = arm64; 325 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 326 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 327 | CODE_SIGN_IDENTITY = "Apple Development"; 328 | CODE_SIGN_STYLE = Automatic; 329 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 330 | DEVELOPMENT_TEAM = JHRZ3PC8R8; 331 | ENABLE_PREVIEWS = YES; 332 | GENERATE_INFOPLIST_FILE = YES; 333 | INFOPLIST_FILE = iosApp/Info.plist; 334 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 335 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 336 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 337 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 338 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 339 | LD_RUNPATH_SEARCH_PATHS = ( 340 | "$(inherited)", 341 | "@executable_path/Frameworks", 342 | ); 343 | SWIFT_EMIT_LOC_STRINGS = YES; 344 | SWIFT_VERSION = 5.0; 345 | TARGETED_DEVICE_FAMILY = "1,2"; 346 | }; 347 | name = Debug; 348 | }; 349 | /* End XCBuildConfiguration section */ 350 | 351 | /* Begin XCConfigurationList section */ 352 | BD96A41A8776DBF81ECBC33B /* Build configuration list for PBXNativeTarget "iosApp" */ = { 353 | isa = XCConfigurationList; 354 | buildConfigurations = ( 355 | C6358A1BC15C2EEDB11E256D /* Debug */, 356 | 3BD7E2027F84D99E3F09838A /* Release */, 357 | ); 358 | defaultConfigurationIsVisible = 0; 359 | defaultConfigurationName = Release; 360 | }; 361 | D68920B7399172027C7782D8 /* Build configuration list for PBXProject "iosApp" */ = { 362 | isa = XCConfigurationList; 363 | buildConfigurations = ( 364 | 74592164CA65220A1D63E77A /* Debug */, 365 | A7E5ECB33D2A9216FE4B5806 /* Release */, 366 | ); 367 | defaultConfigurationIsVisible = 0; 368 | defaultConfigurationName = Release; 369 | }; 370 | /* End XCConfigurationList section */ 371 | }; 372 | rootObject = D5F201B6F85CDD904C09510F /* Project object */; 373 | } 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Daily Agenda View 2 | Daily Agenda View is a clone of the calendar component seeing in the **Microsoft Outlook App** or **Microsoft Teams App**. The component layouts daily events across an hourly based timeline. It is great for task planner apps, appointment apps, event schedule apps, calendar apps. 3 | 4 | Live demo using kotlin-wasm: [Daily Agenda View](https://pablichjenkov.github.io/daily-agenda-view) 5 | 6 | ## Support and Compatibility 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 25 | 34 | 35 |
Platform SupportKotlin Compatibility
15 | 16 | | Platform | Supported | 17 | |----------|:---------:| 18 | | Android | ✅ | 19 | | iOS | ✅ | 20 | | Desktop | ✅ | 21 | | JS | ✅ | 22 | | Wasm | ✅ | 23 | 24 | 26 | 27 | | Agenda Version | Kotlin Version | CMP Version | 28 | |---------------|--------|-------| 29 | | 1.5.1 | 2.2.21 | 1.9.3 | 30 | | 1.5.0 | 2.2.21 | 1.9.3 | 31 | | 1.4.0 | 2.2.21 | 1.9.3 | 32 | 33 |
36 | 37 | ## How to use it 38 | 39 | Add the gradle coordinates: 40 | 41 | ```kotlin 42 | sourceSets { 43 | commonMain.dependencies { 44 | implementation("io.github.pablichjenkov:daily-agenda-view:") 45 | } 46 | } 47 | ``` 48 | 49 | In Android only projects which already use **java-time** or **joda-time**, it is necessary to include the **kotlinx-datetime** dependecy too. So gradle will look like bellow. 50 | 51 | ```kotlin 52 | dependencies { 53 | implementation("io.github.pablichjenkov:daily-agenda-view:") 54 | implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") 55 | } 56 | ``` 57 | 58 |
59 | 60 | The library API is based on the compose **StateController pattern**. A StateController is basically **a mini MVI store just for a specific UI component**. 61 | Instead of being coupled to a full screen single gigantic state. The state controller is only bound to a specific composable ui section of the screen. 62 | These are the options from the library: 63 | - [TimeSlotsStateController](#TimeSlotsStateController) 64 | - [DecimalSlotsStateController](#DecimalSlotsStateController) 65 | - [EpgSlotsStateController](#EpgSlotsStateController) 66 | 67 | ### TimeSlotsStateController 68 | The **TimeSlotsStateController** displays events in a hour and minute based timeline. 69 | 70 | ```kotlin 71 | val timeSlotsStateController = remember { 72 | TimeSlotsStateController( 73 | timeSlotConfig = TimeSlotConfig(slotScale = 2, slotHeight = 48), 74 | eventsArrangement = 75 | EventsArrangement.MixedDirections(EventWidthType.FixedSizeFillLastEvent) 76 | ).apply { 77 | timeSlotsDataUpdater.postUpdate { 78 | addEvent( 79 | uuid = Uuid.random(), 80 | startTime = LocalTime(hour = 8, minute = 0), 81 | endTime = LocalTime(hour = 8, minute = 30), 82 | title = "Event 0", 83 | description = "Description 0" 84 | ) 85 | addEventList( // When adding a list all events must belong to the same slot. 86 | startTime = LocalTime(hour = 8, minute = 0), 87 | events = 88 | listOf( 89 | LocalTimeEvent( 90 | uuid = Uuid.random(), 91 | startTime = LocalTime(hour = 8, minute = 0), 92 | endTime = LocalTime(hour = 8, minute = 45), 93 | title = "Event 1", 94 | description = "Description 1" 95 | ), 96 | LocalTimeEvent( 97 | uuid = Uuid.random(), 98 | startTime = LocalTime(hour = 8, minute = 0), 99 | endTime = LocalTime(hour = 9, minute = 0), 100 | title = "Event 2", 101 | description = "Description 2" 102 | ) 103 | ) 104 | ) 105 | } 106 | } 107 | } 108 | 109 | ``` 110 | 111 |
112 | 113 | Now that you create a **TimeSlotsStateController** and added some events to it. Then add a **TimeSlotsView** 114 | in your Composable screen. 115 | 116 | ```kotlin 117 | @Composable 118 | fun MyDayScheduleView(modifier = Modifier.fillMaxSize()) { 119 | 120 | val timeSlotsStateController = remember { ... } 121 | 122 | TimeSlotsView(timeSlotsStateController = timeSlotsStateController) { localTimeEvent -> 123 | Box(modifier = Modifier.fillMaxSize().padding(all = 2.dp).background(color = Color.Gray)) { 124 | Text( 125 | text = 126 | "${localTimeEvent.title}: ${localTimeEvent.startTime}-${localTimeEvent.endTime}", 127 | fontSize = 12.sp 128 | ) 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | desktop-demo 135 | 136 | ## Events Arrangement Options 137 | 138 | **1.** In this mode the agenda view will try to maximize the events witdh. It achieves that by mixing the rows layout direction. **Even rows** are rendered from left to right while **odd rows** are rendered from right to left. Since the events are order by duration, this mode leverage the maximum space available by laying out in the opposite direction from the previous road. It should be very effective in most data use cases. 139 | 140 | ```kotlin 141 | eventsArrangement = EventsArrangement.MixedDirections(eventWidthType = EventWidthType.VariableSize) 142 | ``` 143 | 144 | daily-agenda-mix-directions-variable-width 145 | 146 | --- 147 | 148 | **2.** Similar to above, this mode also mixes the direction of the layout, even rows do LTR and odd rows fo RTL. But in this mode all the events have the same with. This is for the case where maximum space wants to be coverred but at the same time esthetic is needed. 149 | 150 | ```kotlin 151 | eventsArrangement = EventsArrangement.MixedDirections(eventWidthType = EventWidthType.FixedSize) 152 | ``` 153 | 154 | daily-agenda-mix-directions-same-width 155 | 156 | --- 157 | 158 | **3.** This mode is just like number 2 but expand the single slot events to occupy the full row available width. This is the default configuration if you don't specify any. 159 | 160 | ```kotlin 161 | eventsArrangement = EventsArrangement.MixedDirections(eventWidthType = EventWidthType.FixedSizeFillLastEvent) 162 | ``` 163 | 164 | 165 | 166 | 169 | 172 | 173 | 174 | 177 | 179 | 180 |
167 | Events align with slot start/end time 168 | 170 | Events do not align with slot start/end time 171 |
175 | daily-agenda-mix-directions-same-width-fill-end 176 | daily-agenda-mix-directions-same-width-fill-end 178 |
181 | 182 | --- 183 | 184 | **4.** Instead of maximizing space consumption, an App might want consistency laying out the daily calendar events. Bellow mode renders from left to right always and also expand the single slot events to occupy the full row available width. 185 | 186 | ```kotlin 187 | eventsArrangement = EventsArrangement.LeftToRight(lastEventFillRow = true) 188 | ``` 189 | 190 | daily-agenda-LTR-fill-end 191 | 192 | --- 193 | 194 | **5.** Similar to number 4 but in this case we want all the events to have the same width. 195 | 196 | ```kotlin 197 | eventsArrangement = EventsArrangement.LeftToRight(lastEventFillRow = false) 198 | ``` 199 | 200 | daily-agenda-LTR-no-fill-end 201 | 202 | --- 203 | 204 | **6.** The same as number 4 but from Right to left. Could be useful in countries where languages are written/read from right to left. 205 | 206 | ```kotlin 207 | eventsArrangement = EventsArrangement.RightToLeft(lastEventFillRow = true) 208 | ``` 209 | 210 | daily-agenda-RTL-fill-end 211 | 212 | --- 213 | 214 | **7.** The same as number 5 but from Right to left. 215 | 216 | ```kotlin 217 | eventsArrangement = EventsArrangement.RightToLeft(lastEventFillRow = false) 218 | ``` 219 | 220 | daily-agenda-RTL-no-fill-end 221 | 222 | ### DecimalSlotsStateController 223 | The **DecimalSlotsStateController** displays events in a vertical **decimal axis**. Although use cases for this type of data presentation are rare, the 224 | library includes it anyway in case someone needs it. This is actually the base StateController in which the TimeSlotsStateController builds upon. 225 | 226 | ```kotlin 227 | DecimalSlotsStateController( 228 | decimalSlotConfig = 229 | DecimalSlotConfig( 230 | initialSlotValue = 7.0F, 231 | lastSlotValue = 19.0F, 232 | slotScale = 2, 233 | slotHeight = 48 234 | ), 235 | eventsArrangement = EventsArrangement.MixedDirections(EventWidthType.FixedSizeFillLastEvent) 236 | ) 237 | .apply { 238 | decimalSlotsDataUpdater.postUpdate { 239 | addDecimalEvent( 240 | DecimalEvent( 241 | uuid = Uuid.random(), 242 | title = "Ev0", 243 | description = Constants.EmptyDescription, 244 | startValue = 8.5F, 245 | endValue = 10.0F 246 | ) 247 | ) 248 | addDecimalEventList( 249 | startValue = 8.0F, 250 | segments = listOf( 251 | DecimalEvent( 252 | uuid = Uuid.random(), 253 | title = "Ev1", 254 | description = Constants.EmptyDescription, 255 | startValue = 8.0F, 256 | endValue = 10.0F 257 | ), 258 | DecimalEvent( 259 | uuid = Uuid.random(), 260 | title = "Ev2", 261 | description = Constants.EmptyDescription, 262 | startValue = 8.0F, 263 | endValue = 9.5F 264 | ) 265 | ) 266 | ) 267 | } 268 | } 269 | 270 | ``` 271 | Then use the **DecimalSlotsView** Composable like bellow: 272 | 273 | ```kotlin 274 | // Assuming the StateController is hosted in a ViewModel 275 | val decimalSlotsStateController = viewModel.decimalSlotsStateController 276 | 277 | DecimalSlotsView(decimalSlotsStateController = decimalSlotsStateController) { decimalEvent -> 278 | Text(text = "${decimalEvent.title}: ${decimalEvent.startValue}-${decimalEvent.endValue}", fontSize = 12.sp) 279 | } 280 | ``` 281 | Above code should produce something like the image bellow. Notice the axis values are just decimal numbers, also this component doesn't have a current time line indicator. 282 | 283 | decimal-axis 284 | 285 | ### EpgSlotsStateController 286 | The library also includes an **EpgSlotsStateController**, EPG(Electronic Guide Program) is a very popular component in TV apps. 287 | Although other uses cases can leverage this type of component too. 288 | 289 | ```kotlin 290 | val epgSlotsStateController = remember { 291 | EpgSlotsStateController( 292 | EpgChannelSlotConfig( 293 | timeSlotConfig = 294 | TimeSlotConfig(startSlotTime = LocalTime(6, 0), endSlotTime = LocalTime(23, 59)) 295 | ) 296 | ) 297 | .apply { 298 | epgSlotsDataUpdater.postUpdate { 299 | addChannel( 300 | EpgChannel( 301 | name = "Ch1", 302 | events = 303 | listOf( 304 | LocalTimeEvent( 305 | uuid = Uuid.random(), 306 | title = "Ev1", 307 | description = Constants.EmptyDescription, 308 | startTime = LocalTime(9, 0), 309 | endTime = LocalTime(10, 0) 310 | ), 311 | LocalTimeEvent( 312 | uuid = Uuid.random(), 313 | title = "Ev2", 314 | description = Constants.EmptyDescription, 315 | startTime = LocalTime(10, 0), 316 | endTime = LocalTime(11, 30) 317 | ) 318 | ) 319 | ) 320 | ) 321 | addChannel( 322 | EpgChannel( 323 | name = "Ch2", 324 | events = 325 | listOf( 326 | LocalTimeEvent( 327 | uuid = Uuid.random(), 328 | title = "Ev3", 329 | description = Constants.EmptyDescription, 330 | startTime = LocalTime(9, 30), 331 | endTime = LocalTime(10, 15) 332 | ), 333 | LocalTimeEvent( 334 | uuid = Uuid.random(), 335 | title = "Ev4", 336 | description = Constants.EmptyDescription, 337 | startTime = LocalTime(10, 30), 338 | endTime = LocalTime(11, 0) 339 | ) 340 | ) 341 | ) 342 | ) 343 | } 344 | } 345 | } 346 | 347 | ``` 348 | Then use the **EpgSlotsView** to render the EpgSlotsStateController instance. 349 | 350 | ```kotlin 351 | @Composable 352 | fun MyTvScheduleView(modifier = Modifier.fillMaxSize()) { 353 | 354 | val epgSlotsStateController = remember { ... } 355 | 356 | EpgSlotsView(epgSlotsStateController = epgSlotsStateController) { localTimeEvent -> 357 | Text( 358 | text = "${localTimeEvent.title}: ${localTimeEvent.startTime}-${localTimeEvent.endTime}", 359 | fontSize = 12.sp 360 | ) 361 | } 362 | } 363 | ``` 364 | 365 | epg-view-2 366 | 367 | 368 | 369 | ## Contributions 370 | 371 | We welcome contributions from the community! If you have ideas for new features, bug fixes, or improvements, please open an issue or submit a pull request. 372 | --------------------------------------------------------------------------------