---
# System prepended metadata

title: 'Android App 設計開發課程 #5'

---


## 名人語錄
點擊螢幕可以更換不同的名人語錄，不會點擊了之後還是相同的名人語錄

```kotlin=
package com.example.myfirstcomposeapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.myfirstcomposeapp.ui.theme.MyFirstComposeAppTheme

data class Quote(val message: String, val from: String)

fun getNextQuote(currentQuote: Quote, quotes: List<Quote>): Quote {
    val otherQuotes = quotes.filter { it != currentQuote }
    return otherQuotes.random()
}

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val quotes = listOf(
                Quote("Life is like a box of chocolates.", "Forrest Gump"),
                Quote("Imagination is more important than knowledge.", "Albert Einstein"),
                Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
            )

            var currentQuote by remember { mutableStateOf(quotes.random()) }

            MyFirstComposeAppTheme {
                Surface(
                    modifier = Modifier
                        .fillMaxSize()
                        .clickable {
                            currentQuote = getNextQuote(currentQuote, quotes)
                        },
                    color = MaterialTheme.colorScheme.background
                ) {
                    QuoteImage(message = currentQuote.message, from = currentQuote.from)
                }
            }
        }
    }
}

@Composable
fun QuoteText(message: String, from: String, modifier: Modifier = Modifier) {
    // Create a column so that texts don't overlap
    Column(
        verticalArrangement = Arrangement.Center,
        modifier = modifier
    ) {
        Text(
            text = "\"" + message + "\"",
            fontSize = 48.sp,
            lineHeight = 55.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 16.dp),
        )
        Text(
            text = from,
            fontSize = 36.sp,
            fontStyle = FontStyle.Italic,
            modifier = Modifier
                .padding(top = 16.dp)
                .padding(end = 16.dp)
                .align(alignment = Alignment.End)

        )
    }
}

@Composable
fun QuoteImage(message: String, from: String, modifier: Modifier = Modifier) {
    Box(modifier) {
        Image(
            painter = painterResource(R.drawable.quote_background_1),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            alpha = 0.5f,
            modifier = Modifier.fillMaxSize()
        )
        QuoteText(
            message = message,
            from = from,
            modifier = Modifier
                .fillMaxSize()
                .padding(8.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun QuotePreview() {

    val quotes = listOf(
        Quote("Life is like a box of chocolates.", "Forrest Gump"),
        Quote("Imagination is more important than knowledge.", "Albert Einstein"),
        Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
    )

    var currentQuote by remember { mutableStateOf(quotes.random()) }

    MyFirstComposeAppTheme {
        Surface(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
                    currentQuote = getNextQuote(currentQuote, quotes)
                },
            color = MaterialTheme.colorScheme.background
        ) {
            QuoteImage(message = currentQuote.message, from = currentQuote.from)
        }
    }
}
```

# Quote API

Quotable API
* https://github.com/lukePeavey/quotable
* Documentation https://docs.quotable.io/docs/api/ZG9jOjQ2NDA2-introduction

