## 透過 Free Currency Exchage Rates API 取得 JPY to TWD 的預設匯率 ```kotlin= package com.example.currencyconverterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.currencyconverterapp.ui.theme.CurrencyConverterAppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET data class CurrencyResponse( val jpy: CurrencyRate ) data class CurrencyRate( val twd: Double ) interface CurrencyApi { @GET("v1/currencies/jpy.json") suspend fun getExchangeRate(): CurrencyResponse } class MainActivity : ComponentActivity() { private val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CurrencyConverterAppTheme { Surface( modifier = Modifier.fillMaxSize(), ) { CurrencyConverterLayout(api) } } } } } @Composable fun CurrencyConverterLayout(api: CurrencyApi) { var amountInput by remember { mutableStateOf("") } var exchangeRateInput by remember { mutableStateOf("") } val amount = amountInput.toDoubleOrNull() ?: 0.0 val exchangeRate = exchangeRateInput.toDoubleOrNull() ?: 0.0 val targetAmount = amount * exchangeRate Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.currency_converter), modifier = Modifier .padding(bottom = 16.dp, top = 40.dp) .align(alignment = Alignment.Start) ) TextField( value = amountInput, onValueChange = { amountInput = it }, label = { Text(stringResource(R.string.jpy)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) TextField( value = exchangeRateInput, onValueChange = { exchangeRateInput = it }, label = { Text(stringResource(R.string.jpy_to_twd)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) Text( text = stringResource(R.string.twd_amount, targetAmount), style = MaterialTheme.typography.displayMedium ) } LaunchedEffect(Unit) { CoroutineScope(Dispatchers.IO).launch { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } } @Preview(showBackground = true) @Composable fun CurrencyConverterLayoutPreview() { val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } CurrencyConverterAppTheme { CurrencyConverterLayout(api) } } ``` ## 加入 Switch ```kotlin= package com.example.currencyconverterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.currencyconverterapp.ui.theme.CurrencyConverterAppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET data class CurrencyResponse( val jpy: CurrencyRate ) data class CurrencyRate( val twd: Double ) interface CurrencyApi { @GET("v1/currencies/jpy.json") suspend fun getExchangeRate(): CurrencyResponse } class MainActivity : ComponentActivity() { private val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CurrencyConverterAppTheme { Surface( modifier = Modifier.fillMaxSize(), ) { CurrencyConverterLayout(api) } } } } } @Composable fun CurrencyConverterLayout(api: CurrencyApi) { var amountInput by remember { mutableStateOf("") } var exchangeRateInput by remember { mutableStateOf("0.21") } var exchangeRateShowUp by remember { mutableStateOf(false) } val amount = amountInput.toDoubleOrNull() ?: 0.0 val exchangeRate = exchangeRateInput.toDoubleOrNull() ?: 0.0 val targetAmount = amount * exchangeRate Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.currency_converter), modifier = Modifier .padding(bottom = 16.dp, top = 40.dp) .align(alignment = Alignment.Start) ) TextField( value = amountInput, onValueChange = { amountInput = it }, label = { Text(stringResource(R.string.jpy)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) if (exchangeRateShowUp) { TextField( value = exchangeRateInput, onValueChange = { exchangeRateInput = it }, label = { Text(stringResource(R.string.jpy_to_twd)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) } CustomExchangeRateRow( exchangeRateShowUp = exchangeRateShowUp, onExchangeRateChanged = { exchangeRateShowUp = it }, modifier = Modifier.padding(bottom = 32.dp) ) Text( text = stringResource(R.string.twd_amount, targetAmount), style = MaterialTheme.typography.displayMedium ) } LaunchedEffect(Unit) { CoroutineScope(Dispatchers.IO).launch { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } } @Composable fun CustomExchangeRateRow( exchangeRateShowUp: Boolean, onExchangeRateChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.custom_exchange_rate)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = exchangeRateShowUp, onCheckedChange = onExchangeRateChanged ) } } @Preview(showBackground = true) @Composable fun CurrencyConverterLayoutPreview() { val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } CurrencyConverterAppTheme { CurrencyConverterLayout(api) } } ``` ## 增加動畫效果 ```kotlin= package com.example.currencyconverterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.currencyconverterapp.ui.theme.CurrencyConverterAppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET data class CurrencyResponse( val jpy: CurrencyRate ) data class CurrencyRate( val twd: Double ) interface CurrencyApi { @GET("v1/currencies/jpy.json") suspend fun getExchangeRate(): CurrencyResponse } class MainActivity : ComponentActivity() { private val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CurrencyConverterAppTheme { Surface( modifier = Modifier.fillMaxSize(), ) { CurrencyConverterLayout(api) } } } } } @Composable fun CurrencyConverterLayout(api: CurrencyApi) { var amountInput by remember { mutableStateOf("") } var exchangeRateInput by remember { mutableStateOf("0.21") } var exchangeRateShowUp by remember { mutableStateOf(false) } val amount = amountInput.toDoubleOrNull() ?: 0.0 val exchangeRate = exchangeRateInput.toDoubleOrNull() ?: 0.0 val targetAmount = amount * exchangeRate Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.currency_converter), modifier = Modifier .padding(bottom = 16.dp, top = 40.dp) .align(alignment = Alignment.Start) ) TextField( value = amountInput, onValueChange = { amountInput = it }, label = { Text(stringResource(R.string.jpy)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) AnimatedVisibility( exchangeRateShowUp, enter = fadeIn() + slideInHorizontally(), exit = fadeOut() + slideOutHorizontally() ) { TextField( value = exchangeRateInput, onValueChange = { exchangeRateInput = it }, label = { Text(stringResource(R.string.jpy_to_twd)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) } CustomExchangeRateRow( exchangeRateShowUp = exchangeRateShowUp, onExchangeRateChanged = { exchangeRateShowUp = it }, modifier = Modifier.padding(bottom = 32.dp) ) Text( text = stringResource(R.string.twd_amount, targetAmount), style = MaterialTheme.typography.displayMedium ) } LaunchedEffect(Unit) { CoroutineScope(Dispatchers.IO).launch { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } } @Composable fun CustomExchangeRateRow( exchangeRateShowUp: Boolean, onExchangeRateChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.custom_exchange_rate)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = exchangeRateShowUp, onCheckedChange = onExchangeRateChanged ) } } @Preview(showBackground = true) @Composable fun CurrencyConverterLayoutPreview() { val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } CurrencyConverterAppTheme { CurrencyConverterLayout(api) } } ``` 加入設定圖示在畫面右上角 ```kotlin= package com.example.currencyconverterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.currencyconverterapp.ui.theme.CurrencyConverterAppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET data class CurrencyResponse( val jpy: CurrencyRate ) data class CurrencyRate( val twd: Double ) interface CurrencyApi { @GET("v1/currencies/jpy.json") suspend fun getExchangeRate(): CurrencyResponse } class MainActivity : ComponentActivity() { private val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CurrencyConverterAppTheme { Surface( modifier = Modifier.fillMaxSize(), ) { CurrencyConverterLayout(api) } } } } } @Composable fun CurrencyConverterLayout(api: CurrencyApi) { var amountInput by remember { mutableStateOf("") } var exchangeRateInput by rememberSaveable { mutableStateOf("0.21") } var exchangeRateShowUp by rememberSaveable { mutableStateOf(false) } val amount = amountInput.toDoubleOrNull() ?: 0.0 val exchangeRate = exchangeRateInput.toDoubleOrNull() ?: 0.0 val targetAmount = amount * exchangeRate Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.currency_converter), modifier = Modifier .padding(bottom = 16.dp, top = 40.dp) .align(alignment = Alignment.Start) ) TextField( value = amountInput, onValueChange = { amountInput = it }, label = { Text(stringResource(R.string.jpy)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = if (exchangeRateShowUp) ImeAction.Next else ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) AnimatedVisibility( exchangeRateShowUp, enter = fadeIn() + slideInHorizontally(), exit = fadeOut() + slideOutHorizontally() ) { TextField( value = exchangeRateInput, onValueChange = { exchangeRateInput = it }, label = { Text(stringResource(R.string.jpy_to_twd)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) } CustomExchangeRateRow( exchangeRateShowUp = exchangeRateShowUp, onExchangeRateChanged = { exchangeRateShowUp = it }, modifier = Modifier.padding(bottom = 32.dp) ) Text( text = stringResource(R.string.twd_amount, targetAmount), style = MaterialTheme.typography.displayMedium ) } IconButton( onClick = { /* Handle settings click */ }, modifier = Modifier .statusBarsPadding() .safeDrawingPadding() .padding(top = 10.dp) .padding(horizontal = 10.dp) .align(Alignment.TopEnd) ) { Icon(Icons.Filled.Settings, contentDescription = null, modifier = Modifier.size(36.dp)) } } LaunchedEffect(Unit) { CoroutineScope(Dispatchers.IO).launch { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } } @Composable fun CustomExchangeRateRow( exchangeRateShowUp: Boolean, onExchangeRateChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.custom_exchange_rate)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = exchangeRateShowUp, onCheckedChange = onExchangeRateChanged ) } } @Preview(showBackground = true) @Composable fun CurrencyConverterLayoutPreview() { val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } CurrencyConverterAppTheme { CurrencyConverterLayout(api) } } ``` ## 使用 Navigation 來切換 Screen 在 `build.gradle` 中加入 ``` implementation("androidx.navigation:navigation-compose:2.7.7") ``` 檔案 `MainActivity.kt` ```kotlin= package com.example.currencyconverterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.currencyconverterapp.ui.theme.CurrencyConverterAppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET data class CurrencyResponse( val jpy: CurrencyRate ) data class CurrencyRate( val twd: Double ) interface CurrencyApi { @GET("v1/currencies/jpy.json") suspend fun getExchangeRate(): CurrencyResponse } class MainActivity : ComponentActivity() { private val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CurrencyConverterAppTheme { val navController = rememberNavController() NavHost(navController = navController, startDestination = "main") { composable("main") { Surface( modifier = Modifier.fillMaxSize(), ) { CurrencyConverterLayout(navController, api) } } composable("settings") { Surface( modifier = Modifier.fillMaxSize(), ) { SettingsScreen() } } } } } } } @Composable fun CurrencyConverterLayout(navController: NavController, api: CurrencyApi) { var amountInput by remember { mutableStateOf("") } var exchangeRateInput by rememberSaveable { mutableStateOf("0.21") } var exchangeRateShowUp by rememberSaveable { mutableStateOf(false) } val amount = amountInput.toDoubleOrNull() ?: 0.0 val exchangeRate = exchangeRateInput.toDoubleOrNull() ?: 0.0 val targetAmount = amount * exchangeRate Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.currency_converter), modifier = Modifier .padding(bottom = 16.dp, top = 40.dp) .align(alignment = Alignment.Start) ) TextField( value = amountInput, onValueChange = { amountInput = it }, label = { Text(stringResource(R.string.jpy)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = if (exchangeRateShowUp) ImeAction.Next else ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) AnimatedVisibility( exchangeRateShowUp, enter = fadeIn() + slideInHorizontally(), exit = fadeOut() + slideOutHorizontally() ) { TextField( value = exchangeRateInput, onValueChange = { exchangeRateInput = it }, label = { Text(stringResource(R.string.jpy_to_twd)) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), modifier = Modifier .padding(bottom = 32.dp) .fillMaxWidth() ) } CustomExchangeRateRow( exchangeRateShowUp = exchangeRateShowUp, onExchangeRateChanged = { exchangeRateShowUp = it }, modifier = Modifier.padding(bottom = 32.dp) ) Text( text = stringResource(R.string.twd_amount, targetAmount), style = MaterialTheme.typography.displayMedium ) } IconButton( onClick = { navController.navigate("settings") }, modifier = Modifier .statusBarsPadding() .safeDrawingPadding() .padding(top = 10.dp) .padding(horizontal = 10.dp) .align(Alignment.TopEnd) ) { Icon(Icons.Filled.Settings, contentDescription = null, modifier = Modifier.size(36.dp)) } } LaunchedEffect(Unit) { CoroutineScope(Dispatchers.IO).launch { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } } @Composable fun CustomExchangeRateRow( exchangeRateShowUp: Boolean, onExchangeRateChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.custom_exchange_rate)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = exchangeRateShowUp, onCheckedChange = onExchangeRateChanged ) } } @Preview(showBackground = true) @Composable fun CurrencyConverterLayoutPreview() { val api by lazy { Retrofit.Builder() .baseUrl("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/") .addConverterFactory(GsonConverterFactory.create()) .build() .create(CurrencyApi::class.java) } CurrencyConverterAppTheme { val navController = rememberNavController() CurrencyConverterLayout(navController, api) } } ``` 檔案 `SettingScreen.kt` ```kotlin= package com.example.currencyconverterapp import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun SettingsScreen() { Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text(text = "Settings Screen") } } ``` ## Testing `MainActivity.kt` ```kotlin= val targetAmount = formatCurrency(amount * exchangeRate) ``` ```kotlin= @VisibleForTesting fun formatCurrency(amount: Double): String { return String.format("%.2f", amount) } ``` `ExampleUnitTest.kt` ```kotlin= class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } @Test fun currencyFormat_isCorrect() { assertEquals("1000.00", formatCurrency(1000.0)) assertEquals("1000.50", formatCurrency(1000.50)) assertEquals("1000.31", formatCurrency(1000.31415)) assertEquals("1000.62", formatCurrency(1000.61742)) } } ``` ## 課後練習 在 Currency Converter app 的設定畫面中自行設計想要的功能吧