diff --git a/app/src/androidTest/kotlin/info/appdev/chartexample/StartTest.kt b/app/src/androidTest/kotlin/info/appdev/chartexample/StartTest.kt
index 5bbdbaf80..2c127e96d 100644
--- a/app/src/androidTest/kotlin/info/appdev/chartexample/StartTest.kt
+++ b/app/src/androidTest/kotlin/info/appdev/chartexample/StartTest.kt
@@ -75,7 +75,10 @@ class StartTest {
@After
fun cleanUp() {
- Intents.release()
+ // release() may have already been called by the last loop iteration
+ try {
+ Intents.release()
+ } catch (_: IllegalStateException) { /* not initialised, nothing to do */ }
// Clean up test timber tree
Timber.uprootAll()
}
@@ -249,12 +252,17 @@ class StartTest {
if (!compose)
doClickTest(index, contentClass, contentItem)
- //Thread.sleep(100)
Espresso.pressBack()
// Wait for MainActivity to be visible again
composeTestRule.waitForIdle()
Thread.sleep(200) // Small delay for back navigation
+
+ // Reset intent recording for next iteration; otherwise intents accumulate
+ // across the loop and Intents.intended() can no longer find a fresh
+ // unverified match for the current activity.
+ Intents.release()
+ Intents.init()
} catch (e: Exception) {
Timber.e("#$index/'${contentClass.simpleName}'->'$optionMenu' ${e.message}", e)
onView(ViewMatchers.isRoot())
@@ -264,6 +272,16 @@ class StartTest {
.replace(" ", "")
)
})
+ // Navigate back so subsequent iterations start from MainActivity
+ try {
+ Espresso.pressBack()
+ composeTestRule.waitForIdle()
+ } catch (_: Exception) { /* already on MainActivity */ }
+ // Reset intents so the next iteration starts clean
+ try {
+ Intents.release()
+ Intents.init()
+ } catch (_: Exception) { /* ignore if already released */ }
}
}
}
@@ -278,6 +296,7 @@ class StartTest {
contentItem.clazz == HorizontalBarFullComposeActivity::class.java ||
contentItem.clazz == MultiLineComposeActivity::class.java ||
contentItem.clazz == GradientActivity::class.java ||
+// contentItem.clazz == TimeIntervalChartActivity::class.java ||
contentItem.clazz == TimeLineActivity::class.java
) {
// These charts have less clickable area, so skip further clicks
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 104d62c8d..a8f2a241b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -60,6 +60,7 @@
+
diff --git a/app/src/main/kotlin/info/appdev/chartexample/TimeIntervalChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/TimeIntervalChartActivity.kt
new file mode 100644
index 000000000..ad79bb404
--- /dev/null
+++ b/app/src/main/kotlin/info/appdev/chartexample/TimeIntervalChartActivity.kt
@@ -0,0 +1,46 @@
+package info.appdev.chartexample
+
+import android.graphics.Color
+import android.os.Bundle
+import info.appdev.chartexample.databinding.ActivityTimeIntervalChartBinding
+import info.appdev.chartexample.notimportant.DemoBase
+import info.appdev.charting.data.EntryFloat
+import info.appdev.charting.data.GanttChartData
+import info.appdev.charting.data.GanttTask
+import info.appdev.charting.highlight.Highlight
+import info.appdev.charting.listener.OnChartValueSelectedListener
+
+/**
+ * Demo activity showing Gantt-style timeline visualization.
+ * Each horizontal bar represents a task with start time and duration.
+ */
+class TimeIntervalChartActivity : DemoBase(), OnChartValueSelectedListener {
+
+ private lateinit var binding: ActivityTimeIntervalChartBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityTimeIntervalChartBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // Create Gantt chart data
+ val ganttData = GanttChartData()
+
+ // Add sample project tasks
+ ganttData.addTask(GanttTask("Design", 0f, 50f, Color.rgb(255, 107, 107))) // Red: 0-50
+ ganttData.addTask(GanttTask("Dev", 40f, 100f, Color.rgb(66, 165, 245))) // Blue: 40-140
+ ganttData.addTask(GanttTask("Testing", 120f, 40f, Color.rgb(76, 175, 80), hatched = true)) // Green: 120-160
+ ganttData.addTask(GanttTask("Launch", 150f, 20f, Color.rgb(255, 193, 7))) // Yellow: 150-170
+ ganttData.minTime = 10f
+ ganttData.maxTime = 200f
+ // Set data and render
+ binding.chart1.setData(ganttData)
+ }
+
+ override fun saveToGallery() = Unit
+
+ override fun onValueSelected(entryFloat: EntryFloat, highlight: Highlight) = Unit
+
+ override fun onNothingSelected() = Unit
+
+}
diff --git a/app/src/main/kotlin/info/appdev/chartexample/notimportant/MainActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/notimportant/MainActivity.kt
index 9c50a3bf2..ccae0d21c 100644
--- a/app/src/main/kotlin/info/appdev/chartexample/notimportant/MainActivity.kt
+++ b/app/src/main/kotlin/info/appdev/chartexample/notimportant/MainActivity.kt
@@ -75,6 +75,7 @@ import info.appdev.chartexample.ScrollViewActivity
import info.appdev.chartexample.SpecificPositionsLineChartActivity
import info.appdev.chartexample.StackedBarActivity
import info.appdev.chartexample.StackedBarActivityNegative
+import info.appdev.chartexample.TimeIntervalChartActivity
import info.appdev.chartexample.TimeLineActivity
import info.appdev.chartexample.compose.HorizontalBarComposeActivity
import info.appdev.chartexample.compose.HorizontalBarFullComposeActivity
@@ -219,6 +220,7 @@ class MainActivity : ComponentActivity() {
add(ContentItem("Demonstrate and fix issues"))
add(ContentItem("Gradient", "Show a gradient edge case", GradientActivity::class.java))
add(ContentItem("Timeline", "Show a time line with Unix timestamp", TimeLineActivity::class.java))
+ add(ContentItem("Timeinterval", "Grantt chart", TimeIntervalChartActivity::class.java))
}
}
}
diff --git a/app/src/main/res/layout/activity_time_interval_chart.xml b/app/src/main/res/layout/activity_time_interval_chart.xml
new file mode 100644
index 000000000..b41d55b6c
--- /dev/null
+++ b/app/src/main/res/layout/activity_time_interval_chart.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/GanttChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/GanttChart.kt
new file mode 100644
index 000000000..8f8681906
--- /dev/null
+++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/GanttChart.kt
@@ -0,0 +1,212 @@
+package info.appdev.charting.charts
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.util.AttributeSet
+import android.view.View
+import info.appdev.charting.data.GanttChartData
+import java.util.Locale
+import androidx.core.graphics.withClip
+
+class GanttChart : View {
+ private var data: GanttChartData? = null
+ private var taskPaint: Paint? = null
+ private var hatchPaint: Paint? = null
+ private var gridPaint: Paint? = null
+ private var textPaint: Paint? = null
+
+ private var chartLeft = 0f
+ private var chartTop = 0f
+ private var chartRight = 0f
+ private var chartBottom = 0f
+ private val padding = 16f
+ private val labelTextSize = 24f
+ private val gridLinesMin = 2
+ private val gridLinesMax = 10
+
+ constructor(context: Context?) : super(context) {
+ init()
+ }
+
+ constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
+ init()
+ }
+
+ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
+ init()
+ }
+
+ private fun init() {
+ taskPaint = Paint().apply {
+ isAntiAlias = true
+ }
+ hatchPaint = Paint().apply {
+ color = 0xAAFFFFFF.toInt() // ~67 % opaque white – clearly visible over any bar colour
+ isAntiAlias = true
+ style = Paint.Style.STROKE
+ // strokeWidth is set dynamically in drawTasks to stay proportional to bar height
+ }
+ gridPaint = Paint().apply {
+ color = -0x333334
+ strokeWidth = 1f
+ }
+ textPaint = Paint().apply {
+ color = -0x99999a
+ textSize = 28f
+ isAntiAlias = true
+ }
+ }
+
+ fun setData(data: GanttChartData?) {
+ this.data = data
+ invalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ if (data == null || data!!.taskCount == 0) {
+ return
+ }
+
+ calculateDimensions()
+ drawGrid(canvas)
+ drawTasks(canvas)
+ }
+
+ private fun calculateDimensions() {
+ val labelMeasurePaint = Paint().apply {
+ textSize = labelTextSize
+ isAntiAlias = true
+ }
+ var maxLabelWidth = 0f
+ if (data != null) {
+ for (i in 0.. maxLabelWidth) maxLabelWidth = w
+ }
+ }
+ chartLeft = maxLabelWidth + padding * 3
+ chartTop = padding + 30
+ chartRight = width - padding
+ chartBottom = height - padding - 30
+ }
+
+ private val taskHeight: Float
+ // Dynamically calculate task height based on available space
+ get() {
+ if (data == null || data!!.taskCount == 0) {
+ return 40f
+ }
+ val availableHeight = chartBottom - chartTop
+ val taskCount = data!!.taskCount
+ // 50% of slot for bar, 50% for gap
+ return (availableHeight / taskCount) * 0.5f
+ }
+
+ private val taskSpacing: Float
+ get() {
+ if (data == null || data!!.taskCount == 0) {
+ return 12f
+ }
+ val availableHeight = chartBottom - chartTop
+ val taskCount = data!!.taskCount
+ return (availableHeight / taskCount) * 0.5f
+ }
+
+ private fun drawGrid(canvas: Canvas) {
+ val minTime = data!!.minTime
+ val maxTime = data!!.maxTime
+ var timeRange = maxTime - minTime
+ if (timeRange == 0f) {
+ timeRange = 100f
+ }
+
+ val timeLabelPaint = Paint().apply {
+ color = -0x99999a
+ textSize = 22f
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
+
+ // Calculate how many grid lines fit without overlapping labels
+ val sampleLabel = String.format(Locale.getDefault(), "%.0f", maxTime)
+ val labelWidth = timeLabelPaint.measureText(sampleLabel) + 8f
+ val chartWidth = chartRight - chartLeft
+ val maxGridLines = (chartWidth / labelWidth).toInt().coerceIn(gridLinesMin, gridLinesMax)
+
+ for (i in 0..maxGridLines) {
+ val x = chartLeft + (i / maxGridLines.toFloat()) * chartWidth
+ canvas.drawLine(x, chartTop, x, chartBottom, gridPaint!!)
+
+ val time = minTime + (i / maxGridLines.toFloat()) * timeRange
+ canvas.drawText(String.format(Locale.getDefault(), "%.0f", time), x, chartBottom + 30, timeLabelPaint)
+ }
+ }
+
+ private fun drawTasks(canvas: Canvas) {
+ val minTime = data!!.minTime
+ val maxTime = data!!.maxTime
+ var timeRange = maxTime - minTime
+ if (timeRange == 0f) {
+ timeRange = 100f
+ }
+
+ val taskHeight = this.taskHeight
+ val taskSpacing = this.taskSpacing
+ val slotHeight = taskHeight + taskSpacing
+
+ val labelPaint = Paint()
+ labelPaint.color = -0xcccccd
+ labelPaint.textSize = labelTextSize
+ labelPaint.isAntiAlias = true
+ labelPaint.textAlign = Paint.Align.RIGHT
+
+ val borderPaint = Paint()
+ borderPaint.color = -0x666667
+ borderPaint.strokeWidth = 2f
+ borderPaint.style = Paint.Style.STROKE
+
+ for (i in 0.. strokeWidth or lines overlap.
+ // strokeWidth ≈ 20 % of bar height
+ // hatchSpacing ≈ 40 % of bar height (2× the stroke → clear gap)
+ hatchPaint!!.strokeWidth = taskHeight * 0.20f
+ val hatchSpacing = taskHeight * 0.40f
+
+ // Hatch lines (45° diagonal, bottom-left → top-right)
+ canvas.withClip(rect) {
+ var hx = startX - taskHeight
+ while (hx < endX + taskHeight) {
+ drawLine(hx, taskY + taskHeight, hx + taskHeight, taskY, hatchPaint!!)
+ hx += hatchSpacing
+ }
+ }
+ }
+ canvas.drawRect(rect, borderPaint)
+ }
+ }
+}
diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/GanttChartData.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/GanttChartData.kt
new file mode 100644
index 000000000..e2b8c41ad
--- /dev/null
+++ b/chartLib/src/main/kotlin/info/appdev/charting/data/GanttChartData.kt
@@ -0,0 +1,110 @@
+package info.appdev.charting.data
+
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Data container for Gantt chart.
+ * Manages a list of tasks and provides convenient access methods.
+ */
+class GanttChartData {
+ private var mMaxTime = 0f
+ private var mMinTime = 0f
+
+ /**
+ * Get all tasks.
+ *
+ * @return List of all tasks
+ */
+ private val tasks: MutableList = mutableListOf()
+
+ /**
+ * Add a task to the Gantt chart.
+ *
+ * @param task The task to add
+ */
+ fun addTask(task: GanttTask?) {
+ tasks.add(task!!)
+ mMinTime = calcMinTime()
+ mMaxTime = calcMaxTime()
+ }
+
+ private fun calcMaxTime(): Float {
+ if (tasks.isEmpty())
+ return 100f
+ var max = 0f
+ for (task in tasks) {
+ max = max(max, task.endTime)
+ }
+ return max
+ }
+
+ private fun calcMinTime(): Float {
+ if (tasks.isEmpty()) return 0f
+ var min = Float.MAX_VALUE
+ for (task in tasks) {
+ min = min(min, task.startTime)
+ }
+ return min
+ }
+
+ /**
+ * Add multiple tasks to the Gantt chart.
+ *
+ * @param taskList List of tasks to add
+ */
+ fun addTasks(taskList: MutableList) {
+ tasks.addAll(taskList)
+ mMinTime = calcMinTime()
+ mMaxTime = calcMaxTime()
+ }
+
+ /**
+ * Get a specific task by index.
+ *
+ * @param index Task index
+ * @return The task at the given index
+ */
+ fun getTask(index: Int): GanttTask {
+ return tasks[index]
+ }
+
+ /**
+ * Get the number of tasks.
+ *
+ * @return Number of tasks in the chart
+ */
+ val taskCount: Int
+ get() = tasks.size
+
+ /**
+ * Get the earliest start time across all tasks.
+ *
+ * @return Minimum start time
+ */
+ var minTime: Float
+ get() = mMinTime
+ set(value) {
+ mMinTime = value
+ }
+
+ /**
+ * Get the latest end time across all tasks.
+ *
+ * @return Maximum end time
+ */
+ var maxTime: Float
+ get() = mMaxTime
+ set(value) {
+ mMaxTime = value
+ }
+
+ /**
+ * Clear all tasks.
+ */
+ fun clearTasks() {
+ tasks.clear()
+ mMinTime = calcMinTime()
+ mMaxTime = calcMaxTime()
+ }
+}
diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/GanttTask.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/GanttTask.kt
new file mode 100644
index 000000000..9c96eb613
--- /dev/null
+++ b/chartLib/src/main/kotlin/info/appdev/charting/data/GanttTask.kt
@@ -0,0 +1,15 @@
+package info.appdev.charting.data
+
+/**
+ * Represents a single task in a Gantt chart.
+ * Each task has a name, start time, duration, and display color.
+ *
+ * @param name Task name/label
+ * @param startTime When the task starts
+ * @param duration How long the task lasts
+ * @param color Display color (Android color int)
+ */
+class GanttTask(val name: String?, val startTime: Float, val duration: Float, val color: Int, val hatched: Boolean = false) {
+ val endTime: Float
+ get() = startTime + duration
+}
diff --git a/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-1SampleClick.png b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-1SampleClick.png
new file mode 100644
index 000000000..3e61b1da0
Binary files /dev/null and b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-1SampleClick.png differ
diff --git a/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click.png b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click.png
new file mode 100644
index 000000000..3e61b1da0
Binary files /dev/null and b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click.png differ
diff --git a/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click2020.png b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click2020.png
new file mode 100644
index 000000000..3e61b1da0
Binary files /dev/null and b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click2020.png differ
diff --git a/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click7070.png b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click7070.png
new file mode 100644
index 000000000..3e61b1da0
Binary files /dev/null and b/screenshotsToCompare9/StartTest_smokeTestStart-46-TimeIntervalChartActivity-Timeinterval-click7070.png differ