---
# System prepended metadata

title: '[Refactor Note] BannerDialog'
tags: [Refactor Note]

---

# Banner
![image](https://hackmd.io/_uploads/rJMPUX_gbx.png)

## Dialog
```
typealias OnBannerDataChanged = (banner: Banner, position: OverlayPosition) -> Unit

@Composable
fun BannerDialog(show: Boolean, onDismiss: () -> Unit, overlayModel: OverlayViewModel) {
    val bannerSetting by overlayModel.banner.collectAsState()
    val banner = bannerSetting?.get()


    var dismissCount by remember { mutableStateOf(0) }
    var localBanner by remember(dismissCount) { mutableStateOf(banner!!.copy()) }
    var overlayPosition by remember(dismissCount) { mutableStateOf(bannerSetting!!.position.toOverlayPosition()) }

    val livestreamData = overlayModel.getOverlaySettings()?.liveStreamData?.liveStream

    FullscreenDialog(
        show = show,
        onDismiss = {
            dismissCount++
            onDismiss()
        },
        onDone = {
            val updatedBanner = localBanner.copy()
            val position = when (overlayPosition) {
                OverlayPosition.BottomStart -> BannerSetting.Position.BOTTOM_START
                OverlayPosition.BottomEnd -> BannerSetting.Position.BOTTOM_END
                else -> throw IllegalStateException("Unsupported position: $overlayPosition")
            }

            overlayModel.updateBannerContent(updatedBanner)
            overlayModel.updateBannerPosition(position)

            // 設為已確認
            bannerSetting!!.confirm = true
            onDismiss()

        },
    ) {
        BannerDialogContent(
            banner = localBanner,
            liveStream = livestreamData!!,
            position = overlayPosition,
            onDataChanged = { newBanner, newPosition ->
                localBanner = newBanner
                overlayPosition = newPosition
            },
        )
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun BannerDialogContent(
    banner: Banner,
    liveStream: LiveStream,
    position: OverlayPosition,
    onDataChanged: OnBannerDataChanged
) {
    TwoColumns(
        first = {
            val keyboard = LocalSoftwareKeyboardController.current
            val focus = LocalFocusManager.current

            TextField(
                value = banner.heading,
                onValueChange = { newVal ->
                    onDataChanged(banner.copy(_heading = newVal), position)
                },
                textStyle = TextStyle.Default.copy(color = Color.LightGray),
                singleLine = true,
                keyboardActions = KeyboardActions { focus.moveFocus(FocusDirection.Next) },
                keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
                placeholder = {
                    Text(
                        text = stringResource(
                            id = R.string.banner_title_placeholder
                        ),
                        color = Color.LightGray.muted,
                    )
                },
            )
            Spacer(modifier = Modifier.height(16.dp))
            TextField(
                value = banner.title,
                onValueChange = { newVal ->
                    onDataChanged(banner.copy(_title = newVal), position)
                },
                textStyle = TextStyle.Default.copy(color = Color.LightGray),
                singleLine = true,
                keyboardActions = KeyboardActions { keyboard?.hide() },
                keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
                placeholder = {
                    Text(
                        text = stringResource(
                            id = R.string.banner_content_placeholder
                        ),
                        color = Color.LightGray.muted,
                    )
                },
            )
        },
        second = {
            Column(
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier
                    .windowInsetsPadding(
                        WindowInsets.safeContent.only(WindowInsetsSides.Right)
                    )
            ) {
                val selectedIndex = Banner.DRAWABLE_MAP.keys.indexOf(banner.drawableId)
                val (firstIndex, lastIndex) = Banner.DRAWABLE_MAP.keys.indices.let {
                    Pair(it.first, it.last)
                }
                val nextEnabled = selectedIndex < lastIndex
                val previousEnabled = selectedIndex > firstIndex

                Row(
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    IconButton(
                        onClick = {
                            val newSelectedIndex = max(firstIndex, selectedIndex - 1)
                            onDataChanged(
                                banner.copy(
                                    _drawableId = Banner.DRAWABLE_MAP.keys.elementAt(
                                        newSelectedIndex
                                    )
                                ), position
                            )
                        },
                        enabled = previousEnabled,
                    ) {
                        Icon(
                            imageVector = Icons.Default.ArrowBackIosNew,
                            contentDescription = stringResource(id = R.string.previous),
                            tint = if (previousEnabled) Color.LightGray else Color.LightGray.muted,
                        )
                    }
                    BannerImage(
                        painter = painterResource(id = banner.drawableId),
                        description = stringResource(id = banner.drawableDescriptionId),
                        modifier = Modifier.weight(1f),
                        title = banner.getTitle(liveStream),
                        heading = banner.getHeading(liveStream),
                    )
                    IconButton(
                        onClick = {
                            val newSelectedIndex = min(lastIndex, selectedIndex + 1)
                            onDataChanged(
                                banner.copy(
                                    _drawableId = Banner.DRAWABLE_MAP.keys.elementAt(
                                        newSelectedIndex
                                    )
                                ), position
                            )
                        },
                        enabled = nextEnabled,
                    ) {
                        Icon(
                            imageVector = Icons.Default.ArrowForwardIos,
                            contentDescription = stringResource(id = R.string.next),
                            tint = if (nextEnabled) Color.LightGray else Color.LightGray.muted,
                        )
                    }
                }
                Spacer(modifier = Modifier.height(16.dp))
                Row {
                    LocationToggleButton(
                        position = OverlayPosition.BottomStart,
                        checked = position == OverlayPosition.BottomStart,
                        onCheckedChange = {
                            onDataChanged(banner, OverlayPosition.BottomStart)
                        },
                    )
                    LocationToggleButton(
                        position = OverlayPosition.BottomEnd,
                        checked = position == OverlayPosition.BottomEnd,
                        onCheckedChange = {
                            onDataChanged(banner, OverlayPosition.BottomEnd)
                        },
                    )
                }
            }
        },
    )
}

@Composable
fun BannerImage(
    painter: Painter,
    description: String,
    heading: String,
    title: String,
    modifier: Modifier = Modifier,
    onDrawn: () -> Unit = {},
) {
    val density = LocalDensity.current
    val size = with(density) {
        painter.intrinsicSize.let { DpSize(it.width.toDp(), it.height.toDp()) }
    }
    var imageSize by remember { mutableStateOf(IntSize.Zero) }
    var position by remember { mutableStateOf(Offset.Zero) }

    BoxWithConstraints(modifier = modifier.size(size)) {
        constraints
        Image(
            modifier = Modifier
                .align(Alignment.Center)
                .onGloballyPositioned {
                    imageSize = it.size
                    position = it.positionInParent()
                },
            painter = painter,
            contentDescription = description,
            contentScale = ContentScale.Fit,
        )
        if (imageSize.width == 0 || imageSize.height == 0) {
            return@BoxWithConstraints
        }
        val scaleX = imageSize.width.toFloat() / painter.intrinsicSize.width * 3 / density.density
        val scaleY = imageSize.height.toFloat() / painter.intrinsicSize.height * 3 / density.density

        val positionOffset = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) }
        val offsetX = positionOffset.x + 32.dp * scaleX
        val headingTextWidth =
            with(density) { (painter.intrinsicSize.width.toDp()) * 3 / 4 - offsetX }
        val titleTextWidth =
            with(density) { painter.intrinsicSize.width.toDp() - offsetX }

        Text(
            text = heading,
            fontSize = 9.sp * scaleX,
            modifier = Modifier
                .align(Alignment.TopStart)
                .offset(offsetX, positionOffset.y + 0.dp * scaleY)
                .width(headingTextWidth * scaleX),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            textAlign = TextAlign.Start,
        )

        Text(
            text = title,
            fontSize = 14.sp * scaleX,
            modifier = Modifier
                .align(Alignment.BottomStart)
                .offset {
                    IntOffset(
                        offsetX.roundToPx(),
                        (positionOffset.y - 4.dp * scaleY).roundToPx(),
                    )
                }
                .width(titleTextWidth * scaleX),
            color = Color.White,
            maxLines = 1,
            fontWeight = FontWeight.Bold,
            overflow = TextOverflow.Ellipsis,
            textAlign = TextAlign.Start,
        )

        LaunchedEffect(imageSize) {
            onDrawn()
        }
    }
}
```
## Position
```
enum class OverlayPosition {
    TopStart,
    TopEnd,
    BottomStart,
    BottomEnd,
}
```

---

# 重構步驟
## 1. Extract Composable Components（提取組件）
* BannerDialogContent
* BannerTextField
* BannerNavigation
* BannerImage
* BannerPositionSelector

## 2. 重構 BannerPositionSelector
```
@Composable
fun BannerPositionSelector(
    modifier: Modifier,
    position: OverlayPosition,
    onPositionChange: (OverlayPosition) -> Unit
) {
    Row(modifier = modifier, horizontalArrangement = Arrangement.Center) {
        LocationToggleButton(
            position = OverlayPosition.BottomStart,
            checked = position == OverlayPosition.BottomStart,
            onCheckedChange = {
                onPositionChange(OverlayPosition.BottomStart)
            },
        )
        LocationToggleButton(
            position = OverlayPosition.BottomEnd,
            checked = position == OverlayPosition.BottomEnd,
            onCheckedChange = {
                onPositionChange(OverlayPosition.BottomEnd)
            },
        )
    }
}
```
* Extract Callback Responsibility（提取回呼職責）
把 OnBannerDataChanged 原本負責的兩件事拆出來，拆分出 Banner、Position，Position 更新變成獨立的 callback。
* Separate Concerns（關注點分離）
原本單一 callback 承擔兩件事，把它拆分，讓每個 callback 對應單一邏輯。
* Decouple State Handlers（解耦狀態處理）
把 position 變更和 banner 變更從耦合狀態解放出來，讓 UI 與邏輯更乾淨。
* Improve API Cohesion（提升介面內聚性）
BannerPositionSelector 原本要傳 banner，但它根本不該關心 banner。
讓它專注於 position → 介面內聚程度提升。
* Extract Stateless UI Component（提取無狀態 UI 元件）
    BannerPositionSelector 不再需要 banner → 真正純 UI
* Refine State Flow（細緻化狀態流動）
資料流向被拆分後，BannerDialog 的狀態更新邏輯更直覺。

---

## 3. 重構 BannerNavigation
原始版本的 `BannerNavigation`：

* 同時處理 圖片索引邏輯
* 管理 上一張 / 下一張按鈕 UI
* 呼叫 `onDataChanged` callback

且左右兩個 `IconButton` 幾乎是複製貼上，只差在 icon 與字串資源，導致：

* UI 與狀態邏輯耦合度過高
* 重複程式碼多，維護成本高
* 主要 Composable 可讀性差（掩蓋了真正重要的「切換邏輯」）
```
@Composable
fun BannerNavigation(
    banner: Banner,
    liveStream: LiveStream,
    position: OverlayPosition,
    onDataChanged: OnBannerDataChanged
){
    val selectedIndex = Banner.Companion.DRAWABLE_MAP.keys.indexOf(banner.drawableId)
    val (firstIndex, lastIndex) = Banner.Companion.DRAWABLE_MAP.keys.indices.let {
        Pair(it.first, it.last)
    }
    val nextEnabled = selectedIndex < lastIndex
    val previousEnabled = selectedIndex > firstIndex

    Row(
        verticalAlignment = Alignment.Companion.CenterVertically,
    ) {
        IconButton(
            onClick = {
                val newSelectedIndex = max(firstIndex, selectedIndex - 1)
                onDataChanged(
                    banner.copy(
                        _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt(
                            newSelectedIndex
                        )
                    ), position
                )
            },
            enabled = previousEnabled,
        ) {
            Icon(
                imageVector = Icons.Default.ArrowBackIosNew,
                contentDescription = stringResource(id = R.string.previous),
                tint = if (previousEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted,
            )
        }
        BannerImage(
            painter = painterResource(id = banner.drawableId),
            description = stringResource(id = banner.drawableDescriptionId),
            modifier = Modifier.Companion.weight(1f),
            title = banner.getTitle(liveStream),
            heading = banner.getHeading(liveStream),
        )
        IconButton(
            onClick = {
                val newSelectedIndex = min(lastIndex, selectedIndex + 1)
                onDataChanged(
                    banner.copy(
                        _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt(
                            newSelectedIndex
                        )
                    ), position
                )
            },
            enabled = nextEnabled,
        ) {
            Icon(
                imageVector = Icons.Default.ArrowForwardIos,
                contentDescription = stringResource(id = R.string.next),
                tint = if (nextEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted,
            )
        }
    }
}

```
### 3.1 拆 UI 元件：PrevButton / NextButton
第一步，把「上一張 / 下一張」這兩顆按鈕從 `BannerNavigation` 中抽出來，先做一個粗顆粒度的切分：
```
@Composable
private fun PreviousButton(
    firstIndex: Int,
    selectedIndex: Int,
    onDataChanged: OnBannerDataChanged,
    position: OverlayPosition,
    previousEnabled: Boolean
) 
```

```
@Composable
private fun NextButton(
    lastIndex: Int,
    selectedIndex: Int,
    onDataChanged: OnBannerDataChanged,
    position: OverlayPosition,
    nextEnabled: Boolean
)
```

這個階段的按鈕元件還是知道：

* `firstIndex` / `lastIndex`
* `selectedIndex`
* `position`
* `onDataChanged`

也就是說，它仍然混有「邏輯 + UI」，但至少可視化結構已經被拆出來：

重構目標
* 將左右按鈕抽出為可組合的 UI 元件
* 將重複邏輯去除（DRY 原則）
* `BannerNavigation` 不再直接塞滿 IconButton 細節， 只負責 orchestrate UI，而不被 UI 細節干擾，閱讀時可以先聚焦在「流程」。

### 3.2 精煉按鈕：PreviousButton / NextButton
拆分後的按鈕只需要：

* `enabled`
* `onClick`

這讓 component 已經達到「UI 純粹化」，不再知道 banner 或 position 是什麼。

```
@Composable
private fun PreviousButton(
    previousEnabled: Boolean,
    onClick: () -> Unit
) {
    IconButton(
        onClick = onClick,
        enabled = previousEnabled,
    ) {
        Icon(
            imageVector = Icons.Default.ArrowBackIosNew,
            contentDescription = stringResource(id = R.string.previous),
            tint = if (previousEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted,
        )
    }
}
```

```
@Composable
private fun NextButton(
    nextEnabled: Boolean,
    onClick: () -> Unit
) {
    IconButton(
        onClick = onClick,
        enabled = nextEnabled,
    ) {
        Icon(
            imageVector = Icons.Default.ArrowForwardIos,
            contentDescription = stringResource(id = R.string.next),
            tint = if (nextEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted,
        )
    }
}
```

### 3.3 再抽象化：提取共用 NavigationButton
進一步觀察可以發現：

* PreviousButton / NextButton 仍有 95% 內容相同
* 差別只有：

  * icon
  * contentDescription 的字串

所以進一步提煉成更 generic 的按鈕元件：

```kotlin
@Composable
private fun NavigationButton(
    imageVector: ImageVector,
    labelRes: Int,
    enabled: Boolean,
    onClick: () -> Unit
) {
    IconButton(enabled = enabled, onClick = onClick) {
        Icon(
            imageVector = imageVector,
            contentDescription = stringResource(id = labelRes),
            tint = if (enabled) Color.LightGray else Color.LightGray.muted,
        )
    }
}
```

而 Previous/Next 變成 thin wrapper：

```kotlin
@Composable
private fun PreviousButton(enabled: Boolean, onClick: () -> Unit) =
    NavigationButton(Icons.Default.ArrowBackIosNew, R.string.previous, enabled, onClick)

@Composable
private fun NextButton(enabled: Boolean, onClick: () -> Unit) =
    NavigationButton(Icons.Default.ArrowForwardIos, R.string.next, enabled, onClick)
```

### 3.4 BannerNavigation UI 更乾淨
```kotlin
@Composable
fun BannerNavigation(
    banner: Banner,
    liveStream: LiveStream,
    position: OverlayPosition,
    onDataChanged: OnBannerDataChanged
) {
    val selectedIndex = Banner.Companion.DRAWABLE_MAP.keys.indexOf(banner.drawableId)
    val (firstIndex, lastIndex) = Banner.Companion.DRAWABLE_MAP.keys.indices.let {
        Pair(it.first, it.last)
    }
    val nextEnabled = selectedIndex < lastIndex
    val previousEnabled = selectedIndex > firstIndex

    Row(
        verticalAlignment = Alignment.Companion.CenterVertically,
    ) {
        NavigationButton(
            imageVector = Icons.Default.ArrowBackIosNew,
            id = R.string.previous,
            enabled = previousEnabled,
            onClick = {
                val newSelectedIndex = max(firstIndex, selectedIndex - 1)
                onDataChanged(
                    Banner(
                        _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt(
                            newSelectedIndex
                        )
                    ), position
                )
            })

        BannerImage(
            painter = painterResource(id = banner.drawableId),
            description = stringResource(id = banner.drawableDescriptionId),
            modifier = Modifier.Companion.weight(1f),
            title = banner.getTitle(liveStream),
            heading = banner.getHeading(liveStream),
        )

        NavigationButton(
            imageVector = Icons.Default.ArrowForwardIos,
            id = R.string.next,
            enabled = nextEnabled,
            onClick = {
                val newSelectedIndex = min(lastIndex, selectedIndex + 1)
                onDataChanged(
                    Banner(
                        _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt(
                            newSelectedIndex
                        )
                    ), position
                )
            })
    }
}
```

```kotlin
@Composable
private fun NavigationButton(
    imageVector: ImageVector,
    id: Int,
    enabled: Boolean,
    onClick: () -> Unit
) {
    IconButton(
        onClick = onClick,
        enabled = enabled,
    ) {
        Icon(
            imageVector = imageVector,
            contentDescription = stringResource(id = id),
            tint = if (enabled) Color.Companion.LightGray else Color.Companion.LightGray.muted,
        )
    }
}
```

* UI 與邏輯解耦
  `BannerNavigation` 專心處理索引與狀態更新；按鈕元件專心畫 UI。
* 提高可讀性
  打開檔案時，一眼就能看出「上一張 → Banner → 下一張」的結構與行為。
* 消除重複程式碼
  Previous / Next 共同邏輯下沉到 `NavigationButton`，修改樣式時不會漏改。
* 元件可重用性提升
  `NavigationButton` 可以在其他需要左右切換的 UI 重用。
  
---


## 4. 抽象化 Banner Navigation 邏輯
在前一章完成 UI 元件拆分 與 按鈕抽象化 之後，可以觀察到：
`BannerNavigation` 裡仍存在一段業務邏輯：

* 以 DrawableId 形成列表
* 找出當前 banner 的 index
* 判斷是否能往前／往後
* 根據 index 返回下一個 drawableId

這整段邏輯本質上是 列表索引的導航行為，跟 banner 本身無關，也跟 UI 無關。
因此，可以進一步把這段邏輯抽出成一個獨立類別，形成可重用的邏輯模組。

---
### 4.1 抽象化 Navigation：導入 BannerNavigator
第一步，先讓 Banner 的索引邏輯從 UI 中脫離，形成 `BannerNavigator`：

```kotlin
class BannerNavigator(private val banner: Banner) {

    private val keys = Banner.DRAWABLE_MAP.keys.toList()
    private val index = keys.indexOf(banner.drawableId)

    val canPrev: Boolean get() = index > 0
    val canNext: Boolean get() = index < keys.lastIndex

    fun prev(): Banner {
        val newIndex = (index - 1).coerceAtLeast(0)
        return banner.copy(_drawableId = keys[newIndex])
    }

    fun next(): Banner {
        val newIndex = (index + 1).coerceAtMost(keys.lastIndex)
        return banner.copy(_drawableId = keys[newIndex])
    }
}
```

對應在 UI 裡的使用方式：

```kotlin
@Composable
fun BannerNavigation(
    banner: Banner,
    liveStream: LiveStream,
    position: OverlayPosition,
    onDataChanged: OnBannerDataChanged
) {
    val navigator = remember(banner) { BannerNavigator(banner) }

    Row(verticalAlignment = Alignment.CenterVertically) {
        NavigationButton(
            imageVector = Icons.Default.ArrowBackIosNew,
            id = R.string.previous,
            enabled = navigator.canPrev,
            onClick = { onDataChanged(navigator.prev(), position) }
        )

        BannerImage(/* ... */)

        NavigationButton(
            imageVector = Icons.Default.ArrowForwardIos,
            id = R.string.next,
            enabled = navigator.canNext,
            onClick = { onDataChanged(navigator.next(), position) }
        )
    }
}
```

這樣 UI 已經非常乾淨，但導覽邏輯依然綁死在 Banner 的 domain 上。

### 4.2 泛用化邏輯：導入 ListNavigator`<T>`
為了讓任何 type 都能具備「可前後導航」，可以將邏輯提升成泛型：

```kotlin
class ListNavigator<T>(
    private val items: List<T>,
    private val current: T
) {
    private val index = items.indexOf(current)

    val canPrev: Boolean get() = index > 0
    val canNext: Boolean get() = index < items.lastIndex

    fun prev(): T = items[(index - 1).coerceAtLeast(0)]
    fun next(): T = items[(index + 1).coerceAtMost(items.lastIndex)]
}
```

這類邏輯：

* 不依賴 Banner
* 不依賴 Compose
* 完全是 list navigation 行為

是一個非常純粹的可重用 domain-agnostic 工具類。

---


### 4.3 抽象 UI 組件：Navigation`<T>`
導入泛型 `ListNavigator<T>` 之後，可以再抽象化 UI：
把「左右箭頭 + 中間內容」整個封裝成通用 Composable。

```kotlin
@Composable
fun <T> Navigation(
    modifier: Modifier = Modifier,
    navigator: ListNavigator<T>,
    onChange: (T) -> Unit,
    centerContent: @Composable () -> Unit
) {
    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center
    ) {
        NavigationButton(
            imageVector = Icons.Default.ArrowBackIosNew,
            id = R.string.previous,
            enabled = navigator.canPrev,
            onClick = { onChange(navigator.prev()) }
        )

        centerContent()

        NavigationButton(
            imageVector = Icons.Default.ArrowForwardIos,
            id = R.string.next,
            enabled = navigator.canNext,
            onClick = { onChange(navigator.next()) }
        )
    }
}
```

功能：

* `Navigation` 不需要知道 Banner
* 不需要知道 drawableId
* 不需要知道 position
* 可應用於任何自訂資料列表（e.g. PhotoViewer、Stepper、流程導覽）

----

### 4.4 BannerNavigation 使用泛化後的 Navigation UI

* 從 `onDataChanged(banner, position)` **拆解出更單一職責的 callback**
* `BannerNavigation` 只專注處理 **drawable 切換**
* 不再關心 position、不再回傳整個 banner
* API 更單純、更可測、更好抽換

```kotlin
@Composable
fun BannerNavigation(
    modifier: Modifier,
    banner: Banner,
    liveStream: LiveStream,
    onDrawableChange: (Int) -> Unit
) {
    val drawableList = remember { Banner.DRAWABLE_MAP.keys.toList() }

    val navigator = remember(banner) {
        ListNavigator(drawableList, banner.drawableId)
    }

    Navigation(
        modifier = modifier,
        navigator = navigator,
        onChange = onDrawableChange,
    ) {
        BannerImage(
            painter = painterResource(id = banner.drawableId),
            description = stringResource(id = banner.drawableDescriptionId),
            title = banner.getTitle(liveStream),
            heading = banner.getHeading(liveStream),
        )
    }
}
```

重構後的 BannerNavigation 具有：

* 更高的可讀性
* 最小責任原則（Single Responsibility）
* 完全不涉及 UI 元素細節（NavigationButton 已抽出）
* 也不再涉及 index 計算（由 ListNavigator 處理）

---

### 4.6 最佳資料夾結構（符合 Android / Compose 分層）

不依賴 Compose
是邏輯類
但又與 UI 閱讀方向強相關
最自然放在` ui/common/navigator`

### 4.7 總結
* UI／邏輯徹底解耦
* 可重用的 `ListNavigator<T>` 成為通用工具
* `Navigation<T>` 變成可插入任意 UI 的泛用導覽元件
* feature UI (`BannerNavigation`) 變得更乾淨、可測試、更可維護
* 資料夾結構清晰分層，符合 Android/Compose 的實務習慣
## 5. 重構 BannerTextField

原始版本的 `BannerTextField` 綁定了：

* `Banner` 這個 domain model
* `onDataChanged`（包含 position）
* Banner 的 heading/title 更新邏輯
* TextField UI

導致：

* UI 元件混入 domain 行為
* 不容易重用
* `BannerTextField` 永遠只能用在 BannerFeature 裡
* 測試與預覽都需要完整的 Banner + callback

原始版本範例：

```kotlin
@Composable
fun BannerTextField(
    banner: Banner,
    onDataChanged: OnBannerDataChanged,
    position: OverlayPosition
) {
    TextField(
        value = banner.heading,
        onValueChange = { newVal ->
            onDataChanged(Banner(_heading = newVal), position)
        },
        ...
    )

    TextField(
        value = banner.title,
        onValueChange = { newVal ->
            onDataChanged(Banner(_title = newVal), position)
        },
        ...
    )
}
```

---

### 5.1 拆分責任（Extract Model-Agnostic TextFields）

目的：

* 移除 banner 依賴
* 移除 onDataChanged（含 position）
* 讓 UI 元件只接收純文字與 callback
* 提升 TextField 元件可重用性，變成真正的 UI component

重構後：

```kotlin
@Composable
fun BannerTextField(
    heading: String,
    onHeadingChange: (String) -> Unit,
    title: String,
    onTitleChange: (String) -> Unit
)
```

重點：

* `BannerTextField` 不再需要 Banner
* 不再需要 position
* 不再需要 OnBannerDataChanged
* 變得乾淨、可重用、可預覽
* 更符合 Compose 的無狀態 UI（stateless + callback）

---

### 5.2 重構後在 BannerDialog 的使用方式

使用時由 Feature 層自行組合資料模型：

```kotlin
BannerTextField(
    heading = banner.heading,
    onHeadingChange = { newHeading ->
        onDataChanged(banner.copy(_heading = newHeading), position)
    },
    title = banner.title,
    onTitleChange = { newTitle ->
        onDataChanged(banner.copy(_title = newTitle), position)
    }
)
```

這樣：

* UI function 保持乾淨
* domain 更新邏輯保持在 Feature 層
* 責任明确分離

---

### 5.3 重構價值
* UI 與 domain 解耦
`BannerTextField` 變成通用元件，不再硬依賴 Banner。
* 可重用性提升
任何需要兩個 TextField 的 UI 都能使用它，不再限於 banner 介面。
* 更符合 Compose stateless 原則
所有狀態由外部 owner（ViewModel / Dialog）管理。
* 更容易測試
UI 元件不需 mock Banner 或 Position，測試 TextField 行為變簡單。
* 可預覽（Preview）更容易
Preview 不用建立複雜 Banner、OverlayPosition。

---

 