Android kotlin Jetpack Compose + Room + ksp でAndroid Hiltを利用したアプリ作成2

本記事は、Jetpack Compose、Room、kspでDIを利用した実装方法です。

前回の記事からの続きになります。

https://qiita.com/android_aim_for_the_top/items/514e8197dd41182bc2bb


# 1.ログイン画面、店舗一覧画面の実装
Jetpack Composeの画面構成は、Compose UIとして実装しています。
ファイル名などの命名規約は、Android Codelabなどを参考にしました。
リソースファイル、共通クラスなどは後述します。
```LoginScreen.kt
/**
 * 画面　遷移時のパラメーター
 */
object LoginScreenDestination : NavigationDestination {
    override val route = "Login"
    override val titleRes = R.string.login
}

/**
 * ログイン画面
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
    onButtonClick: () -> Unit,
) {

    //context
    val context = LocalContext.current

    //TODO Composable関数の引数にViewModelを渡す実装は危険です。
    //ViewModel 定数として宣言する。
    val viewModel: LoginViewModel = hiltViewModel()

    //画面ステータス
    val myUiState by viewModel.myUiState.collectAsState()

    //処理中のインジケーターを表示する為のフラグ
    var isLoadingFlg: Boolean by remember {
        mutableStateOf(false)
    }

    // アカウント（入力状態ほ保持する為）
    var account by remember { mutableStateOf<String>("") }
    // パスワード（入力状態ほ保持する為）
    var password by remember { mutableStateOf<String>("") }


    //画面ステータスを監視する処理（副作用）
    //コンポーザブル内から suspend 関数を安全に呼び出す
    LaunchedEffect(key1 = myUiState){
        Log.d("LoginScreen", "###   LaunchedEffect isStateFlg : $isLoadingFlg   ###")

        when(myUiState){
            is Resource.Loading -> {
                Log.d("LoginScreen", "###   Resource.Loading   ###")
                myUiState.isLoading.also {
                    Log.d("LoginScreen", "###   Resource.Loading : $it   ###")
                    isLoadingFlg = it
                }
            }

            is Resource.Success -> {
                Log.d("LoginScreen", "###   Resource.Success   ###")
                isLoadingFlg = false

                //TODO ホーム画面に遷移する処理を実装する。
                onButtonClick.invoke()
            }

            is Resource.Error -> {
                Log.d("LoginScreen", "###   Resource.Error   ###")
            }

            else -> {
                Log.d("LoginScreen", "###   else   ###")
            }
        }
    }

    //レイアウト
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background)

    ) {
        // Header
        LoginScreenHeader()

        // Body
        //TODO weight(1f) これを設定しないとフッターが下段に配置されない。
        Box(
            modifier = Modifier
                .fillMaxSize()
                .weight(1f)
        ) {
            LoginScreenBody(
                account = account,
                password = password,
                onAccountChange = {
                    account = it
                },
                onPasswordChange = {
                    password = it
                }
            )

            if (isLoadingFlg){
                IndeterminateCircularIndicatorBox()
            }

        }

        //Footer
        LoginScreenFooter(
            onButtonClick = {
                //TODO ボタンイベント処理を実装する
                Toast.makeText(context, "ログインボタン", Toast.LENGTH_SHORT).show()
                viewModel.searchUser(
                    account = account,
                    password = password
                )
                isLoadingFlg = true
            }
        )

    } //Column


}

/**
 * タイトル
 */
@Composable
fun LoginScreenHeader(

){
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.primary)
            .padding(16.dp)
    ) {
        Text(
            text = stringResource(id = LoginScreenDestination.titleRes),
            color = Color.White
        )
    }

}


/**
 * アカウント/パスワード入力エリア
 */
@OptIn(
    ExperimentalMaterial3Api::class,
    ExperimentalComposeUiApi::class,
)
@Composable
fun LoginScreenBody(
    account: String,
    password: String,
    onAccountChange: (String) -> Unit,
    onPasswordChange: (String) -> Unit,

){

    //キーボード制御
    val keyboardController = LocalSoftwareKeyboardController.current

    //アイテムを画面上の垂直方向に配置する
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)

    ) {
        //アカウント
        OutlinedTextField(
            value = account,
            onValueChange = {
                onAccountChange(it)
            },
            label = { Text(text = stringResource(id = R.string.account_en)) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            maxLines = 1,
            //キーボード入力タイプ
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
        )

        //パスワード
        OutlinedTextField(
            value = password,
            onValueChange = {
                onPasswordChange(it)
            },
            label = { Text(text = stringResource(id = R.string.password_en)) },
            keyboardOptions = KeyboardOptions.Default.copy(
                //キーボード入力タイプ
                imeAction = ImeAction.Done,
                keyboardType = KeyboardType.Password
            ),
            keyboardActions = KeyboardActions(
                onDone = {
                    keyboardController?.hide()
                }
            ),
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            maxLines = 1,

        )
    }

}


@Composable
fun LoginScreenFooter(
    //コールバックメソッド
    onButtonClick: () -> Unit,

){

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.primary)
            .padding(16.dp),
        horizontalArrangement = Arrangement.End,
        verticalAlignment = Alignment.Bottom

    ) {

        //TODO ボタンの背景色を後で変更する
        Button(
            onClick = onButtonClick,
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp)
                .background(MaterialTheme.colorScheme.primary),
        ) {
            Text(text = stringResource(id = R.string.login))
        }

    }

}
```


```StoreListScreen.kt
/**
 * 画面　遷移時のパラメーター
 */
object StoreListScreenDestination : NavigationDestination {
    override val route = "StoreList"
    override val titleRes = R.string.store_list
}

/**
 * 店舗リスト
 */
@Composable
fun StoreListScreen(
    onBackToLogin: () -> Unit,

){

    //context
    val context = LocalContext.current

    //TODO Composable関数の引数にViewModelを渡す実装は危険です。
    //ViewModel 定数として宣言する。
    val viewModel: StoreViewModel = hiltViewModel()

    // 店舗名（入力状態ほ保持する為）
    var storeName by remember { mutableStateOf<String>("") }

    //画面ステータス
    val myUiState by viewModel.myUiState.collectAsState()

    //rememberSaveable　初回のみ初期化コードが実行され、その後は保存された値が再利用される仕組みです
    val dataStateItemsSaveable = rememberSaveable(saver = StoreListSaver()) {
        mutableListOf<Store>().apply {
            myUiState.data?.let {
                addAll(it)
            }
        }
    }

    //画面ステータスを監視する処理（副作用）
    //コンポーザブル内から suspend 関数を安全に呼び出す
    LaunchedEffect(key1 = myUiState){
        when(myUiState){
            is Resource.Loading -> {
                Log.d("StoreListScreen", "###   Resource.Loading   ###")
                myUiState.isLoading.also {
                    Log.d("StoreListScreen", "###   Resource.Loading : $it   ###")
                    //TODO 画面にデータを表示する為の処理
                    viewModel.selectStoreList()
                }

            }

            is Resource.Success -> {
                Log.d("StoreListScreen", "###   Resource.Success   ###")
                dataStateItemsSaveable.apply {
                    myUiState.data?.let {
                        it.forEach {
                            Log.d("StoreListScreen", "###   id: ${it.id}, storeName: ${it.storeName}   ###")
                        }
                        clear()
                        addAll(it)
                    }
                }
            }

            is Resource.Error -> {
                Log.d("StoreListScreen", "###   Resource.Error   ###")
            }

            else -> {
                Log.d("StoreListScreen", "###   else   ###")
            }
        }
    }

    // Body
    //weight(1f) これを設定しないとフッターが下段に配置されない。
    //レイアウト
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background)

    ) {
        // Header
        StoreListScreenHeader()

        // Body
        //TODO weight(1f) これを設定しないとフッターが下段に配置されない。
        Box(
            modifier = Modifier
                .fillMaxSize()
                .weight(1f)
        ) {
            StoreListScreenBody(
                storeList = dataStateItemsSaveable.toList(),
                storeName = storeName,
                onStoreNameChange = {
                    storeName = it
                }
            )

            Row(
                modifier = Modifier
                    .align(alignment = Alignment.BottomEnd)
                    .padding(8.dp),
            ) {
                FloatingActionButtonSmall(
                    onClick = {
                        //TODO 店舗追加処理を実装する。
                        Toast.makeText(context, "店舗追加", Toast.LENGTH_SHORT).show()
                        viewModel.addStore(storeName = storeName)
                        //登録後に入力欄をクリアする為の処理
                        storeName = ""
                    }
                )
            }

        }

        //Footer
        StoreListScreenFooter()

    } //Column

    OnBackPressed {
        onBackToLogin.invoke()
    }

}


@Composable
fun StoreListScreenHeader(

) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.primary)
            .padding(16.dp)
    ) {
        Text(
            text = stringResource(id = StoreListScreenDestination.titleRes),
            color = Color.White
        )
    }

}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StoreListScreenBody(
    storeList: List<Store>,
    storeName: String,
    onStoreNameChange: (String) -> Unit,

) {

    Log.d("StoreListScreen", "###   StoreListScreenBody start   ###")

    //アイテムを画面上の垂直方向に配置する
    Column(

    ) {
        //入力エリア
        OutlinedTextField(
            value = storeName,
            onValueChange = {
                onStoreNameChange(it)
            },
            label = {
                Text(text = stringResource(id = R.string.store_name))
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            maxLines = 1,
            //キーボード入力タイプ
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
        )

        //スペース
        Spacer(modifier = Modifier
            .height(20.dp),
        )

        //リスト
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            items(
                count = storeList.size,
            ) {
                val store = storeList.get(it)
                StoreCellRow(
                    store = store
                )
            }
        }

    }


}


@Composable
fun StoreCellRow(
    store: Store,
    modifier: Modifier = Modifier,

) {

    Column(
        modifier = modifier
            .padding(horizontal = 10.dp)
            .clickable {
                //TODO タップイベントを実装する。
            },
    ) {
        Spacer(modifier = Modifier.height(10.dp))

        Row(
            verticalAlignment = Alignment.CenterVertically,

        ) {
            Text(
                text = store.storeName,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.padding(start = 20.dp),

            )
        }

        Divider()
    }

}



@Composable
fun StoreListScreenFooter(

){
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.primary)
            .padding(16.dp),
        horizontalArrangement = Arrangement.End,
        verticalAlignment = Alignment.Bottom

    ) {

    }

}

```


