diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..bf2ea9d --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Composable Table \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..7e4744d --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..217e5c5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE index 7e5cbb2..fe1f0ed 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 sunny-chung +Copyright (c) 2023 Sunny Chung Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c84485 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Composable Table + +An Android Jetpack Compose library that provides a `@Composable` table +with automatic layouts. + +![Demo Video](media/composable-table-demo.gif) + +## Features +- 2-dimension scrolling +- Automatic cell width and height calculation using the largest one +- Maximum cell width/height could be optionally specified +- Allowing custom composable and action listeners (e.g. clickable) for each cell +- Infinite table width and height +- Straight-forward to use + +## What is NOT included +- Lazy cells +- Grid lines (have to be implemented by users themselves inside cells) + +## Setup + +1. Add the JitPack maven repository. +```kotlin + repositories { + // ... + maven(url = "https://jitpack.io") + } +``` + +2. Add this library as a dependency. +```kotlin + dependencies { + // ... + implementation("com.github.sunny-chung:composable-table:1.0.0") + } +``` + +## Usage +Below shows an example of minimal usage. +```kotlin + Table( + columnCount = 10, + rowCount = 100 + ) { columnIndex, rowIndex -> + Text("($rowIndex, $columnIndex)") + } +``` + +Please read the [demo app](demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/AppView.kt) for a practical usage example. + +## Troubleshooting + +If `maxCellWidthDp` or `maxCellHeightDp` is specified, please do not use `fillMaxXXX` modifiers +in the root composable of cells, or the cell would occupy maximum size unconditionally. +This behaviour is [documented](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).fillMaxWidth(kotlin.Float)) in the `fillMaxXXX` methods of the Compose Foundation library. + +## Contribution +Contributions are welcomed! + +Please also raise an issue if you think a must-have feature is missing! diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b119f66 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,10 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + val androidGradlePluginVersion = "8.1.0" + val kotlinVersion = "1.8.21" + + id("com.android.application") version androidGradlePluginVersion apply false + id("com.android.library") version androidGradlePluginVersion apply false + kotlin("android") version kotlinVersion apply false + kotlin("plugin.serialization") version kotlinVersion apply false +} diff --git a/composable-table/.gitignore b/composable-table/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/composable-table/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/composable-table/build.gradle.kts b/composable-table/build.gradle.kts new file mode 100644 index 0000000..4d0ee9f --- /dev/null +++ b/composable-table/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.library") + kotlin("android") + id("maven-publish") +} + +android { + namespace = "com.sunnychung.lib.android.composabletable" + compileSdk = 34 + + defaultConfig { + minSdk = 23 + } + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.7" + } +} + +dependencies { + implementation("androidx.compose.ui:ui:${libs.versions.jetpack.compose.get()}") + implementation("androidx.compose.foundation:foundation:${libs.versions.jetpack.compose.get()}") +} + +publishing { + publications { + create("maven") { + groupId = "com.github.sunny-chung" + artifactId = "composable-table" + version = "1.0.0" + } + } +} diff --git a/composable-table/src/main/java/com/sunnychung/lib/android/composabletable/ux/Table.kt b/composable-table/src/main/java/com/sunnychung/lib/android/composabletable/ux/Table.kt new file mode 100644 index 0000000..3f51b96 --- /dev/null +++ b/composable-table/src/main/java/com/sunnychung/lib/android/composabletable/ux/Table.kt @@ -0,0 +1,107 @@ +package com.sunnychung.lib.android.composabletable.ux + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp + +@Composable +fun Table( + modifier: Modifier = Modifier, + columnCount: Int, + rowCount: Int, + maxCellWidthDp: Dp = Dp.Infinity, + maxCellHeightDp: Dp = Dp.Infinity, + verticalScrollState: ScrollState = rememberScrollState(), + horizontalScrollState: ScrollState = rememberScrollState(), + cellContent: @Composable (columnIndex: Int, rowIndex: Int) -> Unit +) { + val columnWidths = remember { mutableStateMapOf() } + val rowHeights = remember { mutableStateMapOf() } + + val maxCellWidth = if (listOf(Dp.Infinity, Dp.Unspecified).contains(maxCellWidthDp)) { + Constraints.Infinity + } else { + with(LocalDensity.current) { maxCellWidthDp.toPx() }.toInt() + } + val maxCellHeight = if (listOf(Dp.Infinity, Dp.Unspecified).contains(maxCellHeightDp)) { + Constraints.Infinity + } else { + with(LocalDensity.current) { maxCellHeightDp.toPx() }.toInt() + } + + Box( + modifier = modifier + .then(Modifier.horizontalScroll(horizontalScrollState)) + .then(Modifier.verticalScroll(verticalScrollState)) + ) { + Layout( + content = { + + (0 until rowCount).forEach { rowIndex -> + (0 until columnCount).forEach { columnIndex -> + cellContent(columnIndex, rowIndex) + } + } + }, + ) { measurables, constraints -> + val placeables = measurables.mapIndexed { index, it -> + val columnIndex = index % columnCount + val rowIndex = index / columnCount + it.measure(Constraints( + minWidth = columnWidths[columnIndex] ?: 0, + minHeight = rowHeights[rowIndex] ?: 0, + maxWidth = maxCellWidth, + maxHeight = maxCellHeight + )) + } + + placeables.forEachIndexed { index, placeable -> + val columnIndex = index % columnCount + val rowIndex = index / columnCount + + val existingWidth = columnWidths[columnIndex] ?: 0 + val maxWidth = maxOf(existingWidth, placeable.width) + if (maxWidth > existingWidth) { + columnWidths[columnIndex] = maxWidth + } + + val existingHeight = rowHeights[rowIndex] ?: 0 + val maxHeight = maxOf(existingHeight, placeable.height) + if (maxHeight > existingHeight) { + rowHeights[rowIndex] = maxHeight + } + } + + val accumWidths = mutableListOf(0) + (1..columnWidths.size).forEach { i -> + accumWidths += accumWidths.last() + columnWidths[i-1]!! + } + val accumHeights = mutableListOf(0) + (1..rowHeights.size).forEach { i -> + accumHeights += accumHeights.last() + rowHeights[i-1]!! + } + + val totalWidth = accumWidths.last() + val totalHeight = accumHeights.last() + + layout(width = totalWidth, height = totalHeight) { + placeables.forEachIndexed { index, placeable -> + val columnIndex = index % columnCount + val rowIndex = index / columnCount + + placeable.placeRelative(accumWidths[columnIndex], accumHeights[rowIndex]) + } + } + } + } +} diff --git a/demo-app/.gitignore b/demo-app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/demo-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts new file mode 100644 index 0000000..4f00f24 --- /dev/null +++ b/demo-app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + kotlin("android") + kotlin("plugin.serialization") +} + +android { + namespace = "com.sunnychung.lib.android.composabletable" + compileSdk = 34 + + defaultConfig { + applicationId = "com.sunnychung.lib.android.composabletable" + minSdk = 23 + targetSdk = 34 + versionCode = 1 + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.7" + } +} + +dependencies { + implementation(project(":composable-table")) + implementation("androidx.compose.ui:ui:${libs.versions.jetpack.compose.get()}") + implementation("androidx.compose.foundation:foundation:${libs.versions.jetpack.compose.get()}") + implementation("androidx.compose.material:material:${libs.versions.jetpack.compose.get()}") + implementation("androidx.activity:activity-compose:1.7.2") + + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") + api("io.ktor:ktor-serialization-kotlinx-json:2.2.4") +} diff --git a/demo-app/proguard-rules.pro b/demo-app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/demo-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/demo-app/src/main/AndroidManifest.xml b/demo-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdcd9d6 --- /dev/null +++ b/demo-app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/MainActivity.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/MainActivity.kt new file mode 100644 index 0000000..49509c0 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/MainActivity.kt @@ -0,0 +1,25 @@ +package com.sunnychung.lib.android.composabletable + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.sunnychung.lib.android.composabletable.ux.AppView + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.White + ) { + AppView() + } + } + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Currency.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Currency.kt new file mode 100644 index 0000000..9bd8e56 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Currency.kt @@ -0,0 +1,20 @@ +package com.sunnychung.lib.android.composabletable.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Currency( + val unit: String?, + val amount: Double +) { + operator fun plus(other: Currency): Currency { + if (other.unit != unit && other.unit != null && unit != null) { + throw UnsupportedOperationException("Currencies of different unit cannot be calculated") + } + return Currency(unit ?: other.unit, amount + other.amount) + } + + override fun toString(): String { + return (unit ?: "") + amount + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/TransitConnect.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/TransitConnect.kt new file mode 100644 index 0000000..f2c582e --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/TransitConnect.kt @@ -0,0 +1,104 @@ +package com.sunnychung.lib.android.composabletable.model + +import com.sunnychung.lib.android.composabletable.util.ZonedDateTime +import kotlinx.serialization.Serializable + +@Serializable +@AndroidParcelize +class TransitConnect : AndroidParcelable { + lateinit var summary: Summary + var keyStops = listOf() + var connects: MutableList = mutableListOf() + + var prevScheduleUrl: String? = null + var nextScheduleUrl: String? = null + + var code: String? = null + + @Serializable + @AndroidParcelize + class Summary : WithDuration, AndroidParcelable { + override lateinit var startAt: ZonedDateTime + override lateinit var endAt: ZonedDateTime + var fares: MutableMap = mutableMapOf() + lateinit var totalFare: Currency + var walkingSeconds: Long = -1 + var waitingSeconds: Long = -1 + var numOfTrips: Int = -1 + } + + @Serializable + @AndroidParcelize + class KeyStop : AndroidParcelable { + lateinit var name: String + var type: TransitType? = null +// var location: Location? = null + var surroundMapUrl: String? = null + var inStopMapUrl: String? = null + var timetableUrls: List> = mutableListOf() // list([Provider Name, URL]) + +// var busStops: List = mutableListOf() +// var railwayStation: RailwayStationPayload? = null + +// var itineraryRemark: String? = null + } + + @Serializable + @AndroidParcelize + class Connect : WithDuration, AndroidParcelable { + lateinit var connectType: ConnectType + override lateinit var startAt: ZonedDateTime + override lateinit var endAt: ZonedDateTime + lateinit var startAtStop: KeyStop + lateinit var endAtStop: KeyStop + var transit: PublicTransit? = null + } + + @Serializable + @AndroidParcelize + class PublicTransit : AndroidParcelable { + lateinit var type: TransitType + var operator: String? = null + var line: String? = null + var direction: String? = null + var speed: String? = null + + var srcPlatform: String? = null + var srcExtraInfo: String? = null + var destPlatform: String? = null + var destExtraInfo: String? = null + + lateinit var baseFare: Currency + var extraFees: MutableList = mutableListOf() + var isReservationMandatory: Boolean? = null + + var intermediateStops: MutableList = mutableListOf() + + var facilities: MutableList = mutableListOf() + } + + enum class ConnectType { + Walk, PublicTransit + } + + enum class TransitType { + JR, Bus, Subway, Ferry, Flight, Others, Poi + } + + @Serializable + @AndroidParcelize + class ChargableItem : AndroidParcelable { + lateinit var item: String + lateinit var fee: Currency + var isMandatory: Boolean = false + } + + @Serializable + @AndroidParcelize + class IntermediateStop : AndroidParcelable { + lateinit var name: String + var arrivalTime: ZonedDateTime? = null +// var location: Location? = null + } + +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Types.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Types.kt new file mode 100644 index 0000000..51ab56c --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/Types.kt @@ -0,0 +1,7 @@ +package com.sunnychung.lib.android.composabletable.model + +typealias AndroidParcelize = Parcelize +typealias AndroidParcelable = Parcelable + +annotation class Parcelize // no implementation, for demo only +interface Parcelable // no implementation, for demo only diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/WithDuration.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/WithDuration.kt new file mode 100644 index 0000000..e2f6d80 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/model/WithDuration.kt @@ -0,0 +1,13 @@ +package com.sunnychung.lib.android.composabletable.model + +import com.sunnychung.lib.android.composabletable.util.SDuration +import com.sunnychung.lib.android.composabletable.util.ZonedDateTime + +interface WithDuration { + var startAt: ZonedDateTime + var endAt: ZonedDateTime + + fun duration(): SDuration { + return SDuration.fromDuration(this.endAt.timestamp - this.startAt.timestamp) + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/repository/RouteSearchHttpRepository.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/repository/RouteSearchHttpRepository.kt new file mode 100644 index 0000000..6b50eed --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/repository/RouteSearchHttpRepository.kt @@ -0,0 +1,15 @@ +package com.sunnychung.lib.android.composabletable.repository + +import com.sunnychung.lib.android.composabletable.model.TransitConnect +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +class RouteSearchHttpRepository { + protected val jsonMapper = Json { ignoreUnknownKeys = true } + + fun searchRoutes(): List { + // dummy data + val json = "[{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T18:37:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":560},\"JR\":{\"unit\":\"¥\",\"amount\":11590},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12410},\"walkingSeconds\":1320,\"waitingSeconds\":4860,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"霧島神宮駅/霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T19:04:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":560},\"JR\":{\"unit\":\"¥\",\"amount\":11590},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12410},\"walkingSeconds\":1320,\"waitingSeconds\":5280,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"霧島神宮駅/霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T19:34:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":560},\"JR\":{\"unit\":\"¥\",\"amount\":10990},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":11810},\"walkingSeconds\":1320,\"waitingSeconds\":7140,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"霧島神宮駅/霧島神宮\"},{\"name\":\"鹿児島 ≪降車不要≫\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:27:00+09:00\",\"endAt\":\"2023-06-29T19:34:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":2360},\"JR\":{\"unit\":\"¥\",\"amount\":9040},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":11660},\"walkingSeconds\":1260,\"waitingSeconds\":6120,\"numOfTrips\":5},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"丸尾\"},{\"name\":\"鹿児島空港\"},{\"name\":\"川内駅前/川内(鹿児島)\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T19:34:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":940},\"JR\":{\"unit\":\"¥\",\"amount\":10990},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12190},\"walkingSeconds\":1260,\"waitingSeconds\":6420,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"国分駅/国分(鹿児島)\"},{\"name\":\"鹿児島 ≪降車不要≫\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T19:58:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":560},\"JR\":{\"unit\":\"¥\",\"amount\":10990},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":11810},\"walkingSeconds\":1320,\"waitingSeconds\":7680,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"霧島神宮駅/霧島神宮\"},{\"name\":\"鹿児島 ≪降車不要≫\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:18:00+09:00\",\"endAt\":\"2023-06-29T19:58:00+09:00\",\"fares\":{\"Bus\":{\"unit\":\"¥\",\"amount\":940},\"JR\":{\"unit\":\"¥\",\"amount\":10990},\"Subway\":{\"unit\":\"¥\",\"amount\":260}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12190},\"walkingSeconds\":1260,\"waitingSeconds\":6960,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾\"},{\"name\":\"国分駅/国分(鹿児島)\"},{\"name\":\"鹿児島 ≪降車不要≫\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:17:00+09:00\",\"endAt\":\"2023-06-29T18:42:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":12940}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12940},\"walkingSeconds\":60,\"waitingSeconds\":6420,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港(鉄道)\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:17:00+09:00\",\"endAt\":\"2023-06-29T19:34:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":11810}},\"totalFare\":{\"unit\":\"¥\",\"amount\":11810},\"walkingSeconds\":60,\"waitingSeconds\":8280,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港(鉄道)\"}]},{\"summary\":{\"startAt\":\"2023-06-29T17:02:00+09:00\",\"endAt\":\"2023-06-29T20:43:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":12940}},\"totalFare\":{\"unit\":\"¥\",\"amount\":12940},\"walkingSeconds\":60,\"waitingSeconds\":2460,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港(鉄道)\"}]},{\"summary\":{\"startAt\":\"2023-06-29T17:02:00+09:00\",\"endAt\":\"2023-06-29T21:28:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":11810}},\"totalFare\":{\"unit\":\"¥\",\"amount\":11810},\"walkingSeconds\":60,\"waitingSeconds\":5760,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"霧島神宮\"},{\"name\":\"鹿児島中央\"},{\"name\":\"博多\"},{\"name\":\"福岡空港(鉄道)\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:17:00+09:00\",\"endAt\":\"2023-06-29T21:29:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":7970}},\"totalFare\":{\"unit\":\"¥\",\"amount\":7970},\"walkingSeconds\":720,\"waitingSeconds\":7380,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"国分駅前(鹿児島県・高速・連絡バス)\"},{\"name\":\"鹿児島空港(高速・連絡バス)\"},{\"name\":\"筑紫野(高速・連絡バス)\"},{\"name\":\"福岡空港国内線(高速・連絡バス)\"}]},{\"summary\":{\"startAt\":\"2023-06-29T14:17:00+09:00\",\"endAt\":\"2023-06-29T21:34:00+09:00\",\"fares\":{\"Others\":{\"unit\":\"¥\",\"amount\":7670}},\"totalFare\":{\"unit\":\"¥\",\"amount\":7670},\"walkingSeconds\":960,\"waitingSeconds\":6240,\"numOfTrips\":4},\"keyStops\":[{\"name\":\"丸尾温泉(いわさき各社路線)\"},{\"name\":\"国分駅前(鹿児島県・高速・連絡バス)\"},{\"name\":\"鹿児島空港(高速・連絡バス)\"},{\"name\":\"天神\"},{\"name\":\"福岡空港(鉄道)\"}]}]" + return jsonMapper.decodeFromString(json) + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/serializer/ZonedDateTimeSerializer.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/serializer/ZonedDateTimeSerializer.kt new file mode 100644 index 0000000..032b998 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/serializer/ZonedDateTimeSerializer.kt @@ -0,0 +1,23 @@ +package com.sunnychung.lib.android.composabletable.serializer + +import com.sunnychung.lib.android.composabletable.util.ZonedDateTime +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object ZonedDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ZonedDateTime", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ZonedDateTime { + val s = decoder.decodeString() + return ZonedDateTime(s) + } + + override fun serialize(encoder: Encoder, value: ZonedDateTime) { + val s = value.dateTime.toString() + value.zone + encoder.encodeString(s) + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/SDuration.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/SDuration.kt new file mode 100644 index 0000000..fdb1bec --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/SDuration.kt @@ -0,0 +1,31 @@ +package com.sunnychung.lib.android.composabletable.util + +import kotlin.time.Duration + +class SDuration { + val totalMs: Long + + val dayPart: Int + val hoursPart: Int + val minutesPart: Int + val secondsPart: Int + val msPart: Int + + constructor(totalMs: Long) { + this.totalMs = totalMs + + this.msPart = (totalMs % 1000L).toInt() + this.secondsPart = ((totalMs / 1000L) % 60L).toInt() + this.minutesPart = ((totalMs / 1000L / 60L) % 60L).toInt() + this.hoursPart = ((totalMs / 1000L / 60L / 60L) % 24L).toInt() + this.dayPart = (totalMs / 1000L / 60L / 60L / 24L).toInt() + } + + fun minutes(): Long { + return totalMs / 1000L / 60L + } + + companion object { + fun fromDuration(duration: Duration) = SDuration(duration.inWholeMilliseconds) + } +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/ZonedDateTime.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/ZonedDateTime.kt new file mode 100644 index 0000000..53a10c0 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/util/ZonedDateTime.kt @@ -0,0 +1,64 @@ +package com.sunnychung.lib.android.composabletable.util + +import com.sunnychung.lib.android.composabletable.model.AndroidParcelable +import com.sunnychung.lib.android.composabletable.model.AndroidParcelize +import com.sunnychung.lib.android.composabletable.serializer.ZonedDateTimeSerializer +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable(with = ZonedDateTimeSerializer::class) +@AndroidParcelize +class ZonedDateTime(val string: String) : AndroidParcelable { + val timestamp: Instant + val dateTime: LocalDateTime + val zone: String + + init { + var match = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2})([Z\\+\\-][:0-9]+)").matchEntire(string) + val s = if (match != null) { + "${match.groups[1]!!.value}:00${match.groups[2]!!.value}" + } else { + string + } + + timestamp = Instant.parse(s) + match = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?)([Z\\+\\-][:0-9]+)").matchEntire(s)!! + dateTime = LocalDateTime.parse(match.groups[1]!!.value) + zone = match.groups[2]!!.value + } + + override fun toString(): String { + return dateTime.toString() + zone + } +} + +fun string(char: Char, count: Int): String { + if (count <= 0) return "" + return (1..count).map { char }.joinToString() +} + +fun prePadZero(value: Int, length: Int): String { + val s = value.toString() + return string('0', length - s.length) + s +} + +fun LocalDateTime.ampm(): String { + return if (this.hour < 12) { + "am" + } else { + "pm" + } +} + +fun LocalDateTime.halfHour(): Int { + return if (this.hour <= 12) { + this.hour + } else { + return this.hour - 12 + } +} + +fun ZonedDateTime.toDisplayText(): String { + return "${dateTime.year}-${prePadZero(dateTime.monthNumber, 2)}-${prePadZero(dateTime.dayOfMonth, 2)} ${dateTime.halfHour()}:${prePadZero(dateTime.minute, 2)} ${dateTime.ampm()} (${zone})" +} diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/AppView.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/AppView.kt new file mode 100644 index 0000000..1ee30d6 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/AppView.kt @@ -0,0 +1,158 @@ +package com.sunnychung.lib.android.composabletable.ux + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sunnychung.lib.android.composabletable.model.TransitConnect +import com.sunnychung.lib.android.composabletable.repository.RouteSearchHttpRepository +import com.sunnychung.lib.android.composabletable.util.SDuration +import java.util.concurrent.TimeUnit + +@Composable +fun AppView() { + + @Composable + fun HeaderCell(text: String) { + if (text.isNullOrBlank()) { + Surface {} + return + } + Box( + modifier = Modifier + .background( + color = Color.White, + shape = RoundedCornerShape(corner = CornerSize(0.dp)) + ) + .border(width = 1.dp, color = Color.Gray) + ) { + Text( + text = text, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(10.dp) + .align(Alignment.Center) + ) + } + } + + @Composable + fun ContentCell(text: String, isChecked: Boolean, alignment: Alignment = Alignment.Center) { + Box(modifier = Modifier + .background( + color = if (!isChecked) Color.White else Color.Yellow, + shape = RoundedCornerShape(corner = CornerSize(0.dp)) + ) + .border(width = 1.dp, color = Color.Gray) + .clickable { + // do something wonderful + } + ) { + Text( + text = text, + modifier = Modifier + .padding(10.dp) + .align(alignment) + ) + } + } + + val checkedRows = remember { + mutableStateMapOf() + } + + @Composable + fun ToggleCell(row: Int) { + Box(contentAlignment = Alignment.Center) { + Switch( + checked = checkedRows.containsKey(row), + onCheckedChange = { + if (!checkedRows.containsKey(row)) { + checkedRows[row] = Unit + } else { + checkedRows.remove(row) + } + } + ) + } + } + + val result: List by remember { + mutableStateOf(RouteSearchHttpRepository().searchRoutes()) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Search Result", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 10.dp) + ) + + val headers = listOf( + "", + "#", + "Start Time", + "End Time", + "Duration", + "#Trips", + "Total Fare", + "Fare (JR)", + "Fare (Bus)", + "Fare (Others)", + "Walking", + "Waiting", + "Stops" + ) + Table( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + columnCount = headers.size, + rowCount = result.size + 1, + maxCellWidthDp = 320.dp + ) { columnIndex, rowIndex -> + val header = headers[columnIndex] + if (rowIndex == 0) { + HeaderCell(header) + } else { + val r = result[rowIndex - 1] + when (header) { + "" -> ToggleCell(rowIndex - 1) + "#" -> ContentCell(rowIndex.toString(), checkedRows.containsKey(rowIndex - 1)) + "Start Time" -> ContentCell(r.summary.startAt.formatHalfHoursMins(), checkedRows.containsKey(rowIndex - 1)) + "End Time" -> ContentCell(r.summary.endAt.formatHalfHoursMins(), checkedRows.containsKey(rowIndex - 1)) + "Duration" -> ContentCell(r.summary.duration().formatHoursMins(), checkedRows.containsKey(rowIndex - 1)) + "#Trips" -> ContentCell(r.summary.numOfTrips.toString(), checkedRows.containsKey(rowIndex - 1)) + "Total Fare" -> ContentCell(r.summary.totalFare.toString(), checkedRows.containsKey(rowIndex - 1)) + "Fare (JR)" -> ContentCell(r.summary.fares["JR"]?.toString() ?: "-", checkedRows.containsKey(rowIndex - 1)) + "Fare (Bus)" -> ContentCell(r.summary.fares["Bus"]?.toString() ?: "-", checkedRows.containsKey(rowIndex - 1)) + "Fare (Others)" -> ContentCell(r.summary.fares["Others"]?.toString() ?: "-", checkedRows.containsKey(rowIndex - 1)) + "Walking" -> ContentCell(SDuration(totalMs = TimeUnit.SECONDS.toMillis(r.summary.walkingSeconds)).formatHoursMins(), checkedRows.containsKey(rowIndex - 1)) + "Waiting" -> ContentCell(SDuration(totalMs = TimeUnit.SECONDS.toMillis(r.summary.waitingSeconds)).formatHoursMins(), checkedRows.containsKey(rowIndex - 1)) + "Stops" -> ContentCell(r.keyStopsFormatted(), checkedRows.containsKey(rowIndex - 1), Alignment.CenterStart) + } + } + } + } +} + diff --git a/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/RouteSearchUiExtension.kt b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/RouteSearchUiExtension.kt new file mode 100644 index 0000000..3981bf6 --- /dev/null +++ b/demo-app/src/main/java/com/sunnychung/lib/android/composabletable/ux/RouteSearchUiExtension.kt @@ -0,0 +1,63 @@ +package com.sunnychung.lib.android.composabletable.ux + +import com.sunnychung.lib.android.composabletable.model.TransitConnect +import com.sunnychung.lib.android.composabletable.util.SDuration +import com.sunnychung.lib.android.composabletable.util.ZonedDateTime +import com.sunnychung.lib.android.composabletable.util.ampm +import com.sunnychung.lib.android.composabletable.util.halfHour +import com.sunnychung.lib.android.composabletable.util.prePadZero + +fun TransitConnect.Summary.duration(): SDuration { + return SDuration.fromDuration(this.endAt.timestamp - this.startAt.timestamp) +} + +fun SDuration.formatHoursMins(): String { + if (this.hoursPart == 0) { + return "${this.minutesPart}m" + } + return "${this.hoursPart}h ${this.minutesPart}m" +} + +fun ZonedDateTime.formatHalfHoursMins(): String { + return "${dateTime.halfHour()}:${prePadZero(dateTime.minute, 2)} ${dateTime.ampm()} (${zone})" +} + +fun ZonedDateTime.formatShortHoursMins(): String { + return "${prePadZero(dateTime.hour, 2)}:${prePadZero(dateTime.minute, 2)}" +} + +fun TransitConnect.keyStopsFormatted(): String { + return this.keyStops + .map { it.name } + .joinToString(" → ") +} + +fun TransitConnect.PublicTransit.name(): String { + return listOf(this.operator, this.line, this.direction, this.speed) + .filter { !it.isNullOrBlank() } + .joinToString(" ") +} + +fun TransitConnect.PublicTransit.departureFormatted(): String? { + return listOf(srcPlatform.letIfNotEmpty { "Platform $it" }, srcExtraInfo) + .filter { !it.isNullOrBlank() } + .joinToString(", ") + .emptyToNull() +} + +fun TransitConnect.PublicTransit.arrivalFormatted(): String? { + return listOf(destPlatform.letIfNotEmpty { "Platform $it" }, destExtraInfo) + .filter { !it.isNullOrBlank() } + .joinToString(", ") + .emptyToNull() +} + +fun String.emptyToNull(): String? { + if (isEmpty()) return null + return this +} + +fun String?.letIfNotEmpty(mapping: (String) -> T): T? { + if (this.isNullOrEmpty()) return null + return mapping(this) +} diff --git a/demo-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/demo-app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/demo-app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demo-app/src/main/res/drawable/ic_launcher_background.xml b/demo-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/demo-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo-app/src/main/res/values/styles.xml b/demo-app/src/main/res/values/styles.xml new file mode 100644 index 0000000..6b4fa3d --- /dev/null +++ b/demo-app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +