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