## 從 API 讀取名言
```kotlin=
package com.example.myfirstcomposeapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.myfirstcomposeapp.ui.theme.MyFirstComposeAppTheme
import com.google.gson.Gson
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL

data class Quote(val message: String, val from: String)

data class QuoteFromApi(
    val _id: String,
    val content: String,
    val author: String,
    val tags: List<String>,
    val authorSlug: String,
    val length: Int,
    val dateAdded: String,
    val dateModified: String
)

fun getQuoteFromApi(): QuoteFromApi? {
    val url = URL("https://api.quotable.io/random")

    with(url.openConnection() as HttpURLConnection) {
        requestMethod = "GET"

        println("Response Code: $responseCode")

        BufferedReader(InputStreamReader(inputStream)).use {
            val response = it.readText()
            val gson = Gson()
            return gson.fromJson(response, QuoteFromApi::class.java)
        }
    }

}

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val quotes = listOf(
                Quote("Life is like a box of chocolates.", "Forrest Gump"),
                Quote("Imagination is more important than knowledge.", "Albert Einstein"),
                Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
            )

            var currentQuote by remember { mutableStateOf(quotes.random()) }

            MyFirstComposeAppTheme {
                Surface(
                    modifier = Modifier
                        .fillMaxSize()
                        .clickable {
                            currentQuote = quotes.random()
                        },
                    color = MaterialTheme.colorScheme.background
                ) {
                    QuoteImage(message = currentQuote.message, from = currentQuote.from)
                }
            }
        }
    }
}

@Composable
fun QuoteText(message: String, from: String, modifier: Modifier = Modifier) {
    // Create a column so that texts don't overlap
    Column(
        verticalArrangement = Arrangement.Center,
        modifier = modifier
    ) {
        Text(
            text = "\"" + message + "\"",
            fontSize = 48.sp,
            lineHeight = 55.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 16.dp),
        )
        Text(
            text = from,
            fontSize = 36.sp,
            fontStyle = FontStyle.Italic,
            modifier = Modifier
                .padding(top = 16.dp)
                .padding(end = 16.dp)
                .align(alignment = Alignment.End)

        )
    }
}

@Composable
fun QuoteImage(message: String, from: String, modifier: Modifier = Modifier) {
    Box(modifier) {
        Image(
            painter = painterResource(R.drawable.quote_background_1),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            alpha = 0.5f,
            modifier = Modifier.fillMaxSize()
        )
        QuoteText(
            message = message,
            from = from,
            modifier = Modifier
                .fillMaxSize()
                .padding(8.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun QuotePreview() {

    val quotes = listOf(
        Quote("Life is like a box of chocolates.", "Forrest Gump"),
        Quote("Imagination is more important than knowledge.", "Albert Einstein"),
        Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
    )

    var currentQuote by remember { mutableStateOf(quotes.random()) }

    MyFirstComposeAppTheme {
        Surface(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
                    //currentQuote = quotes.random()
                    var quoteFromApi = getQuoteFromApi()
                    if (quoteFromApi != null) {
                        currentQuote = Quote(quoteFromApi.content, quoteFromApi.author)
                    }
                },
            color = MaterialTheme.colorScheme.background
        ) {
            QuoteImage(message = currentQuote.message, from = currentQuote.from)
        }
    }
}
```
Note:
* 需要在 build.gradle.kts 裡加入
`implementation("com.google.code.gson:gson:2.10.1")`
* 方便看 JSON 格式的線上工具
    * https://jsonformatter.curiousconcept.com/
* 在模擬器與實機上需要 INTERNET 的權限
`<uses-permission android:name="android.permission.INTERNET" />`
有時可能需要將 app 移除之後再安裝才可正常取得權限

取得 thread 限制
* macOS
`sysctl kern.num_threads`
* Linux
`cat /proc/sys/kernel/threads-max`

