メむンコンテンツぞスキップ
芋出し画像

Androidアプリ制䜜 #14タむマヌアプリ改造蚈画⑊ ワンタッチタむマヌ、぀いに完成

    ■ はじめに

    い぀もありがずうございたす
    たっさんです

    今日もAndroidアプリを
    改造しおいきたす

    そしお今回は  

    ワンタッチタむマヌ改造蚈画、
    ぀いに最終回です

    最初はカップラヌメン専甚の
    3分タむマヌだったのですが  

    気が぀けば、いろいろな機胜を
    远加しおいたした

    前回たでに、

    • 奜きな時間を登録できる
      カスタムボタン

    • ボタンに奜きな画像を
      蚭定する機胜

    • タむマヌの壁玙を
      倉曎する機胜

    • ボタンの配眮を入れ
      替える機胜

    などを実装しおきたした。

    今回は新しい機胜を
    どんどん远加するのではなく、

    芋た目ず䜿いやすさを䞭心に
    ブラッシュアップしおいきたす

    なお、今回はコヌドの詳しい
    解説は省略したす。

    完成したコヌドは、
    蚘事の最埌に付録ずしお
    掲茉したすので、

    気になる方は芋おください

    画像
    完成したアプリは
    こんな感じです

    ■ ① 画面のレむアりトを芋盎しおみた


    たず気になったのが、
    画面党䜓のバランス
    です。

    前回たでの画面では、
    タむマヌ衚瀺やボタンが
    䞊の方に集たっおいたした。

    その䞀方で、
    画面の䞋には倧きな䜙癜が  

    せっかく壁玙を自由に
    倉曎できるようにしたのに、

    なんだか少し
    もったいない感じです

    そこで今回は、
    時間衚瀺ずプリセットボタンの
    間隔を広げおみたした。

    ボタンの配眮を少し䞋げるこずで、
    画面党䜓のバランスを調敎

    これだけでも、
    かなり印象が倉わりたした。

    機胜は同じでも、配眮を倉えるだけで
    䜿いやすさや芋た目が倉わるのですね

    画像
    前回たでの画面
    画像
    完成版の画面

    ■ ② ボタンの芋やすさを改善

    次に気になったのは、
    ボタンの芋やすさです。

    今回のアプリは、
    奜きな画像を壁玙にできたす。

    しかし  

    明るい壁玙を蚭定するず、
    ボタンや文字が背景に
    埋もれおしたうこずがありたす。

    特に「蚭定倉曎」ボタンは、
    背景によっおは
    かなり芋えにくい状態でした。

    そこで今回は、
    蚭定倉曎ボタンの色を倉曎

    さらに、操䜜ボタンの埌ろに
    半透明の背景を远加したした。

    これなら壁玙の雰囲気を
    残しながら、ボタンも
    芋やすくなりたす。

    そしお、
    プリセットボタンにも
    黒い枠線を远加

    以前の癜い枠線よりも
    ボタンの茪郭がはっきりしたした。

    こうした现かい調敎も、
    倧切なのですね。


    ■ ③ プリセットボタンが増えおも倧䞈倫

    さお  

    カスタムボタンを自由に
    远加できるようになったのは
    良いのですが、

    ここで䞀぀問題が。

    ボタンを远加し続けたら、
    画面がどんどん長くなるのでは

    ずいうこずです

    実際、ボタンが増えるたびに
    画面が瞊に䌞びおしたうのは、
    あたり䜿いやすくありたせん。

    そこで今回は、
    プリセット䞀芧に
    スクロヌル機胜を远加したした

    仕様はシンプルです。

    ボタンが6個たでは通垞衚瀺。

    7個以䞊になるず、
    䞀芧の䞭をスクロヌルできる

    ずいう仕組みです。

    これならボタンが増えおも、
    タむマヌの操䜜郚分が
    どんどん䞋ぞ移動するのを
    抑えられたす。

    ちなみに、
    ボタンをスクロヌルしおいる間も
    タむマヌのカりントダりンは
    そのたた続きたす。

    奜きなタむマヌを
    いく぀も登録できるようになり、
    䜿い勝手が良くなりたした

    画像
    個以䞊で自動的に
    スクロヌルモヌドに切り替わる

    スクロヌルを远加したので
    ボタンが増えおも倧䞈倫


    ■ ④ 誀操䜜を防ぐための改良

    そしお最埌に、
    もう䞀぀気になる問題が
    芋぀かりたした。

    それは  

    カりントダりン䞭に
    別のタむマヌボタンを抌すず、
    時間が再蚭定されおしたうこず

    䟋えば、
    ラヌメンの3分タむマヌを
    䜿っおいる途䞭で、

    間違えおパスタのボタンを
    抌しおしたったら  

    7分に倉曎されおしたいたす

    これは困りたすね  

    䞀時停止䞭も同じ問題が
    発生しおいたした。

    そこで今回は、
    カりントダりン䞭ず䞀時停止䞭は
    プリセットボタンを
    操䜜できないようにしたした。

    䞀床タむマヌを開始したら、
    リセットするたで
    別の時間を蚭定できたせん。

    もちろん、
    䞀時停止や再開は
    今たでどおり䜿えたす。

    これなら誀っお別のボタンに
    觊れおしたっおも安心です。

    䟿利なアプリを䜜るには、
    誀操䜜を防ぐこずも倧切なのですね


    ■ â‘€ ワンタッチタむマヌ、぀いに完成

    ずいうこずで  

    ワンタッチタむマヌ、
    ひずたず完成です

    最初はカップラヌメン専甚の
    3分タむマヌでしたが、

    珟圚はこんなこずが
    できるようになりたした

    • ワンタッチでタむマヌを開始

    • 通垞タむマヌずの切り替え

    • 䞀時停止・再開・リセット

    • 奜きな時間のカスタムタむマヌを远加

    • カスタムボタンの名前や画像を倉曎

    • 奜きな画像を壁玙に蚭定

    • プリセットボタンの配眮倉曎

    • ボタンが7個以䞊になった堎合のスクロヌル

    • カりントダりン䞭の誀操䜜防止

    いやぁ  

    ずいぶん機胜が増えたしたね

    最初に䜜ったタむマヌず
    芋比べおみるず、

    同じアプリずは思えないほど
    倉わっおしたいたした

    ただ现かい改善点は
    あるかもしれたせんが、

    今回はここで完成ずしたす

    画像
    ぀いに完成

    ■ ⑥ AIず䞀緒にアプリを䜜っおみた感想

    さお  

    今回のタむマヌ制䜜を
    振り返っおみたいず思いたす。

    そもそも、
    最初に考えおいたのは、

    「䟿利なアプリっお䜕だろう」

    ずいうこずでした。

    䜕か䜜っおみたいけど、
    䜕を䜜ればいいのか
    なかなか思い぀かない  

    そんな䞭で、

    ずりあえずカップラヌメン専甚の
    3分タむマヌでも䜜っおみよう

    ず思ったのが始たりです

    ずころが  

    実際に䜜っお䜿っおみるず、
    これが意倖に䟿利だったのです。

    だったら、

    「うどん甚の5分タむマヌも
    あったら䟿利じゃない」

    「パスタ甚の7分も欲しいな  」

    「奜きな時間を登録できたら」

    「画像も倉曎できたら面癜いかも」

    ず、次々にやりたいこずが
    増えおいきたした

    そしお気が぀けば、
    今のようなアプリになっおいたした。

    ただ  

    機胜を远加すればするほど、
    良いアプリになるずは限りたせん。

    実際、機胜を远加するたびに
    コヌドが耇雑になっおしたい、

    途䞭でAIにコヌドを
    敎理し盎しおもらうこずも
    ありたした。

    たた、機胜を远加しおも、
    画面が芋づらかったり、
    操䜜しにくかったり  

    思っおいたずおりに
    いかないこずもありたした。

    今回の制䜜で感じたのは、

    「動くアプリ」ず
    「䜿いやすいアプリ」は違う

    ずいうこずです。

    そしお、もう䞀぀。

    AIを䜿えば、
    プログラミング初心者でも
    比范的短時間でコヌドを
    䜜るこずができたす。

    しかし、
    AIが䜜ったコヌドを
    そのたた䜿えば良いずいう
    わけではありたせん。

    実際に動かしお、
    問題がないか確認しお、
    必芁なら修正する。

    この繰り返しが
    ずおも倧切なのだず感じたした。

    今回も、AIに䜕床も
    助けおもらいたした

    ずはいえ  

    䜕を䜜りたいのか、
    どこたで䜜るのかを決めるのは
    自分自身です。

    䟿利だからずいっお、
    機胜を远加し続けおいたら
    い぀たでたっおも完成したせん

    なので今回は、
    このあたりで䞀区切りに
    したいず思いたす


    ■ ⑩ 完成したけど、公開はただ先

    さお

    ワンタッチタむマヌは
    ひずたず完成したしたが  

    これでGoogle Playに
    公開できるわけではありたせん。

    実機での動䜜確認や、
    Google Playぞの開発者登録、
    必芁なテストや審査など  

    公開するたでには、
    ただただ乗り越えないず
    いけない壁がありたす

    特に、アプリを公開するずなるず、
    自分のスマホで動けば良い
    ずいうわけにはいきたせん。

    いろいろな端末での動䜜や、
    䞍具合ぞの察応なども
    考える必芁がありたす。

    ただただ勉匷するこずが
    たくさんありたすね  

    それでも、

    「これなら公開しおもいいかな」

    ず思える䜜品を
    䞀぀䜜るこずができたのは、

    自分にずっお
    倧きな䞀歩だず思いたす

    公開に぀いおは、
    たた改めお挑戊しおみたいず
    思っおいたす。

    ■ 今日のマンガ

    画像

    ■ 次回からはゲヌムアプリ制䜜に挑戊

    ずいうこずで、
    ワンタッチタむマヌの
    改造蚈画は今回で終了です

    ここたでお付き合いいただき、
    ありがずうございたした

    そしお次回からは  

    AIず䞀緒に、
    ゲヌムアプリを䜜っおいきたす

    ずはいえ、
    いきなり倧䜜ゲヌムを䜜る
    ぀もりはありたせん

    たずは、
    単玔な仕組みで比范的短期間に
    䜜れそうなゲヌムに
    挑戊しおみたいず思いたす。

    タむマヌアプリで孊んだこずを
    どこたで掻かせるのか

    そしお、
    ゲヌムアプリではどんな問題が
    埅ち受けおいるのか

    今から楜しみですね

    最埌たで読んでいただき
    ありがずうございたす

    それではたた


    ■ 付録ワンタッチタむマヌ完成版コヌド

    今回の改造を反映した
    Kotlinコヌドを掲茉したす。

    コヌドが長くなっおいるため、
    今回は詳しい解説を省略したす。

    これたでの制䜜過皋や
    コヌドの倉曎点に぀いおは、
    過去の蚘事をご芧ください。

    ※今回掲茉するコヌドは、
    ワンタッチタむマヌの
    䞀時完成版です。

    Google Playぞの公開に
    必芁な動䜜確認やテストが
    完了したものではありたせん。

    ▌今回完成したコヌド▌

    package com.example.myapplication6
    
    import android.content.Context
    import android.graphics.Bitmap
    import android.graphics.BitmapFactory
    import android.graphics.ImageDecoder
    import android.graphics.Matrix
    import android.media.ExifInterface
    import android.net.Uri
    import android.os.Build
    import android.media.AudioManager
    import android.media.ToneGenerator
    import android.os.Bundle
    import android.os.SystemClock
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.activity.compose.rememberLauncherForActivityResult
    import androidx.activity.result.contract.ActivityResultContracts
    import androidx.compose.foundation.BorderStroke
    import androidx.compose.foundation.Image
    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.rememberScrollState
    import androidx.compose.foundation.shape.RoundedCornerShape
    import androidx.compose.foundation.text.KeyboardOptions
    import androidx.compose.foundation.verticalScroll
    import androidx.compose.material3.*
    import androidx.compose.runtime.*
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.graphics.ImageBitmap
    import androidx.compose.ui.graphics.asImageBitmap
    import androidx.compose.ui.layout.ContentScale
    import androidx.compose.ui.platform.LocalContext
    import androidx.compose.ui.res.painterResource
    import androidx.compose.ui.text.input.KeyboardType
    import androidx.compose.ui.unit.dp
    import androidx.compose.ui.unit.sp
    import androidx.compose.ui.text.font.FontWeight
    import kotlinx.coroutines.delay
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.CancellationException
    import kotlinx.coroutines.currentCoroutineContext
    import kotlinx.coroutines.ensureActive
    import kotlinx.coroutines.launch
    import kotlinx.coroutines.withContext
    import java.io.File
    import java.io.IOException
    import org.json.JSONArray
    import org.json.JSONObject
    import java.util.Locale
    import java.util.UUID
    
    // 1. デヌタ暙準ずカスタムを同じ型・同じ䞀芧で管理する。
    // imageUriにはアプリ内ぞコピヌした画像のファむル名を保存する。
    // bodyImageUriにはワンタッチタむマヌの壁玙画像のファむル名を保存する。
    data class TimerPreset(
        val id: Long,
        val name: String,
        val durationSeconds: Int,
        val imageUri: String? = null,
        val builtInKey: String? = null
    ) {
        val isBuiltIn: Boolean get() = builtInKey != null
    }
    
    data class TimerSettings(
        val buttons: List<TimerPreset> = defaultButtons(),
        val bodyImageUri: String? = null
    )
    
    private fun defaultButtons() = listOf(
        TimerPreset(-1L, "ラヌメン", 180, builtInKey = "ramen"),
        TimerPreset(-2L, "うどん", 300, builtInKey = "udon"),
        TimerPreset(-3L, "パスタ", 420, builtInKey = "pasta")
    )
    
    private fun newPresetId(existing: List<TimerPreset>): Long {
        var id: Long
        do { id = UUID.randomUUID().mostSignificantBits and Long.MAX_VALUE }
        while (id == 0L || existing.any { it.id == id })
        return id
    }
    
    private fun formatTime(seconds: Int): String =
        String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60)
    
    // 2. 保存UIからJSONやSharedPreferencesの詳现を切り離す。
    // 新しいキヌに保存するため、旧itemsを䞊曞きせずに移行できる。
    private class TimerRepository(context: Context) {
        private val prefs = context.getSharedPreferences("timer_presets", Context.MODE_PRIVATE)
    
        fun load(): TimerSettings {
            val raw = prefs.all[SETTINGS_KEY] as? String
            if (raw != null) {
                runCatching {
                    val root = JSONObject(raw)
                    val array = root.getJSONArray("buttons")
                    val buttons = (0 until array.length()).map { index ->
                        decodePreset(array.getJSONObject(index))
                    }
                    require(buttons.map { it.id }.distinct().size == buttons.size)
                    require(defaultButtons().all { original ->
                        buttons.count { it.builtInKey == original.builtInKey } == 1
                    })
                    return TimerSettings(buttons, root.optionalString("bodyImageUri"))
                }
            }
            // 旧版のitemsはStringSetずJSON文字列の䞡方を読み蟌む。
            val legacy = when (val value = prefs.all["items"]) {
                is Set<*> -> value.filterIsInstance<String>().sorted().mapNotNull { rawItem ->
                    val parts = rawItem.split(":", limit = 3)
                    if (parts.size != 3) null else {
                        val id = parts[0].toLongOrNull()
                        val seconds = parts[1].toIntOrNull()
                        if (id == null || seconds == null || seconds !in 1..5999 || parts[2].isBlank()) {
                            null
                        } else TimerPreset(id, parts[2], seconds)
                    }
                }
                is String -> runCatching {
                    val array = JSONArray(value)
                    (0 until array.length()).mapNotNull { index ->
                        runCatching { decodePreset(array.getJSONObject(index)).copy(builtInKey = null) }
                            .getOrNull()
                    }
                }.getOrDefault(emptyList())
                else -> emptyList()
            }
            val merged = defaultButtons().toMutableList()
            legacy.forEach { preset ->
                merged.add(if (merged.any { it.id == preset.id }) {
                    preset.copy(id = newPresetId(merged))
                } else preset)
            }
            return TimerSettings(buttons = merged)
        }
    
        fun save(settings: TimerSettings) {
            val array = JSONArray()
            settings.buttons.forEach { preset ->
                array.put(JSONObject().apply {
                    put("id", preset.id)
                    put("name", preset.name)
                    put("durationSeconds", preset.durationSeconds)
                    put("imageUri", preset.imageUri ?: JSONObject.NULL)
                    put("builtInKey", preset.builtInKey ?: JSONObject.NULL)
                })
            }
            val root = JSONObject().apply {
                put("version", 2)
                put("buttons", array) // 配列の順番がそのたた画面のボタン順になる。
                put("bodyImageUri", settings.bodyImageUri ?: JSONObject.NULL)
            }
            prefs.edit().putString(SETTINGS_KEY, root.toString()).apply()
        }
    
        private fun decodePreset(json: JSONObject): TimerPreset {
            val seconds = if (json.has("durationSeconds")) json.getInt("durationSeconds")
            else json.getInt("seconds")
            val name = json.getString("name")
            require(seconds in 1..5999 && name.isNotBlank())
            val key = json.optionalString("builtInKey")
            require(key == null || key in setOf("ramen", "udon", "pasta"))
            return TimerPreset(json.getLong("id"), name, seconds,
                json.optionalString("imageUri"), key)
        }
    
        private fun JSONObject.optionalString(key: String): String? =
            if (!has(key) || isNull(key)) null else getString(key).takeIf { it.isNotBlank() }
    
        companion object { private const val SETTINGS_KEY = "settings_v2" }
    }
    
    // 3. 蚈枬数字入力・開始・停止・リセットをここに集玄。
    // 画面を増やしおも、同じ蚈枬ルヌルを共有できる。
    @Stable
    private class TimerController {
        var remainingSeconds by mutableIntStateOf(0)
            private set
        var inputNumber by mutableIntStateOf(0)
            private set
        var isRunning by mutableStateOf(false)
            private set
        var hasStarted by mutableStateOf(false)
            private set
        var sessionId by mutableIntStateOf(0)
            private set
        private var deadlineMillis = 0L
    
        val canInput: Boolean get() = !hasStarted
        val displayText: String get() = if (hasStarted) formatTime(remainingSeconds)
        else String.format(Locale.ROOT, "%02d:%02d", inputNumber / 100, inputNumber % 100)
    
        fun inputDigit(digit: Int) {
            if (canInput && digit in 0..9 && inputNumber < 1000) {
                inputNumber = inputNumber * 10 + digit
            }
        }
    
        fun selectPreset(seconds: Int) {
            if (hasStarted) return // 開始・䞀時停止・終了埌は別のプリセットで䞊曞きしない。
            require(seconds > 0)
            remainingSeconds = seconds
            inputNumber = 0
            hasStarted = true
            start()
        }
    
        fun toggle() {
            if (isRunning) {
                updateRemaining()
                isRunning = false
            } else {
                if (!hasStarted) {
                    // 入力䞭は99:99たで衚瀺。開始時だけ実際の秒数ぞ倉換。
                    remainingSeconds = (inputNumber / 100) * 60 + inputNumber % 100
                    if (remainingSeconds == 0) return
                    inputNumber = 0
                    hasStarted = true
                }
                if (remainingSeconds > 0) start()
            }
        }
    
        fun reset() {
            isRunning = false
            remainingSeconds = 0
            inputNumber = 0
            hasStarted = false
            sessionId++
        }
    
        private fun start() {
            deadlineMillis = SystemClock.elapsedRealtime() + remainingSeconds * 1000L
            sessionId++ // 蚈枬䞭に別のボタンを抌した堎合も埅機凊理をやり盎す。
            isRunning = true
        }
    
        // 時刻ずの差から残りを算出し、delayの遅れが積み重ならないようにする。
        private fun updateRemaining() {
            val millis = (deadlineMillis - SystemClock.elapsedRealtime()).coerceAtLeast(0L)
            remainingSeconds = ((millis + 999L) / 1000L).toInt()
        }
    
        fun tick(): Boolean {
            if (!isRunning) return false
            updateRemaining()
            if (remainingSeconds > 0) return false
            isRunning = false
            return true // 完了音を鳎らすのは完了した1回だけ。
        }
    }
    
    // 4. アプリの入口・画面共通凊理。
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent { MaterialTheme { CustomTimer() } }
        }
    }
    
    @Composable
    fun CustomTimer() {
        val context = LocalContext.current
        val repository = remember(context) { TimerRepository(context.applicationContext) }
        var settings by remember { mutableStateOf(repository.load()) }
        val imageStore = remember(context) { ButtonImageStore(context.applicationContext) }
        val timer = remember { TimerController() }
        // 線集キャンセルや差し替えで䞍芁になったコピヌは、次回起動時に片付ける。
        LaunchedEffect(imageStore) {
            withContext(Dispatchers.IO) {
                imageStore.deleteUnused(settings.buttons.mapNotNull { it.imageUri } + listOfNotNull(settings.bodyImageUri))
            }
        }
        var normalMode by remember { mutableStateOf(false) }
        var showEditor by remember { mutableStateOf(false) }
        var editingPreset by remember { mutableStateOf<TimerPreset?>(null) }
        var showDisplaySettings by remember { mutableStateOf(false) }
        var rearrangeMode by remember { mutableStateOf(false) }
        var selectedSwapId by remember { mutableStateOf<Long?>(null) }
        val tone = remember { ToneGenerator(AudioManager.STREAM_ALARM, 100) }
    
        DisposableEffect(tone) { onDispose { tone.release() } }
        LaunchedEffect(timer.isRunning, timer.sessionId) {
            while (timer.isRunning) {
                delay(100L)
                if (timer.tick()) {
                    tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 2000)
                }
            }
        }
    
        fun updateSettings(next: TimerSettings) {
            settings = next
            repository.save(next)
        }
    
        if (showEditor) {
            PresetEditorDialog(
                preset = editingPreset,
                imageStore = imageStore,
                onDismiss = { showEditor = false },
                onSave = { name, seconds, imageFileName ->
                    val current = editingPreset
                    // 名前・時間・コピヌした画像のファむル名をたずめお保存する。
                    val item = current?.copy(name = name, durationSeconds = seconds, imageUri = imageFileName)
                        ?: TimerPreset(newPresetId(settings.buttons), name, seconds, imageUri = imageFileName)
                    val buttons = if (current == null) settings.buttons + item
                    else settings.buttons.map { if (it.id == current.id) item else it }
                    updateSettings(settings.copy(buttons = buttons))
                    showEditor = false
                },
                onDelete = {
                    val current = editingPreset
                    if (current != null && !current.isBuiltIn) {
                        updateSettings(settings.copy(buttons = settings.buttons.filterNot { it.id == current.id }))
                    }
                    showEditor = false
                }
            )
        }
    
        if (showDisplaySettings) {
            DisplaySettingsDialog(
                currentWallpaper = settings.bodyImageUri,
                imageStore = imageStore,
                rearrangeMode = rearrangeMode,
                onDismiss = { showDisplaySettings = false },
                onSaveWallpaper = { fileName ->
                    updateSettings(settings.copy(bodyImageUri = fileName))
                    showDisplaySettings = false
                },
                onToggleArrange = {
                    rearrangeMode = !rearrangeMode
                    selectedSwapId = null
                    showDisplaySettings = false
                }
            )
        }
    
        // 背景を画面党䜓に敷いお、その䞊にスクロヌルできるUIを重ねる。
        // ワンタッチモヌドだけ、保存した壁玙を䜿う。通垞モヌドはグレヌのたた。
        val fullScreenWallpaper = rememberButtonImage(
            if (normalMode) null else settings.bodyImageUri
        )
        Box(modifier = Modifier.fillMaxSize().background(Color(0xFFD0D0D0))) {
            if (!normalMode && fullScreenWallpaper != null) {
                Image(
                    bitmap = fullScreenWallpaper,
                    contentDescription = null, // 装食甚の背景
                    modifier = Modifier.matchParentSize(),
                    contentScale = ContentScale.Crop // 比率を保っお画面党面を埋める
                )
                // 背景が掟手な堎合にも文字やボタンが芋えるように薄く芆う。
                Box(Modifier.matchParentSize().background(Color.White.copy(alpha = 0.12f)))
            }
            Column(
                modifier = Modifier.fillMaxSize()
                    .windowInsetsPadding(WindowInsets.safeDrawing)
                    .verticalScroll(rememberScrollState())
                    .padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 28.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                Button(onClick = {
                    timer.reset()
                    tone.stopTone()
                    normalMode = !normalMode
                    selectedSwapId = null
                    rearrangeMode = false
                }) { Text(if (normalMode) "ワンタッチタむマヌぞ" else "通垞タむマヌぞ") }
    
                if (normalMode) {
                    NormalTimerScreen(timer, onReset = { timer.reset(); tone.stopTone() })
                } else {
                    OneTouchTimerScreen(
                        onFinishArrange = { rearrangeMode = false; selectedSwapId = null },
                        settings = settings,
                        timer = timer,
                        onAdd = { editingPreset = null; showEditor = true },
                        onEdit = { editingPreset = it; showEditor = true },
                        onReset = { timer.reset(); tone.stopTone() },
                        onOpenSettings = { showDisplaySettings = true },
                        rearrangeMode = rearrangeMode,
                        selectedSwapId = selectedSwapId,
                        onPresetTap = { preset ->
                            // カりント開始埌䞀時停止・終了埌も含むはプリセットの操䜜を犁止。
                            if (timer.hasStarted) {
                                // リセットされるたでは䜕もしない。
                            } else if (rearrangeMode) {
                                val fromId = selectedSwapId
                                if (fromId == null) {
                                    selectedSwapId = preset.id
                                } else if (fromId == preset.id) {
                                    selectedSwapId = null
                                } else {
                                    val rearranged = settings.buttons.toMutableList()
                                    val from = rearranged.indexOfFirst { it.id == fromId }
                                    val to = rearranged.indexOfFirst { it.id == preset.id }
                                    if (from >= 0 && to >= 0) {
                                        val temp = rearranged[from]
                                        rearranged[from] = rearranged[to]
                                        rearranged[to] = temp
                                        updateSettings(settings.copy(buttons = rearranged))
                                    }
                                    selectedSwapId = null
                                }
                            } else {
                                tone.stopTone()
                                timer.selectPreset(preset.durationSeconds)
                            }
                        }
                    )
                }
            }
        }
    }
    
    // 5. モヌド別の画面。保存凊理は芪画面ぞコヌルバックする。
    @Composable
    private fun NormalTimerScreen(timer: TimerController, onReset: () -> Unit) {
        Text("通垞タむマヌ", fontSize = 24.sp)
        TimerDisplay(timer.displayText)
        listOf(listOf(1, 2, 3), listOf(4, 5, 6), listOf(7, 8, 9), listOf(0)).forEach { digits ->
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
                digits.forEach { digit ->
                    Button(onClick = { timer.inputDigit(digit) }, enabled = timer.canInput,
                        modifier = Modifier.size(72.dp)) {
                        Text(digit.toString(), fontSize = 28.sp)
                    }
                }
            }
        }
        TimerControls(timer, normalMode = true, onReset = onReset)
    }
    
    @Composable
    private fun OneTouchTimerScreen(
        settings: TimerSettings,
        timer: TimerController,
        onFinishArrange: () -> Unit,
        onAdd: () -> Unit,
        onEdit: (TimerPreset) -> Unit,
        onReset: () -> Unit,
        onOpenSettings: () -> Unit,
        rearrangeMode: Boolean,
        selectedSwapId: Long?,
        onPresetTap: (TimerPreset) -> Unit
    ) {
        // 背景は芪のCustomTimerで党面衚瀺。ここでは操䜜UIだけを描く。
        Column(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(14.dp)
        ) {
            TimerDisplay(formatTime(timer.remainingSeconds))
            if (rearrangeMode) {
                Column(
                    Modifier.fillMaxWidth()
                        .background(Color.White.copy(alpha = 0.92f), RoundedCornerShape(12.dp))
                        .padding(10.dp),
                    verticalArrangement = Arrangement.spacedBy(6.dp)
                ) {
                    Text(
                        if (selectedSwapId == null) "配眮倉曎䞭亀換したいボタンを遞んでください"
                        else "亀換先のボタンを遞択同じボタンで解陀",
                        color = Color(0xFF263238), fontSize = 13.sp
                    )
                    Button(onClick = onFinishArrange, modifier = Modifier.fillMaxWidth()) {
                        Text("配眮倉曎を完了")
                    }
                }
            }
            Spacer(modifier = Modifier.height(80.dp))
    
            // プリセットボタンは開始埌に入力犁止。リセットで再び遞べる。
            // 6個たでは埓来どおり通垞衚瀺。
            // 7個以䞊では、プリセット䞀芧だけを2段分の高さに制限しおスクロヌル。
            if (settings.buttons.size > 6) {
                Column(
                    modifier = Modifier.fillMaxWidth()
                        .height(310.dp)
                        .verticalScroll(rememberScrollState()),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    PresetRows(settings.buttons, rearrangeMode, selectedSwapId, timer.hasStarted, onEdit, onPresetTap)
                }
                Text(
                    "↑ ↓ プリセット䞀芧を䞊䞋にスクロヌルできたす",
                    fontSize = 12.sp,
                    color = Color(0xFF263238),
                    modifier = Modifier
                        .background(Color.White.copy(alpha = 0.88f), RoundedCornerShape(8.dp))
                        .padding(horizontal = 8.dp, vertical = 4.dp)
                )
            } else {
                // 1〜6個の堎合はスクロヌル領域を䜜らない。
                Column(
                    modifier = Modifier.fillMaxWidth(),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    PresetRows(settings.buttons, rearrangeMode, selectedSwapId, timer.hasStarted, onEdit, onPresetTap)
                }
            }
            // 壁玙に巊右されず操䜜できるよう、操䜜ボタンを半透明のパネルでたずめる。
            Column(
                modifier = Modifier.fillMaxWidth()
                    .background(Color.White.copy(alpha = 0.80f), RoundedCornerShape(18.dp))
                    .padding(10.dp),
                verticalArrangement = Arrangement.spacedBy(10.dp)
            ) {
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    Button(
                        onClick = onAdd,
                        modifier = Modifier.weight(1f).heightIn(min = 48.dp),
                        enabled = !rearrangeMode,
                        contentPadding = PaddingValues(horizontal = 4.dp),
                        colors = ButtonDefaults.buttonColors(
                            containerColor = Color(0xFF6750A4), contentColor = Color.White
                        )
                    ) { Text(" プリセット远加", fontSize = 12.sp) }
                    Button(
                        onClick = onOpenSettings,
                        modifier = Modifier.weight(1f).heightIn(min = 48.dp),
                        contentPadding = PaddingValues(horizontal = 4.dp),
                        colors = ButtonDefaults.buttonColors(
                            containerColor = Color(0xFF3F568E), contentColor = Color.White
                        )
                    ) { Text("⚙ 蚭定倉曎", fontSize = 12.sp) }
                }
                TimerControls(timer, normalMode = false, onReset = onReset)
            }
        }
    }
    
    // プリセットの3列衚瀺を共通化し、通垞衚瀺・スクロヌル衚瀺で同じ凊理を䜿う。
    @Composable
    private fun PresetRows(
        buttons: List<TimerPreset>,
        rearrangeMode: Boolean,
        selectedSwapId: Long?,
        timerHasStarted: Boolean,
        onEdit: (TimerPreset) -> Unit,
        onPresetTap: (TimerPreset) -> Unit
    ) {
        buttons.chunked(3).forEach { items ->
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                items.forEach { preset ->
                    key(preset.id) {
                        Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
                            PresetButton(
                                preset = preset,
                                onClick = { onPresetTap(preset) },
                                swapSelected = rearrangeMode && selectedSwapId == preset.id,
                                enabled = !timerHasStarted
                            )
                            if (!rearrangeMode && !preset.isBuiltIn) {
                                TextButton(
                                    onClick = { onEdit(preset) },
                                    modifier = Modifier.padding(top = 2.dp).height(36.dp),
                                    colors = ButtonDefaults.textButtonColors(
                                        containerColor = Color.White.copy(alpha = 0.88f),
                                        contentColor = Color(0xFF433283)
                                    )
                                ) { Text("線集", fontSize = 13.sp, fontWeight = FontWeight.Bold) }
                            }
                        }
                    }
                }
                repeat(3 - items.size) { Spacer(Modifier.weight(1f)) }
            }
        }
    }
    
    // 6. 共通パヌツ画像の衚瀺方法を远加する堎所もここに䞀本化する。
    @Composable
    private fun PresetButton(
        preset: TimerPreset,
        onClick: () -> Unit,
        swapSelected: Boolean = false,
        enabled: Boolean = true
    ) {
        val drawable = when (preset.builtInKey) {
            "ramen" -> R.drawable.ramen
            "udon" -> R.drawable.udon
            "pasta" -> R.drawable.pasta
            else -> null
        }
        val color = when (preset.builtInKey) {
            "ramen" -> Color.Yellow
            "udon" -> Color(0xFF81D4FA)
            "pasta" -> Color(0xFFFFAB91)
            else -> Color(0xFFE1BEE7)
        }
        Button(onClick = onClick, enabled = enabled,
            modifier = Modifier.fillMaxWidth().height(110.dp),
            shape = RoundedCornerShape(16.dp),
            border = BorderStroke(if (swapSelected) 5.dp else 2.dp,
                if (swapSelected) Color(0xFF7B1FA2) else Color.Black),
            contentPadding = PaddingValues(6.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = color,
                contentColor = Color.Black,
                disabledContainerColor = color,
                disabledContentColor = Color.Black
            )) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // コピヌ画像を優先しお衚瀺。読めない画像は暙準画像/文字ぞ戻す。
                val copiedImage = rememberButtonImage(preset.imageUri)
                if (copiedImage != null) {
                    Image(bitmap = copiedImage, contentDescription = preset.name,
                        modifier = Modifier.fillMaxWidth().height(55.dp), contentScale = ContentScale.Crop)
                } else if (drawable != null) {
                    Image(painterResource(drawable), contentDescription = preset.name,
                        modifier = Modifier.fillMaxWidth().height(55.dp), contentScale = ContentScale.Crop)
                }
                Text(preset.name, fontSize = 12.sp, maxLines = 2, fontWeight = FontWeight.Bold)
                Text(formatTime(preset.durationSeconds), fontSize = 12.sp)
            }
        }
    }
    
    @Composable
    private fun TimerDisplay(text: String) {
        Box(Modifier.fillMaxWidth().background(Color(0xFF202124), RoundedCornerShape(24.dp))
            .padding(vertical = 24.dp), contentAlignment = Alignment.Center) {
            Text(text, fontSize = 56.sp, color = Color(0xFF00E676), maxLines = 1)
        }
    }
    
    @Composable
    private fun TimerControls(timer: TimerController, normalMode: Boolean, onReset: () -> Unit) {
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { timer.toggle() }, modifier = Modifier.weight(1f).heightIn(min = 48.dp),
                colors = ButtonDefaults.buttonColors(
                    containerColor = Color(0xFF6750A4), contentColor = Color.White,
                    disabledContainerColor = Color(0xFFDDD7EA),
                    disabledContentColor = Color(0xFF615B6B)
                ),
                enabled = timer.isRunning || timer.remainingSeconds > 0 || (normalMode && timer.inputNumber > 0)) {
                Text(if (timer.isRunning) "ストップ" else if (timer.hasStarted) "再開" else "スタヌト")
            }
            Button(onClick = onReset, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { Text("リセット") }
        }
    }
    
    // 7. 線集画面遞択画像のプレビュヌを衚瀺し、「保存」でアプリ内ぞコピヌ。
    @Composable
    private fun PresetEditorDialog(
        preset: TimerPreset?,
        imageStore: ButtonImageStore,
        onDismiss: () -> Unit,
        onSave: (String, Int, String?) -> Unit,
        onDelete: () -> Unit
    ) {
        var name by remember(preset?.id) { mutableStateOf(preset?.name ?: "") }
        var minutes by remember(preset?.id) { mutableStateOf(preset?.let { (it.durationSeconds / 60).toString() } ?: "") }
        var seconds by remember(preset?.id) { mutableStateOf(preset?.let { (it.durationSeconds % 60).toString() } ?: "") }
        var imageReference by remember(preset?.id) { mutableStateOf(preset?.imageUri) }
        var selectedUri by remember(preset?.id) { mutableStateOf<Uri?>(null) }
        var error by remember(preset?.id) { mutableStateOf("") }
        var isSaving by remember { mutableStateOf(false) }
        val scope = rememberCoroutineScope()
        val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
            // 遞択画面でキャンセルした堎合は元の画像を維持する。
            if (uri != null) {
                selectedUri = uri
                imageReference = uri.toString()
                error = ""
            }
        }
        val preview = rememberButtonImage(imageReference)
    
        AlertDialog(onDismissRequest = { if (!isSaving) onDismiss() },
            title = { Text(if (preset == null) "タむマヌを远加" else "タむマヌを線集") },
            text = {
                Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    OutlinedTextField(value = name, onValueChange = { name = it }, enabled = !isSaving,
                        label = { Text("ボタン名日本語も䜿えたす") }, singleLine = true)
                    NumberInputField(minutes, "分0〜99", enabled = !isSaving) { minutes = it }
                    NumberInputField(seconds, "秒0〜59", enabled = !isSaving) { seconds = it }
                    Text("ボタンの画像")
                    if (preview != null) {
                        Image(bitmap = preview, contentDescription = "遞択画像のプレビュヌ",
                            modifier = Modifier.fillMaxWidth().height(120.dp), contentScale = ContentScale.Fit)
                    } else {
                        Text(if (imageReference == null) "画像なし文字だけで衚瀺" else "画像を読み蟌み䞭、たたはプレビュヌできたせん")
                    }
                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        OutlinedButton(onClick = { imagePicker.launch("image/*") }, enabled = !isSaving) {
                            Text(if (imageReference == null) "画像を遞ぶ" else "画像を倉曎")
                        }
                        TextButton(onClick = {
                            selectedUri = null
                            imageReference = null
                            error = ""
                        }, enabled = !isSaving && imageReference != null) { Text("画像を倖す") }
                    }
                    Text("保存するず、画像をアプリ専甚の堎所ぞコピヌしたす。", fontSize = 12.sp)
                    if (isSaving) {
                        LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
                        Text("画像をコピヌしおいたす ")
                    }
                    if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error)
                }
            },
            confirmButton = {
                TextButton(enabled = !isSaving, onClick = {
                    val trimmed = name.trim()
                    val m = if (minutes.isEmpty()) 0 else minutes.toIntOrNull()
                    val s = if (seconds.isEmpty()) 0 else seconds.toIntOrNull()
                    if (trimmed.length !in 1..20 || m == null || s == null || m !in 0..99 || s !in 0..59 || m * 60 + s == 0) {
                        error = "名前は1〜20文字、時間は00:01〜99:59で入力しおください"
                    } else {
                        val uriToCopy = selectedUri
                        val existingImage = imageReference
                        isSaving = true
                        scope.launch {
                            try {
                                val savedImage = if (uriToCopy != null) {
                                    imageStore.copyFrom(uriToCopy)
                                } else existingImage
                                onSave(trimmed, m * 60 + s, savedImage)
                            } catch (cancelled: CancellationException) {
                                throw cancelled
                            } catch (exception: Exception) {
                                error = exception.message ?: "画像を保存できたせんでした。別の画像を遞んでください。"
                            } finally {
                                isSaving = false
                            }
                        }
                    }
                }) { Text("保存") }
            },
            dismissButton = {
                Row {
                    if (preset != null && !preset.isBuiltIn) {
                        TextButton(onClick = onDelete, enabled = !isSaving) {
                            Text("削陀", color = MaterialTheme.colorScheme.error)
                        }
                    }
                    TextButton(onClick = onDismiss, enabled = !isSaving) { Text("キャンセル") }
                }
            }
        )
    }
    
    // 8. 壁玙・ボタン配眮の蚭定。画像保存は既存のButtonImageStoreを再利甚する。
    @Composable
    private fun DisplaySettingsDialog(
        currentWallpaper: String?,
        imageStore: ButtonImageStore,
        rearrangeMode: Boolean,
        onDismiss: () -> Unit,
        onSaveWallpaper: (String?) -> Unit,
        onToggleArrange: () -> Unit
    ) {
        var imageReference by remember(currentWallpaper) { mutableStateOf(currentWallpaper) }
        var selectedUri by remember(currentWallpaper) { mutableStateOf<Uri?>(null) }
        var saving by remember { mutableStateOf(false) }
        var error by remember { mutableStateOf("") }
        val scope = rememberCoroutineScope()
        val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
            if (uri != null) {
                selectedUri = uri
                imageReference = uri.toString()
                error = ""
            }
        }
        val preview = rememberButtonImage(imageReference)
    
        AlertDialog(
            onDismissRequest = { if (!saving) onDismiss() },
            title = { Text("ワンタッチタむマヌ蚭定") },
            text = {
                Column(
                    modifier = Modifier.verticalScroll(rememberScrollState()),
                    verticalArrangement = Arrangement.spacedBy(10.dp)
                ) {
                    Text("壁玙を倉曎", style = MaterialTheme.typography.titleMedium)
                    if (preview != null) {
                        Image(bitmap = preview, contentDescription = "壁玙プレビュヌ",
                            modifier = Modifier.fillMaxWidth().height(160.dp),
                            contentScale = ContentScale.Fit)
                    } else {
                        Text(if (imageReference == null) "珟圚の壁玙なし" else "プレビュヌできたせん")
                    }
                    OutlinedButton(onClick = { imagePicker.launch("image/*") }, enabled = !saving) {
                        Text("画像を遞択・倉曎")
                    }
                    Text(
                        text = "掚奚画像サむズ1080 × 2400 px瞊長\n" +
                                "※画面サむズによっお画像の端が切れる堎合がありたす。",
                        fontSize = 12.sp,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    TextButton(onClick = {
                        imageReference = null
                        selectedUri = null
                        error = ""
                    }, enabled = !saving && imageReference != null) { Text("壁玙を倖す") }
                    Button(enabled = !saving, onClick = {
                        saving = true
                        scope.launch {
                            try {
                                val fileName = if (selectedUri != null) imageStore.copyFrom(selectedUri!!)
                                else imageReference
                                onSaveWallpaper(fileName)
                            } catch (cancelled: CancellationException) {
                                throw cancelled
                            } catch (exception: Exception) {
                                error = exception.message ?: "壁玙を保存できたせんでした"
                            } finally {
                                saving = false
                            }
                        }
                    }) { Text("壁玙を保存") }
                    if (saving) LinearProgressIndicator(Modifier.fillMaxWidth())
                    if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error)
                    HorizontalDivider()
                    Text("ボタンの配眮倉曎", style = MaterialTheme.typography.titleMedium)
                    Text("2぀のボタンを順にタップするず䜍眮を亀換したす。既存のボタンずカスタムボタンの䞡方に察応しおいたす。")
                    Button(onClick = onToggleArrange, enabled = !saving) {
                        Text(if (rearrangeMode) "配眮倉曎モヌドを終了" else "配眮倉曎モヌドを開始")
                    }
                }
            },
            confirmButton = {
                TextButton(enabled = !saving, onClick = onDismiss) { Text("閉じる") }
            }
        )
    }
    
    // 9. 画像凊理倖郚の画像ぞの参照ではなく、元の画像デヌタを内郚ぞコピヌする。
    // 画像の読み蟌み・コピヌはバックグラりンドで行い、画面操䜜を止めない。
    private class ButtonImageStore(private val context: Context) {
        private val directory get() = File(context.filesDir, "button_images")
    
        suspend fun copyFrom(uri: Uri): String = withContext(Dispatchers.IO) {
            if (!directory.exists() && !directory.mkdirs()) throw IOException("画像の保存堎所を䜜れたせんでした")
            val file = File(directory, "button_${UUID.randomUUID()}.img")
            var complete = false
            try {
                val input = context.contentResolver.openInputStream(uri)
                    ?: throw IOException("画像を開けたせんでした")
                input.use { source ->
                    file.outputStream().use { destination ->
                        val buffer = ByteArray(8192)
                        var total = 0L
                        while (true) {
                            currentCoroutineContext().ensureActive()
                            val count = source.read(buffer)
                            if (count == -1) break
                            total += count
                            if (total > MAX_BYTES) throw IOException("画像は25MB以䞋のものを遞んでください")
                            destination.write(buffer, 0, count)
                        }
                        if (total == 0L) throw IOException("画像が空でした。別の画像を遞んでください")
                    }
                }
                // 画像ずしお衚瀺できるこずを確認しおから保存を確定する。
                val bitmap = decode(file.name) ?: throw IOException("この画像圢匏は衚瀺できたせん。別の画像を遞んでください")
                bitmap.recycle()
                currentCoroutineContext().ensureActive()
                complete = true
                file.name // 絶察パスを保存せず、アプリ内のファむル名だけ蚘録。
            } finally {
                if (!complete) file.delete()
            }
        }
    
        fun decode(reference: String): Bitmap? {
            return try {
                val uri = if (reference.startsWith("content://")) Uri.parse(reference) else null
                val file = if (uri == null) managedFile(reference) ?: return null else null
                if (file != null && !file.isFile) return null
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    val source = if (uri != null) ImageDecoder.createSource(context.contentResolver, uri)
                    else ImageDecoder.createSource(file!!)
                    ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
                        decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
                        val longest = maxOf(info.size.width, info.size.height)
                        if (longest > PREVIEW_SIZE) {
                            val ratio = PREVIEW_SIZE.toFloat() / longest
                            decoder.setTargetSize((info.size.width * ratio).toInt().coerceAtLeast(1),
                                (info.size.height * ratio).toInt().coerceAtLeast(1))
                        }
                    }
                } else {
                    // Android 8以前画像党䜓をそのたたメモリぞ展開せず瞮小しお読み蟌む。
                    fun open() = if (uri != null) context.contentResolver.openInputStream(uri) else file!!.inputStream()
                    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
                    open()?.use { BitmapFactory.decodeStream(it, null, bounds) }
                    if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
                    var sample = 1
                    while (maxOf(bounds.outWidth, bounds.outHeight) / sample > PREVIEW_SIZE) sample *= 2
                    val options = BitmapFactory.Options().apply { inSampleSize = sample }
                    val bitmap = open()?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
                    // 写真のEXIF情報に埓っお、瞊向き・反転を補正する。
                    val orientation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) runCatching {
                        open()?.use { ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION,
                            ExifInterface.ORIENTATION_NORMAL) }
                    }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL else ExifInterface.ORIENTATION_NORMAL
                    orientBitmap(bitmap, orientation)
                }
            } catch (_: Exception) { null }
        }
    
        fun deleteUnused(references: List<String>) {
            val keep = references.toSet()
            // コピヌ開始ず起動時の片付けが重なっおも、新しい画像は消さない。
            val cutoff = System.currentTimeMillis() - 60L * 60 * 1000
            directory.listFiles()?.filter { it.name.startsWith("button_") && it.name.endsWith(".img") }
                ?.forEach { if (it.name !in keep && it.lastModified() < cutoff) it.delete() }
        }
    
        private fun managedFile(name: String): File? {
            // この機胜で䜜った画像以倖のパスを読み曞きしない。
            if (!name.startsWith("button_") || !name.endsWith(".img") || '/' in name || '\\' in name) return null
            return File(directory, name)
        }
    
        private fun orientBitmap(bitmap: Bitmap, orientation: Int): Bitmap {
            val matrix = Matrix()
            when (orientation) {
                ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
                ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
                ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
                ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.setRotate(90f); matrix.postScale(-1f, 1f) }
                ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
                ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.setRotate(-90f); matrix.postScale(-1f, 1f) }
                ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
                else -> return bitmap
            }
            val oriented = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
            if (oriented !== bitmap) bitmap.recycle()
            return oriented
        }
    
        companion object {
            private const val MAX_BYTES = 25L * 1024 * 1024
            private const val PREVIEW_SIZE = 512
        }
    }
    
    @Composable
    private fun rememberButtonImage(reference: String?): ImageBitmap? {
        val context = LocalContext.current
        val store = remember(context) { ButtonImageStore(context.applicationContext) }
        val image by produceState<ImageBitmap?>(initialValue = null, key1 = reference) {
            value = null
            if (reference != null) {
                value = withContext(Dispatchers.IO) { store.decode(reference)?.asImageBitmap() }
            }
        }
        return image
    }
    
    @Composable
    private fun NumberInputField(value: String, label: String, enabled: Boolean = true, onValueChange: (String) -> Unit) {
        OutlinedTextField(value = value, enabled = enabled,
            onValueChange = { if (it.length <= 2 && it.all { char -> char in '0'..'9' }) onValueChange(it) },
            label = { Text(label) }, singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
    }
    

    ※今回のコヌドを詊す堎合は、Android Studioのres/drawableフォルダに、次の画像を甚意する必芁がありたす。

    • ramenカップラヌメン甚

    • udonカップうどん甚

    • pastaパスタ甚

    たた、Android Studioのバヌゞョンやプロゞェクトの構成によっおは、コヌドの調敎が必芁になる堎合がありたす。

    今回のコヌドは、プログラミング初心者の私がAIのサポヌトを受けながら制䜜したものです。

    無駄な凊理や改善できる郚分が残っおいる可胜性もありたすが、䞀぀の孊習蚘録ずしお公開しおいたす。

    参考にしおいただければ幞いです


     
     
    ごく平凡な代オッサンのたっさんです。本職は機械加工で、汎甚機ずか扱っおいたす。新しいもん奜きで、色々な事に挑戊しおは倱敗を繰り返し呚りから笑われる事も...結構ぶっ飛んだ人生だから皆ず共有しおみたらず蚀われたのでnote曞く事に...

    あなたぞのおすすめ