# 2.画面遷移
NavControllerを利用した画面遷移を実装しています。
詳しくはこちらから

https://developer.android.com/jetpack/compose/navigation?hl=ja


このクラスは画面遷移のNavControllerで利用するrouteのkeyを設定するインタフェースになります。
```NavigationDestination.kt
interface NavigationDestination {

    /**
     * Unique name to define the path for a composable
     */
    val route: String

    /**
     * String resource id to that contains title to be displayed for the screen.
     */
    val titleRes: Int

}
```

これがメインとなる画面遷移処理を実装するComposableです。
```InventoryNavHost.kt
const val TAG = "InventoryNavHost"

/**
 * NavHostController
 * 画面遷移の制御を行う。
 * 画面追加した場合、必ずこのファイルに追加する
 */
@Composable
fun InventoryNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier,
    //TODO 初回起動はログイン画面
    startDestination: String = LoginScreenDestination.route,

){

    //アプリ画面遷移処理
    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,

    ) {
        Log.d(TAG, "###   NavHost start   ###")

        //TODO ログイン画面
        composable(
            route = LoginScreenDestination.route,

        ) {
            LoginScreen(
                onButtonClick = {
                    //店舗リスト画面へ遷移する
                    navController.navigate(StoreListScreenDestination.route){
                        launchSingleTop = true
                    }

                }
            )
        }

        //TODO 店舗リスト画面
        composable(
            route = StoreListScreenDestination.route,

        ) {
            StoreListScreen(
                onBackToLogin = {
                    //戻るボタンでログイン画面へ戻る
                    navController.navigate(LoginScreenDestination.route){

                        //TODO バックスタックにある画面を削除する方法を調査する。
//                        popUpTo(StoreListScreenDestination.route) {
//                            inclusive = true
//                        }

//                        launchSingleTop = true

                    }
                }
            )


        }

    }

}
```

これはアプリ起動時にNavControllerを作成するComposableです。MainActivityクラスから呼び出します。
```InventoryApp.kt
@Composable
fun InventoryApp(
    navController: NavHostController = rememberNavController()
){
    InventoryNavHost(
        navController = navController
    )

}
```


これがアプリ起動時のMainActivityです。
```MainActivity.kt
/**
 * アプリ起動画面
 */
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeHilt01Theme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    InventoryApp()
                }
            }
        }
    }
}
```

