【Claude活用】プロンプト付き!生成AIと作るAndroidアプリ開発入門:第5回「UI開発」

概要

前回の記事で、DB接続のためのコードについて悪戦苦闘しながらエラーを解消しました。

何とか動くようになったので、次にUI周りを開発していこうと思います。

前回の記事はこちら

UI周りの開発は見た目が大分わかりやすく変わるので、好きな人は好きかもしれません。これ実装したいな、違和感だなと思ったら生成AIに投げて修正していこうと思います。

使用環境

  • Windows 11
  • Claude (無料プラン)

前回と引き続きですが、今回は情報の見比べをする訳でもないのでClaude君だけで頑張っていきます。

UI開発

現状整理→簡単なたたき台作成

今回もClaude君に頼みますが、エラー解消で本来の目的を忘れていそうなので、最初に現状報告をしながら何をしたいか伝えていきましょうか。

以下のような回答で、2つファイルを新規作成と1つファイルの変更をしていきます。「ui」パッケージは既存のパッケージと競合して作成できなかったので、「appui」パッケージを作成してその下に作成していきます。

パッケージの位置を直したコードが以下です。コピペに使って下さい。

appui/SongViewModel.kt
package com.example.karaokememo.appui

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.karaokememo.data.Song
import com.example.karaokememo.data.SongDatabase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class SongViewModel(application: Application) : AndroidViewModel(application) {

    private val dao = SongDatabase.getDatabase(application).songDao()

    private val _category = MutableStateFlow("my_songs")
    val category: StateFlow<String> = _category

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query

    val songs: StateFlow<List<Song>> = _category
        .combine(_query) { category, query -> Pair(category, query) }
        .flatMapLatest { (category, query) ->
            dao.getSongsByCategoryAndQuery(category, query)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun setCategory(category: String) {
        _category.value = category
    }

    fun setQuery(query: String) {
        _query.value = query
    }

    fun addSong(title: String, artist: String) {
        viewModelScope.launch {
            dao.insert(Song(title = title, artist = artist, category = _category.value))
        }
    }

    fun deleteSong(song: Song) {
        viewModelScope.launch {
            dao.delete(song)
        }
    }
}
appui/SongApp.kt
package com.example.karaokememo.appui

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.karaokememo.data.Song

// タブの定義
val tabs = listOf(
    Triple("my_songs", "持ち歌", Icons.Default.Star),
    Triple("practice", "練習曲", Icons.Default.Build),
    Triple("want_to_sing", "歌いたい曲", Icons.Default.Favorite)
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongApp(viewModel: SongViewModel = viewModel()) {
    val songs by viewModel.songs.collectAsState()
    val query by viewModel.query.collectAsState()
    val category by viewModel.category.collectAsState()
    var showDialog by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    OutlinedTextField(
                        value = query,
                        onValueChange = { viewModel.setQuery(it) },
                        placeholder = { Text("曲名・歌手名で検索") },
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(end = 8.dp),
                        leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
                    )
                }
            )
        },
        bottomBar = {
            NavigationBar {
                tabs.forEach { (key, label, icon) ->
                    NavigationBarItem(
                        selected = category == key,
                        onClick = { viewModel.setCategory(key) },
                        icon = { Icon(icon, contentDescription = label) },
                        label = { Text(label) }
                    )
                }
            }
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { showDialog = true }) {
                Icon(Icons.Default.Add, contentDescription = "追加")
            }
        }
    ) { padding ->
        if (songs.isEmpty()) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding),
                contentAlignment = Alignment.Center
            ) {
                Text("曲が登録されていません", color = MaterialTheme.colorScheme.onSurfaceVariant)
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding)
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
                contentPadding = PaddingValues(vertical = 8.dp)
            ) {
                items(songs, key = { it.id }) { song ->
                    SongCard(song = song, onDelete = { viewModel.deleteSong(song) })
                }
            }
        }
    }

    if (showDialog) {
        AddSongDialog(
            onDismiss = { showDialog = false },
            onAdd = { title, artist ->
                viewModel.addSong(title, artist)
                showDialog = false
            }
        )
    }
}

@Composable
fun SongCard(song: Song, onDelete: () -> Unit) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = song.title, style = MaterialTheme.typography.titleMedium)
                Text(text = song.artist, style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant)
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, contentDescription = "削除",
                    tint = MaterialTheme.colorScheme.error)
            }
        }
    }
}

@Composable
fun AddSongDialog(onDismiss: () -> Unit, onAdd: (String, String) -> Unit) {
    var title by remember { mutableStateOf("") }
    var artist by remember { mutableStateOf("") }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("曲を追加") },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("曲名") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
                OutlinedTextField(
                    value = artist,
                    onValueChange = { artist = it },
                    label = { Text("歌手名") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
            }
        },
        confirmButton = {
            TextButton(
                onClick = { if (title.isNotBlank()) onAdd(title, artist) },
                enabled = title.isNotBlank()
            ) { Text("追加") }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("キャンセル") }
        }
    )
}
MainActivity.kt
package com.example.karaokememo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import com.example.karaokememo.appui.SongApp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                SongApp()
            }
        }
    }
}

これで「SyncNow」をぽちっとしてもSongAppが真っ赤っかです。それを聞きます。

1. エラー解消

エラーをコピーしつつ、appuiというパッケージに変更したことも報告しています。報連相大事!

こう俯瞰してみると、「icons」が解決できていないように見えます。

Claude君回答は以下の通りでした。依存関係の解決と、型推論エラーの解消らしいですね。ふむふむ。

修正版コードは以下!都度「SyncNow」をして、反映していく感じですね。

bundle.gradle.kts(app)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    id("com.google.devtools.ksp")
}