## 五秒內要出拳的剪刀石頭布遊戲
```kotlin=
package com.example.hellokotlin

import java.util.Scanner
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

enum class Choice {
    ROCK, PAPER, SCISSORS
}

fun getPlayerInput(): String? {
    val executor = Executors.newSingleThreadExecutor()
    val scanner = Scanner(System.`in`)
    val future: Future<String> = executor.submit<String> { scanner.nextLine() }

    return try {
        future.get(5, TimeUnit.SECONDS)
    } catch (e: TimeoutException) {
        null
    } finally {
        future.cancel(true)
        executor.shutdown()
    }
}

fun main() {
    var playerWins = 0
    var computerWins = 0
    var round = 0

    while (playerWins < 2 && computerWins < 2) {
        round++
        println("現在是第 $round 局")

        var result = 0
        do {
            println("請五秒內出拳：Rock, Paper, Scissors")
            val playerInput = getPlayerInput()?.trim()?.lowercase()
            if (playerInput == null) {
                println("超過時間，你輸了這局\n")
                computerWins++
                break
            }

            val playerChoice = when (playerInput) {
                "rock", "r" -> Choice.ROCK
                "paper", "p" -> Choice.PAPER
                "scissors", "s" -> Choice.SCISSORS
                else -> {
                    println("輸入錯誤，請重新輸入\n")
                    continue
                }
            }
            val computerChoice = Choice.entries.random()
            result = (playerChoice.ordinal - computerChoice.ordinal + 3) % 3
            println("玩家出拳：$playerChoice")
            println("電腦出拳：$computerChoice")
            println(
                "結果：${
                    when (result) {
                        0 -> "平手"
                        1 -> {
                            playerWins++
                            "你贏了"
                        }
                        else -> {
                            computerWins++
                            "你輸了"
                        }
                    }
                }\n"
            )
        } while (result == 0)
    }

    if (playerWins > computerWins) {
        println("恭喜你獲得最終勝利！")
    } else {
        println("很遺憾，最終是電腦獲勝了。")
    }
}
```
用 Kotlin coroutines 的方式來寫
```kotlin=
package com.example.hellokotlin

import android.app.admin.SystemUpdatePolicy
import kotlinx.coroutines.*
import java.util.Scanner
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

enum class Choice {
    ROCK, PAPER, SCISSORS
}

suspend fun getPlayerInput(): String? = withTimeoutOrNull(5000L) {
    var scanner = Scanner(System.`in`)
    while (true) {
        delay(100)
        if (System.`in`.available() > 0) {
            return@withTimeoutOrNull scanner.nextLine()
        }
    }
    null
}

fun main() = runBlocking {
    var playerWins = 0
    var computerWins = 0
    var round = 0

    while (playerWins < 2 && computerWins < 2) {
        round++
        println("現在是第 $round 局")

        var result = 0
        do {
            println("請五秒內出拳：Rock, Paper, Scissors")
            val playerInput = getPlayerInput()?.trim()?.lowercase()
            if (playerInput == null) {
                println("超過時間，你輸了這局\n")
                computerWins++
                break
            }

            val playerChoice = when (playerInput) {
                "rock", "r" -> Choice.ROCK
                "paper", "p" -> Choice.PAPER
                "scissors", "s" -> Choice.SCISSORS
                else -> {
                    println("輸入錯誤，請重新輸入\n")
                    continue
                }
            }
            val computerChoice = Choice.entries.random()
            result = (playerChoice.ordinal - computerChoice.ordinal + 3) % 3
            println("玩家出拳：$playerChoice")
            println("電腦出拳：$computerChoice")
            println(
                "結果：${
                    when (result) {
                        0 -> "平手"
                        1 -> {
                            playerWins++
                            "你贏了"
                        }
                        else -> {
                            computerWins++
                            "你輸了"
                        }
                    }
                }\n"
            )
        } while (result == 0)
    }

    if (playerWins > computerWins) {
        println("恭喜你獲得最終勝利！")
    } else {
        println("很遺憾，最終是電腦獲勝了。")
    }
}
```

## 使用 Retrofit 改寫名人語錄

加入到 `build.gradle.kts`
`implementation("com.squareup.retrofit2:retrofit:2.11.0")`
`implementation("com.squareup.retrofit2:converter-gson:2.11.0")`