共通コンポーザブルです。
```OnBackPressed.kt
/**
 * 端末ソフトキーボードの戻るボタンイベント処理
 */
@Composable
fun OnBackPressed(
    onBack: () -> Unit,

){

    val dispatcherOwner = LocalOnBackPressedDispatcherOwner.current
    DisposableEffect(Unit) {
        val backCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                // ソフトキーボードの戻るボタンが押されたときに呼ばれる
                // onBackToLoginを呼び出すことでLoginScreenに戻り、loginResultStateの初期値が0に設定される
                onBack()
            }
        }

        // OnBackPressedDispatcherOwnerを使用してOnBackPressedCallbackを登録
        dispatcherOwner?.also {
            val dispatcher: OnBackPressedDispatcher = it.onBackPressedDispatcher
            dispatcher.addCallback(backCallback)
        }

        // DisposableEffectのクリーンアップ時に登録したコールバックを解除
        onDispose {
            backCallback.remove()
        }
    }

}
```

```FloatingActionButton.kt
/**
 * 小さいサイズのFloatingActionButton
 */
@Composable
fun FloatingActionButtonSmall(onClick: () -> Unit) {
    SmallFloatingActionButton(
        onClick = {
            onClick()
        },
        containerColor = MaterialTheme.colorScheme.secondaryContainer,
        contentColor = MaterialTheme.colorScheme.secondary,
        shape = CircleShape,

        ) {
        Icon(Icons.Filled.Add, "Small floating action button.")
    }
}

/**
 * 大きいサイズのFloatingActionButton
 */
@Composable
fun FloatingActionButtonLarge(
    onClick: () -> Unit
) {
    LargeFloatingActionButton(
        onClick = {
            onClick()
        },
        shape = CircleShape,
    ) {
        Icon(
            Icons.Filled.Add, "Large floating action button"
        )
    }
}

```


```IndeterminateCircularIndicator.kt
/**
 * インジケーター
 */
@Composable
fun IndeterminateCircularIndicatorBox(

){

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.5f))

    ) {
        CircularProgressIndicator(
            modifier = Modifier
                .size(50.dp)
                .align(Alignment.Center)
        )
    }
}


/**
 * インジケーター
 */
@Composable
fun IndeterminateCircularIndicatorColumn(

){
    Column(
        modifier = Modifier
            .fillMaxSize()

    ) {
        CircularProgressIndicator(
            modifier = Modifier
                .size(50.dp)
                .align(alignment = CenterHorizontally)
        )
    }

}
```


共通クラスです。
```Constants.kt
class Constants {

    companion object {
        const val BASE_URL = "https://qiita.com/api/v2/"
    }

}
```

```Resource.kt
sealed class Resource<T>(
    val data: T? = null,
    val message: String? = null,
    val isLoading: Boolean = false,

){

    /**
     * 処理結果が成功時のイベント
     */
    class Success<T>(data: T)
        : Resource<T>(data = data)

    /**
     * 処理結果がエラー時のイベント
     */
    class Error<T>(data: T? = null, message: String, )
        : Resource<T>(data = data, message = message)

    /**
     * 処理中のイベント
     */
    class Loading<T>(data: T? = null, isLoading: Boolean)
        : Resource<T>(data = data, isLoading = isLoading)


}

```


```StoreListSaver.kt
class StoreListSaver : Saver<MutableList<Store>, MutableList<Store>> {

    override fun restore(value: MutableList<Store>): MutableList<Store> {
        return value
    }

    override fun SaverScope.save(value: MutableList<Store>): MutableList<Store> {
        return value
    }

}
```

# 3.最後に
* 一部、動作に不備があります
ログイン画面（LoginScreen.kt）から店舗一覧画面（StoreListScreen.kt）へ遷移中に、アプリをバックグラウンドからフォアグラウンドへ操作し戻るボタンをタップした場合、店舗一覧画面（StoreListScreen.kt）が再表示されてしまう。
popUpToメソッドなどを利用すれば良いかと思うのですが、うまく動作しないです。
もし、対応方法をご存知の方がいましたら、助言して頂けるとありがたいです。


以上で説明は終わりになります。
ご意見ご要望があれば、お気軽にお知らせください。