**Atlas Device SDK (之前叫做 Realm)** https://www.mongodb.com/docs/atlas/device-sdks/ Data layer 管理資料的一個選擇,有提供各個平台的 SDK **TOML** https://toml.io/ ## 在 Currency Converter app 中儲存基本設定與數值 在 `build.gradle` 中加入 ``` implementation("androidx.datastore:datastore-preferences:1.1.0") ``` `MainActivity.kt` ```kotlin import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore ``` ```kotlin val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "preferences") class PreferenceHelper(private val context: Context) { companion object { val EXCHANGE_RATE = stringPreferencesKey("exchange_rate") val USE_CUSTOM_EXCHANGE_RATE = booleanPreferencesKey("use_custom_exchange_rate") } val exchangeRateFlow: Flow<String> = context.dataStore.data .map { preferences -> preferences[EXCHANGE_RATE] ?: "0.0" } val useCustomExchangeRateFlow: Flow<Boolean> = context.dataStore.data .map { preferences -> preferences[USE_CUSTOM_EXCHANGE_RATE] ?: false } suspend fun setExchangeRate(exchangeRate: String) { context.dataStore.edit { preferences -> preferences[EXCHANGE_RATE] = exchangeRate } } suspend fun setUseCustomExchangeRate(useCustomExchangeRate: Boolean) { context.dataStore.edit { preferences -> preferences[USE_CUSTOM_EXCHANGE_RATE] = useCustomExchangeRate } } } ``` `MainActivity.kt` 完整程式碼 ```kotlin= package com.example.currencyconverterapp import android.content.Context 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.platform.LocalContext 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.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore 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.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting 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 } val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "preferences") class PreferenceHelper(private val context: Context) { companion object { val EXCHANGE_RATE = stringPreferencesKey("exchange_rate") val USE_CUSTOM_EXCHANGE_RATE = booleanPreferencesKey("use_custom_exchange_rate") } val exchangeRateFlow: Flow<String> = context.dataStore.data .map { preferences -> preferences[EXCHANGE_RATE] ?: "0.0" } val useCustomExchangeRateFlow: Flow<Boolean> = context.dataStore.data .map { preferences -> preferences[USE_CUSTOM_EXCHANGE_RATE] ?: false } suspend fun setExchangeRate(exchangeRate: String) { context.dataStore.edit { preferences -> preferences[EXCHANGE_RATE] = exchangeRate } } suspend fun setUseCustomExchangeRate(useCustomExchangeRate: Boolean) { context.dataStore.edit { preferences -> preferences[USE_CUSTOM_EXCHANGE_RATE] = useCustomExchangeRate } } } 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) { val context = LocalContext.current val preferences = PreferenceHelper(context) 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 = formatCurrency(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 CoroutineScope(Dispatchers.IO).launch { preferences.setExchangeRate(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 CoroutineScope(Dispatchers.IO).launch { preferences.setUseCustomExchangeRate(it) if (!it) { try { val response = api.getExchangeRate() exchangeRateInput = response.jpy.twd.toString() } catch (e: Exception) { // Handle the error } } } }, 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) { preferences.useCustomExchangeRateFlow.collect { exchangeRateShowUp = it if (it) { preferences.exchangeRateFlow.collect { exchangeRateInput = it } } else { 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 ) } } @VisibleForTesting fun formatCurrency(amount: Double): String { return String.format("%.2f", amount) } @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) } } ``` ## 課後練習 * 繼續完善 Currency Converter app 的設定畫面吧~ * 把自家研發好的剪刀石頭布遊戲移植到 Android 上吧!