android {
    namespace = "com.example.karaokememo"
    compileSdk {
        version = release(37) {
            minorApiLevel = 0
        }
    }

    defaultConfig {
        applicationId = "com.example.karaokememo"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    buildFeatures {
        compose = true
    }
}

dependencies {
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    testImplementation(libs.junit)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(libs.androidx.junit)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
    debugImplementation(libs.androidx.compose.ui.tooling)
    // Room Database
    implementation("androidx.room:room-runtime:2.8.4")
    implementation("androidx.room:room-ktx:2.8.4")
    ksp("androidx.room:room-compiler:2.8.4")

// ViewModel & LiveData
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")

// Compose (すでに入っているはずですが念のため)
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.navigation:navigation-compose:2.7.6")

    implementation("androidx.compose.material:material-icons-extended")
}

SongApp.kt
package com.example.karaokememo.appui

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.karaokememo.data.Song
import androidx.compose.ui.graphics.vector.ImageVector

// タブの定義
data class TabItem(val key: String, val label: String, val icon: ImageVector)

val tabs = listOf(
    TabItem("my_songs", "持ち歌", Icons.Default.Star),
    TabItem("practice", "練習曲", Icons.Default.Build),
    TabItem("want_to_sing", "歌いたい曲", Icons.Default.Favorite)
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongApp(viewModel: SongViewModel = viewModel()) {
    val songs by viewModel.songs.collectAsState()
    val query by viewModel.query.collectAsState()
    val category by viewModel.category.collectAsState()
    var showDialog by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    OutlinedTextField(
                        value = query,
                        onValueChange = { viewModel.setQuery(it) },
                        placeholder = { Text("曲名・歌手名で検索") },
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(end = 8.dp),
                        leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
                    )
                }
            )
        },
        bottomBar = {
            NavigationBar {
                tabs.forEach { tab ->
                    NavigationBarItem(
                        selected = category == tab.key,
                        onClick = { viewModel.setCategory(tab.key) },
                        icon = { Icon(tab.icon, contentDescription = tab.label) },
                        label = { Text(tab.label) }
                    )
                }
            }
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { showDialog = true }) {
                Icon(Icons.Default.Add, contentDescription = "追加")
            }
        }
    ) { padding ->
        if (songs.isEmpty()) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding),
                contentAlignment = Alignment.Center
            ) {
                Text("曲が登録されていません", color = MaterialTheme.colorScheme.onSurfaceVariant)
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding)
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
                contentPadding = PaddingValues(vertical = 8.dp)
            ) {
                items(songs, key = { it.id }) { song ->
                    SongCard(song = song, onDelete = { viewModel.deleteSong(song) })
                }
            }
        }
    }

    if (showDialog) {
        AddSongDialog(
            onDismiss = { showDialog = false },
            onAdd = { title, artist ->
                viewModel.addSong(title, artist)
                showDialog = false
            }
        )
    }
}

@Composable
fun SongCard(song: Song, onDelete: () -> Unit) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = song.title, style = MaterialTheme.typography.titleMedium)
                Text(text = song.artist, style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant)
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, contentDescription = "削除",
                    tint = MaterialTheme.colorScheme.error)
            }
        }
    }
}

@Composable
fun AddSongDialog(onDismiss: () -> Unit, onAdd: (String, String) -> Unit) {
    var title by remember { mutableStateOf("") }
    var artist by remember { mutableStateOf("") }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("曲を追加") },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("曲名") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
                OutlinedTextField(
                    value = artist,
                    onValueChange = { artist = it },
                    label = { Text("歌手名") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
            }
        },
        confirmButton = {
            TextButton(
                onClick = { if (title.isNotBlank()) onAdd(title, artist) },
                enabled = title.isNotBlank()
            ) { Text("追加") }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("キャンセル") }
        }
    )
}

2. 実行結果

上記の方法で一通りエラーが解決できましたので、実行してみました!

エミュレータを起動後、「Run」をぽちっと。

少し実行に時間はかかるものの、以下の画面が起動できました!

おおー!!かなりいい感じですね。

色々と触ってみましょうか。下のタブボタンを選択したり、「+」ボタンをクリック、削除もしてみます。

ほお、なるほど!実際に触ると、こここうしたいなというのが出てきますね。

ここからは好きに機能を追加してほしいので、一旦記事としてはここまでにします!

これを基にぜひ皆様個人個人で開発を進めてください。

番外編

Claude君との対話を載せて開発するのは上記までなのですが、触ってみて修正したいなという部分が何個か出てきたので、それを実装します。

一旦、改善点は以下の3項目です。

  • 曲の追加時、「持ち歌」「練習曲」「歌いたい曲」を選択できるようにドロップダウンリストを入れる
  • 曲の削除時、本当に削除していいかの確認ダイアログを出す
  • 保存済みの曲を「持ち歌」「練習曲」「歌いたい曲」で移動できるようにする

これをClaude君に投げて追加していきました。

実行結果は以下です!

いい感じですね!!!

エラーが出たら一度投げますが、かなりサクサク変更ができました。さらに追加したい機能があれば、対話しながらできそうです。

まとめ

UI開発をClaude君に投げて作成していきました。

知識がなくてもUIが実装されていくのは面白いですね!

今回のようなAndroidアプリやフロントエンドなどは情報が豊富なので、生成AIにとっては得意な領分だと思います。ただ、エラーにぶつかったときに知識がないと修正箇所がわからずその場しのぎになってしまいがちなので、生成AIを使用しながら知識を一緒に得ていく、並行して勉強していく姿勢は大事だなと改めて感じました。コード先行で、知識が追い付いていない状況ですね。

連載していました全5回にわたるAndroideアプリ開発入門はこれにて終了です。引き続きアプリ開発など楽しみながら勉強していきましょう。

ではではまた別の記事で!

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です