@@ -22,7 +22,10 @@ import androidx.compose.foundation.layout.width
2222import androidx.compose.foundation.layout.wrapContentSize
2323import androidx.compose.foundation.shape.CircleShape
2424import androidx.compose.foundation.shape.RoundedCornerShape
25+ import androidx.compose.material.icons.Icons
26+ import androidx.compose.material.icons.filled.CompareArrows
2527import androidx.compose.material3.ExperimentalMaterial3Api
28+ import androidx.compose.material3.Icon
2629import androidx.compose.material3.MaterialTheme
2730import androidx.compose.material3.ModalBottomSheet
2831import androidx.compose.material3.Text
@@ -144,70 +147,148 @@ private fun TabBar(
144147 selected : TabKind ,
145148 onSelect : (TabKind ) -> Unit
146149) {
147- data class Entry (
148- val tab : TabKind ,
149- val label : String ,
150- val color : Color ,
151- val source : DataSource ?
152- )
153- val entries = listOf (
154- Entry (TabKind .PHONE , DataSource .PHONE .displayName, DataSource .PHONE .color, DataSource .PHONE ),
155- Entry (TabKind .WHEEL , DataSource .WHEEL .displayName, DataSource .WHEEL .color, DataSource .WHEEL ),
156- Entry (TabKind .RACEBOX , DataSource .RACEBOX .displayName, DataSource .RACEBOX .color, DataSource .RACEBOX ),
157- Entry (TabKind .COMPARE , " Compare" , MaterialTheme .colorScheme.onSurface, null )
150+ val sourceEntries = listOf (
151+ Triple (TabKind .PHONE , DataSource .PHONE , DataSource .PHONE .displayName),
152+ Triple (TabKind .WHEEL , DataSource .WHEEL , DataSource .WHEEL .displayName),
153+ Triple (TabKind .RACEBOX , DataSource .RACEBOX , DataSource .RACEBOX .displayName)
158154 )
159155 Row (
160156 modifier = Modifier .fillMaxWidth(),
161- horizontalArrangement = Arrangement .spacedBy( 6 .dp)
157+ verticalAlignment = Alignment . CenterVertically
162158 ) {
163- entries.forEach { e ->
164- val isSel = e.tab == selected
165- val live = e.source?.let { snapshots[it]?.isLive == true }
166- Box (
167- modifier = Modifier
168- .weight(1f )
169- .clip(RoundedCornerShape (10 .dp))
170- .background(
171- if (isSel) e.color.copy(alpha = 0.18f )
172- else MaterialTheme .colorScheme.surfaceVariant
173- )
174- .clickable { onSelect(e.tab) }
175- .padding(vertical = 8 .dp, horizontal = 4 .dp),
176- contentAlignment = Alignment .Center
177- ) {
178- Row (
179- verticalAlignment = Alignment .CenterVertically ,
180- horizontalArrangement = Arrangement .Center
181- ) {
182- // Live/offline dot — filled colour for live, hollow grey
183- // ring for offline. Skipped on the Compare tab.
184- if (live != null ) {
185- if (live) {
186- Box (
187- modifier = Modifier
188- .size(7 .dp)
189- .clip(CircleShape )
190- .background(e.color)
191- )
192- } else {
193- Box (
194- modifier = Modifier
195- .size(7 .dp)
196- .clip(CircleShape )
197- .border(1 .dp, Color (0xFF707070 ), CircleShape )
198- )
199- }
200- Spacer (Modifier .width(5 .dp))
201- }
202- Text (
203- text = e.label,
204- fontSize = 12 .sp,
205- fontWeight = if (isSel) FontWeight .Bold else FontWeight .Medium ,
206- color = if (isSel) e.color else MaterialTheme .colorScheme.onSurfaceVariant
207- )
208- }
159+ // Three source tabs in a tight cluster — they're peers, all the
160+ // same chip style with a live/offline dot prefix.
161+ Row (
162+ modifier = Modifier .weight(3f ),
163+ horizontalArrangement = Arrangement .spacedBy(6 .dp)
164+ ) {
165+ sourceEntries.forEach { (tab, source, label) ->
166+ SourceTabChip (
167+ label = label,
168+ color = source.color,
169+ isSelected = tab == selected,
170+ isLive = snapshots[source]?.isLive == true ,
171+ onClick = { onSelect(tab) },
172+ modifier = Modifier .weight(1f )
173+ )
209174 }
210175 }
176+ // Visual break before Compare: 10dp gap + a thin vertical divider
177+ // so the tab bar reads as "(3 sources) | (action)" instead of four
178+ // peers. Without this the Compare chip blends in with the sources.
179+ Spacer (Modifier .width(10 .dp))
180+ Box (
181+ modifier = Modifier
182+ .width(1 .dp)
183+ .height(28 .dp)
184+ .background(MaterialTheme .colorScheme.surfaceVariant)
185+ )
186+ Spacer (Modifier .width(10 .dp))
187+ // Compare tab — outlined chip (no filled background) when unselected
188+ // so it reads visually distinct from the three source chips, plus a
189+ // compare-arrows icon prefix that source tabs don't have.
190+ CompareTabChip (
191+ isSelected = TabKind .COMPARE == selected,
192+ onClick = { onSelect(TabKind .COMPARE ) },
193+ modifier = Modifier .weight(1.1f )
194+ )
195+ }
196+ }
197+
198+ @Composable
199+ private fun SourceTabChip (
200+ label : String ,
201+ color : Color ,
202+ isSelected : Boolean ,
203+ isLive : Boolean ,
204+ onClick : () -> Unit ,
205+ modifier : Modifier = Modifier
206+ ) {
207+ Box (
208+ modifier = modifier
209+ .clip(RoundedCornerShape (10 .dp))
210+ .background(
211+ if (isSelected) color.copy(alpha = 0.18f )
212+ else MaterialTheme .colorScheme.surfaceVariant
213+ )
214+ .clickable { onClick() }
215+ .padding(vertical = 8 .dp, horizontal = 4 .dp),
216+ contentAlignment = Alignment .Center
217+ ) {
218+ Row (
219+ verticalAlignment = Alignment .CenterVertically ,
220+ horizontalArrangement = Arrangement .Center
221+ ) {
222+ if (isLive) {
223+ Box (
224+ modifier = Modifier
225+ .size(7 .dp)
226+ .clip(CircleShape )
227+ .background(color)
228+ )
229+ } else {
230+ Box (
231+ modifier = Modifier
232+ .size(7 .dp)
233+ .clip(CircleShape )
234+ .border(1 .dp, Color (0xFF707070 ), CircleShape )
235+ )
236+ }
237+ Spacer (Modifier .width(5 .dp))
238+ Text (
239+ text = label,
240+ fontSize = 12 .sp,
241+ fontWeight = if (isSelected) FontWeight .Bold else FontWeight .Medium ,
242+ color = if (isSelected) color else MaterialTheme .colorScheme.onSurfaceVariant
243+ )
244+ }
245+ }
246+ }
247+
248+ @Composable
249+ private fun CompareTabChip (
250+ isSelected : Boolean ,
251+ onClick : () -> Unit ,
252+ modifier : Modifier = Modifier
253+ ) {
254+ val accent = MaterialTheme .colorScheme.onSurface
255+ Box (
256+ modifier = modifier
257+ .clip(RoundedCornerShape (10 .dp))
258+ // Outlined when unselected — no filled background, just a border.
259+ // When selected it fills like the source chips so the active
260+ // state still reads obviously.
261+ .then(
262+ if (isSelected) Modifier .background(accent.copy(alpha = 0.12f ))
263+ else Modifier
264+ )
265+ .border(
266+ width = 1 .dp,
267+ color = if (isSelected) accent.copy(alpha = 0.5f ) else accent.copy(alpha = 0.30f ),
268+ shape = RoundedCornerShape (10 .dp)
269+ )
270+ .clickable { onClick() }
271+ .padding(vertical = 8 .dp, horizontal = 4 .dp),
272+ contentAlignment = Alignment .Center
273+ ) {
274+ Row (
275+ verticalAlignment = Alignment .CenterVertically ,
276+ horizontalArrangement = Arrangement .Center
277+ ) {
278+ Icon (
279+ imageVector = Icons .Default .CompareArrows ,
280+ contentDescription = null ,
281+ tint = if (isSelected) accent else accent.copy(alpha = 0.6f ),
282+ modifier = Modifier .size(14 .dp)
283+ )
284+ Spacer (Modifier .width(4 .dp))
285+ Text (
286+ text = " Compare" ,
287+ fontSize = 12 .sp,
288+ fontWeight = if (isSelected) FontWeight .Bold else FontWeight .Medium ,
289+ color = if (isSelected) accent else accent.copy(alpha = 0.7f )
290+ )
291+ }
211292 }
212293}
213294
0 commit comments