```kotlin=
package com.example.myfirstcomposeapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope
import com.example.myfirstcomposeapp.ui.theme.MyFirstComposeAppTheme
import com.google.gson.Gson
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL

interface QuotableApi {
    @GET("random")
    suspend fun getRandomQuote(): QuoteFromApi
}

data class Quote(val message: String, val from: String)

data class QuoteFromApi(
    val _id: String,
    val content: String,
    val author: String,
    val tags: List<String>,
    val authorSlug: String,
    val length: Int,
    val dateAdded: String,
    val dateModified: String
)

fun getQuoteFromApi(): QuoteFromApi? {
    val url = URL("https://api.quotable.io/random")

    with(url.openConnection() as HttpURLConnection) {
        requestMethod = "GET"  // optional default is GET

        println("\nSending 'GET' request to URL : $url")
        println("Response Code : $responseCode")

        BufferedReader(InputStreamReader(inputStream)).use {
            val response = it.readText()  //This is your response
            val gson = Gson()
            val quote = gson.fromJson(response, QuoteFromApi::class.java)
            return quote
        }
    }
}

class MainActivity : ComponentActivity() {

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("https://api.quotable.io/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    private val api = retrofit.create(QuotableApi::class.java)

    private suspend fun getRandomQuote(): QuoteFromApi? {
        return api.getRandomQuote()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create a list of quotes
        val quotes = listOf(
            Quote("Life is like a box of chocolates.", "Forrest Gump"),
            Quote("Imagination is more important than knowledge.", "Albert Einstein"),
            Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
        )

        setContent {
            // Create a state for the current quote
            var currentQuote by remember { mutableStateOf(quotes.random()) }

            MyFirstComposeAppTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier
                        .fillMaxSize()
                        .clickable { // Add the clickable modifier
                            // Update the current quote state with a new random quote when the surface is clicked
                            lifecycleScope.launch {
                                val quoteFromApi = getRandomQuote()
                                if (quoteFromApi != null) {
                                    currentQuote = Quote(quoteFromApi.content, quoteFromApi.author)
                                }
                            }
                        },
                    color = MaterialTheme.colorScheme.background
                ) {
                    QuoteImage(
                        message = currentQuote.message,
                        from = currentQuote.from,
                    )
                }
            }
        }
    }
}

@Composable
fun QuoteText(message: String, from: String, modifier: Modifier = Modifier) {
    // Create a column so that texts don't overlap
    Column(
        verticalArrangement = Arrangement.Center,
        modifier = modifier
    ) {
        Text(
            text = "\"" + message + "\"",
            fontSize = 48.sp,
            lineHeight = 55.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 16.dp),
        )
        Text(
            text = from,
            fontSize = 36.sp,
            // Make the font italic
            fontStyle = FontStyle.Italic,
            modifier = Modifier
                .padding(top = 16.dp)
                .padding(end = 16.dp)
                .align(alignment = Alignment.End)

        )
    }
}

@Composable
fun QuoteImage(message: String, from: String, modifier: Modifier = Modifier) {
    Box(modifier) {
        Image(
            painter = painterResource(R.drawable.quote_background_1),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            alpha = 0.5f,
            modifier = Modifier.fillMaxSize()
        )
        QuoteText(
            message = message,
            from = from,
            modifier = Modifier
                .fillMaxSize()
                .padding(8.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun QuotePreview() {

    // Create a list of quotes
    val quotes = listOf(
        Quote("Life is like a box of chocolates.", "Forrest Gump"),
        Quote("Imagination is more important than knowledge.", "Albert Einstein"),
        Quote("Time you enjoy wasting, was not wasted.", "John Lennon"),
    )

    // Create a state for the current quote
    var currentQuote by remember { mutableStateOf(quotes.random()) }

    MyFirstComposeAppTheme {
        // A surface container using the 'background' color from the theme
        Surface(
            modifier = Modifier
                .fillMaxSize()
                .clickable { // Add the clickable modifier
                    // Update the current quote state with a new random quote when the surface is clicked
                    //currentQuote = quotes.random()
                    val quoteFromApi = getQuoteFromApi()
                    if (quoteFromApi != null) {
                        currentQuote = Quote(quoteFromApi.content, quoteFromApi.author)
                    }
                },
            color = MaterialTheme.colorScheme.background
        ) {
            QuoteImage(
                message = currentQuote.message,
                from = currentQuote.from,
            )
        }
    }
}
```