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 @@
+
+
+
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..3c5031e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..df2fa53
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,2 @@
+[versions]
+jetpack-compose = "1.5.0"
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..eb8cdf8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Aug 27 20:56:56 HKT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/media/composable-table-demo.gif b/media/composable-table-demo.gif
new file mode 100644
index 0000000..7ec0770
Binary files /dev/null and b/media/composable-table-demo.gif differ
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..e8c9737
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Composable Table"
+include(":composable-table")
+include(":demo-app")