なぜこのアプリを作るのか?

正直に言うと、毎朝同じことの繰り返しにうんざりしてました。まずGitHubを開いて、昨日のプロジェクトにスターが付いてないか確認。次にCoinMarketCapでビットコインがまた下がってないかチェック(一応、投資家なので…)。それからHackerNewsで技術トレンドを読む。これ、いくつものアプリを開いてますよね?バカげてる気がしますね。そこで自分は”全部1つのアプリにまとめたらどうだろう?”って思ったんです。で、実際に作ってみました。めちゃくちゃ便利です。今回はこのアプリの作り方を、皆さんに共有したいと思います。


誰に読んでほしいか

このチュートリアルは、こんなあなたのために書きました!

・Androidアプリを作ってみたいけど、どこから手をつければいいかわからない
・Jetpack Composeってよく聞くけど、実際に使える例が見たい
・Todoアプリのチュートリアルはもう飽きた。実際に使えるものを作りたい

Kotlinの基本がわかっていれば大丈夫です。一緒にコードを書きながら進めていきましょう。


このダッシュボードには4つの機能を入れました!

1. GitHub統計 : 自分のリポジトリとスター数が見れる
2. 仮想通貨価格 : ビットコインとかの最新価格をチェック
3. HackerNewsフィード : 開発者向けのニュースを読める
4. ポモドーロタイマー : 集中作業用タイマー
全部、実際に僕が毎日使っている機能です。

学べること
このチュートリアルを終える頃には、こんなことができるようになってます

・Jetpack Composeで見た目の良いUIを作る
・RetrofitでAPIからデータを取ってくる
・ViewModelで画面の状態を管理する
・MVVMアーキテクチャを実装する(なんか難しそうだけど、やってみると意外と簡単)
・Kotlinコルーチンで非同期処理を書く

コードを載せるので、見ながら理解していけば大丈夫です。じゃあ、始めましょうか。


Part 1: プロジェクトのセットアップ

1. プロジェクトの作成

まずは新しいプロジェクトを作りましょう。Android Studioを開いて
1. テンプレート: Empty Activity (Compose)
2. 名前: DevDashboard(好きな名前でOKです)
3. パッケージ名(なんでも良い): com.example.tester
4. 最小SDK: API 24(これなら大体のデバイスで動きます)
5. ビルド設定: Kotlin DSL


2. 依存関係の追加

ここが少しめんどくさい部分ですが、最初に全部入れちゃいましょう!

gradle/libs.versions.toml
このファイルの[versions]セクションを探して、こんな感じで追加してください


retrofit = "2.9.0"
okhttp = "4.12.0"
coil = "2.5.0"
lifecycleViewModel = "2.10.0"

次に、同じファイルの[libraries]セクションにも追加

retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }

app/build.gradle.kts
次に、app/build.gradle.ktsを開いて、dependenciesブロックの中にこれを追加する

// ネットワーキング
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging)
// 画像読み込み
implementation(libs.coil.compose)
// ViewModel
implementation(libs.lifecycle.viewmodel.compose)

3. インターネット権限の追加

ネットワーク通信するには権限が必要です。AndroidManifest.xmlに1行追加するだけ


4. プロジェクト構造

ファイルがごちゃごちゃにならないように、最初から整理しておきましょう。MVVMアーキテクチャを使います。難しそうに聞こえるけど、要するに「データ」「ロジック」「UI」を別々のフォルダに分けるってだけの話です


このアプリはMVVM (Model、View、ViewModel)パターンを採用します。

・View (UI): Jetpack Composeで構築された画面とコンポーネント
・ViewModel: 状態管理とビジネスロジック(DashboardViewModel)
・Model (Data): API通信とデータモデル(Retrofit + Data Classes)

データは一方向に流れ(Unidirectional Data Flow)、状態はStateFlowで管理されます。これにより、予測可能で保守しやすいコードベースが実現されます。


Part 2: データレイヤーの実装
さて、これからAPIと話すためのコードを書いていきます。


◾️ GitHub API
data/GitHubApi.ktという新しいファイルを作って、こんな感じで書いてください。


import retrofit2.http.GET
import retrofit2.http.Path

// GitHubユーザー用データクラス
data class GitHubUser(
    val login: String,
    val name: String?,
    val avatar_url: String,
    val public_repos: Int,
    val followers: Int,
    val following: Int
)

// GitHubリポジトリ用データクラス
data class GitHubRepo(
    val name: String,
    val stargazers_count: Int,
    val forks_count: Int,
    val language: String?
)

// GitHub API用のRetrofitインターフェース
interface GitHubApi {
    @GET("users/{username}")
    suspend fun getUser(@Path("username") username: String): GitHubUser

    @GET("users/{username}/repos")
    suspend fun getRepos(@Path("username") username: String): List<GitHubRepo>
}

◾️ ここでは何をやってるか?
1.GitHubUserGitHubRepoは、APIから返ってくるJSONデータを入れる箱です
2.GitHubApiインターフェースは、Retrofitが実際のネットワークコードを自動生成するための設計図
3.suspendキーワードは、この処理は時間がかかるよという目印です

◾️ CoinGecko API
同じように、data/CryptoApi.ktも作りましょう!

import retrofit2.http.GET
import retrofit2.http.Query
data class CryptoPrice(
val id: String,
val symbol: String,
val name: String,
val current_price: Double,
val price_change_percentage_24h: Double,
val image: String
)
interface CryptoApi {
@GET("coins/markets")
suspend fun getCryptoPrices(
@Query("vs_currency") currency: String = "usd",
@Query("order") order: String = "market_cap_desc",
@Query("per_page") perPage: Int = 10,
@Query("page") page: Int = 1
): List<CryptoPrice>
}

CoinGeckoは無料で使える仮想通貨価格APIです。登録不要なのが最高ですね。


HackerNews API
最後に、data/HackerNewsApi.ktを実装する

import retrofit2.http.GET
import retrofit2.http.Path
data class HackerNewsItem(
val id: Long,
val title: String?,
val url: String?,
val score: Int?,
val by: String?,
val time: Long?
)
interface HackerNewsApi {
@GET("topstories.json")
suspend fun getTopStories(): List<Long>
@GET("item/{id}.json")
suspend fun getItem(@Path("id") id: Long): HackerNewsItem

HackerNewsのAPIは少し変わってて、まずトップストーリーのIDリストを取得して、それから各記事の詳細を取得する仕組みです。


Retrofitクライアント
さて、これが全部のAPIクライアントをまとめるやつです。data/RetrofitClient.ktを作りましょう!


import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitClient {
    // デバッグ用のログインターセプター
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    // タイムアウトとログ機能付きOkHttpクライアント
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build()

    // GitHub APIクライアント
    val githubApi: GitHubApi by lazy {
        Retrofit.Builder()
            .baseUrl("https://api.github.com/") // !!!!! 本当はURIやURLなどはConstとした方がいい
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(GitHubApi::class.java)
    }

    // CoinGecko APIクライアント
    val cryptoApi: CryptoApi by lazy {
        Retrofit.Builder()
            .baseUrl("https://api.coingecko.com/api/v3/")
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(CryptoApi::class.java)
    }

    // HackerNews APIクライアント
    val hackerNewsApi: HackerNewsApi by lazy {
        Retrofit.Builder()
            .baseUrl("https://hacker-news.firebaseio.com/v0/")
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(HackerNewsApi::class.java)
    }
}

`by lazy`ってのがポイントで、これのおかげで実際に使うまでインスタンスが作られません。メモリの節約になります。


Part 3: ViewModel

ここからがちょっと面白いところです。ViewModelは、UIとデータの間の橋渡し役です。viewmodel/DashboardViewModel.ktを作りましょう!

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.tester.data.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
// UIの状態を一元管理
data class DashboardUiState(
val githubUser: GitHubUser? = null,
val githubRepos: List<GitHubRepo> = emptyList(),
val cryptoPrices: List<CryptoPrice> = emptyList(),
val hackerNews: List<HackerNewsItem> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val pomodoroTimeLeft: Int = 25 * 60, // 25分
val isPomodoroRunning: Boolean = false
)
class DashboardViewModel : ViewModel() {
// プライベートな変更可能状態
private val _uiState = MutableStateFlow(DashboardUiState())
// パブリックな変更不可能状態
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
// GitHubユーザーとリポジトリを読み込む
fun loadGitHubData(username: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val user = RetrofitClient.githubApi.getUser(username)
val repos = RetrofitClient.githubApi.getRepos(username)
_uiState.value = _uiState.value.copy(
githubUser = user,
githubRepos = repos.sortedByDescending { it.stargazers_count }.take(5),
isLoading = false
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = "GitHubデータの読み込みに失敗: ${e.message}",
isLoading = false
)
}
}
}
// 仮想通貨価格を読み込む
fun loadCryptoPrices() {
viewModelScope.launch {
try {
val prices = RetrofitClient.cryptoApi.getCryptoPrices(perPage = 5)
_uiState.value = _uiState.value.copy(cryptoPrices = prices)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = "仮想通貨価格の読み込みに失敗: ${e.message}"
)
}
}
}
// HackerNewsストーリーを読み込む
fun loadHackerNews() {
viewModelScope.launch {
try {
val topStoryIds = RetrofitClient.hackerNewsApi.getTopStories().take(10)
val stories = topStoryIds.mapNotNull { id ->
try {
RetrofitClient.hackerNewsApi.getItem(id)
} catch (e: Exception) {
null // 失敗したアイテムはスキップ
}
}
_uiState.value = _uiState.value.copy(hackerNews = stories)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = "HackerNewsの読み込みに失敗: ${e.message}"
)
}
}
}
// ポモドーロタイマー機能
fun startPomodoro() {
_uiState.value = _uiState.value.copy(isPomodoroRunning = true)
viewModelScope.launch {
while (_uiState.value.isPomodoroRunning && _uiState.value.pomodoroTimeLeft > 0) {
kotlinx.coroutines.delay(1000) // 1秒待機
_uiState.value = _uiState.value.copy(
pomodoroTimeLeft = _uiState.value.pomodoroTimeLeft - 1
)
}
if (_uiState.value.pomodoroTimeLeft == 0) {
_uiState.value = _uiState.value.copy(isPomodoroRunning = false)
}
}
}
fun pausePomodoro() {
_uiState.value = _uiState.value.copy(isPomodoroRunning = false)
}
fun resetPomodoro() {
_uiState.value = _uiState.value.copy(
pomodoroTimeLeft = 25 * 60,
isPomodoroRunning = false
)
}
}

長いですよね。でも実はやってることはシンプルで

1. DashboardUiStateで画面の状態を全部まとめて管理する
2. StateFlowで状態が変わったらUIに自動で通知する
3. 各関数でAPIを呼んで、結果を状態に反映する

コルーチンのviewModelScope.launchを使うことで、画面が閉じられたら自動的に処理もキャンセルされます。便利ですよね。


Part 4: UIコンポーネント

さて、ここからが見た目の部分です。Jetpack Composeは最初は慣れないかもですが、一度わかると楽しいですよ。

ポモドーロタイマーカード

ui/components/PomodoroCard.ktを作ります。時間を表示して、開始/停止/リセットボタンを付けたシンプルなタイマーです。コードが長いので、完全版は付属のファイルを見てください。

コード
@Composable
fun PomodoroCard(
timeLeft: Int,
isRunning: Boolean,
onStart: () -> Unit,
onPause: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "ポモドーロタイマー",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = formatTime(timeLeft),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
fontSize = 48.sp,
color = if (isRunning) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(24.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
FloatingActionButton(
onClick = if (isRunning) onPause else onStart,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = if (isRunning) Icons.Default.Face else Icons.Default.PlayArrow,
contentDescription = if (isRunning) "一時停止" else "開始"
)
}
FloatingActionButton(
onClick = onReset,
containerColor = MaterialTheme.colorScheme.secondary
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "リセット"
)
}
}
}
}
}
private fun formatTime(seconds: Int): String {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d", minutes, remainingSeconds)
}

GitHubカード
ui/components/GitHubCard.ktを作ります。ユーザーのアバター画像、名前、統計(リポジトリ数、フォロワーなど)と、スター数の多いリポジトリを表示します。


コード
@Composable
fun GitHubCard(
user: GitHubUser?,
repos: List<GitHubRepo>,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "GitHub統計",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
user?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
model = it.avatar_url,
contentDescription = "Avatar",
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = it.name ?: it.login,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "@${it.login}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("リポジトリ", it.public_repos.toString())
StatItem("フォロワー", it.followers.toString())
StatItem("フォロー中", it.following.toString())
}
if (repos.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "人気のリポジトリ",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
repos.take(3).forEach { repo ->
RepoItem(repo)
}
}
} ?: run {
Text(
text = "GitHubデータを読み込んでいません",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun RepoItem(repo: GitHubRepo) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = repo.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
repo.language?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row {
Text(
text = "⭐ ${repo.stargazers_count}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "🍴 ${repo.forks_count}",
style = MaterialTheme.typography.bodySmall
)
}
}
}

仮想通貨カード
ui/components/CryptoCard.ktを作ります。
仮想通貨の価格と24時間の変動率を表示します。上がってたら緑、下がってたら赤で色分けします。


@Composable
fun CryptoCard(
    prices: List<CryptoPrice>,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "💰 仮想通貨価格",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            )

            Spacer(modifier = Modifier.height(16.dp))

            if (prices.isEmpty()) {
                Text(
                    text = "仮想通貨データを読み込んでいません",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            } else {
                prices.forEach { crypto ->
                    CryptoItem(crypto)
                    if (crypto != prices.last()) {
                        HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
                    }
                }
            }
        }
    }
}

@Composable
private fun CryptoItem(crypto: CryptoPrice) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.weight(1f)
        ) {
            AsyncImage(
                model = crypto.image,
                contentDescription = crypto.name,
                modifier = Modifier
                    .size(32.dp)
                    .clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column {
                Text(
                    text = crypto.name,
                    style = MaterialTheme.typography.bodyMedium,
                    fontWeight = FontWeight.Medium
                )
                Text(
                    text = crypto.symbol.uppercase(),
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }

        Column(horizontalAlignment = Alignment.End) {
            Text(
                text = "$${String.format("%.2f", crypto.current_price)}",
                style = MaterialTheme.typography.bodyMedium,
                fontWeight = FontWeight.Bold
            )
            val changeColor = if (crypto.price_change_percentage_24h >= 0) {
                Color(0xFF4CAF50)
            } else {
                Color(0xFFF44336)
            }
            Text(
                text = "${if (crypto.price_change_percentage_24h >= 0) "+" else ""}${
                    String.format("%.2f", crypto.price_change_percentage_24h)
                }%",
                style = MaterialTheme.typography.bodySmall,
                color = changeColor,
                fontWeight = FontWeight.Medium
            )
        }
    }
}




HackerNewsカード
ui/components/HackerNewsCard.ktを作ります。
トップストーリーをリスト表示して、タップしたらブラウザで記事を開きます。


@Composable
fun HackerNewsCard(
    news: List<HackerNewsItem>,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "📰 HackerNews",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            )

            Spacer(modifier = Modifier.height(16.dp))

            if (news.isEmpty()) {
                Text(
                    text = "ニュースを読み込んでいません",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            } else {
                news.take(5).forEach { item ->
                    NewsItem(item)
                    if (item != news.last()) {
                        HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
                    }
                }
            }
        }
    }
}

@Composable
private fun NewsItem(item: HackerNewsItem) {
    val context = LocalContext.current
    
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .clickable {
                item.url?.let { url ->
                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
                    context.startActivity(intent)
                }
            }
            .padding(vertical = 4.dp)
    ) {
        Text(
            text = item.title ?: "タイトルなし",
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Medium,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis
        )
        Spacer(modifier = Modifier.height(4.dp))
        Row {
            item.score?.let {
                Text(
                    text = "⬆ ${it}ポイント",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            item.by?.let {
                Text(
                    text = " • 投稿者: $it",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}





Part 5: メイン画面

最後に、全部をまとめるメイン画面を作ります。ui/screens/DashboardScreen.kt
`LaunchedEffect(Unit)`は、画面が最初に表示されたときに1回だけ実行されるやつです。ここでAPIからデータを取ってきます。あとは全部のカードを縦に並べるだけ。`verticalScroll`で自動的にスクロールできるようにしてます。


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
    viewModel: DashboardViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    var githubUsername by remember { mutableStateOf("") }
    var showDialog by remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        // Load initial data with default GitHub username
        viewModel.loadGitHubData("torvalds") // Linus Torvalds as default
        viewModel.loadCryptoPrices()
        viewModel.loadHackerNews()
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = "開発者ダッシュボード",
                        fontWeight = FontWeight.Bold
                    )
                },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
                )
            )
        },
        floatingActionButton = {
            if (uiState.githubUser == null) {
                FloatingActionButton(
                    onClick = { showDialog = true }
                ) {
                    Text("+ GitHub")
                }
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .verticalScroll(rememberScrollState())
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // Show error if any
            uiState.error?.let { error ->
                Card(
                    colors = CardDefaults.cardColors(
                        containerColor = MaterialTheme.colorScheme.errorContainer
                    )
                ) {
                    Text(
                        text = error,
                        modifier = Modifier.padding(16.dp),
                        color = MaterialTheme.colorScheme.onErrorContainer
                    )
                }
            }

            // Loading indicator
            if (uiState.isLoading) {
                LinearProgressIndicator(
                    modifier = Modifier.fillMaxWidth()
                )
            }

            // Pomodoro Timer
            PomodoroCard(
                timeLeft = uiState.pomodoroTimeLeft,
                isRunning = uiState.isPomodoroRunning,
                onStart = { viewModel.startPomodoro() },
                onPause = { viewModel.pausePomodoro() },
                onReset = { viewModel.resetPomodoro() }
            )

            // GitHub Stats
            GitHubCard(
                user = uiState.githubUser,
                repos = uiState.githubRepos
            )

            // Crypto Prices
            CryptoCard(prices = uiState.cryptoPrices)

            // HackerNews
            HackerNewsCard(news = uiState.hackerNews)

            Spacer(modifier = Modifier.height(80.dp))
        }
    }

    // GitHub Username Dialog
    if (showDialog) {
        AlertDialog(
            onDismissRequest = { showDialog = false },
            title = { Text("GitHubユーザー名を入力") },
            text = {
                OutlinedTextField(
                    value = githubUsername,
                    onValueChange = { githubUsername = it },
                    label = { Text("ユーザー名") },
                    singleLine = true,
                    placeholder = { Text("例: octocat") }
                )
            },
            confirmButton = {
                Button(
                    onClick = {
                        if (githubUsername.isNotBlank()) {
                            viewModel.loadGitHubData(githubUsername)
                            showDialog = false
                        }
                    }
                ) {
                    Text("読み込む")
                }
            },
            dismissButton = {
                TextButton(onClick = { showDialog = false }) {
                    Text("キャンセル")
                }
            }
        )
    }
}
最後のステップとして、DashboardScreenをMainActivityで呼ぶだけです!
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TesterTheme {
DashboardScreen()
}
}
}
}

アプリの実行。さあ、動かしてみましょう!

1. Gradleを同期(Android Studioが自動でやってくれるはず)
2. Build > Make Project(エラーがないか確認)
3. エミュレータか実機で実行
初回起動時は、APIからデータを取ってくるので少し時間がかかります。気長に待ちましょう。


まとめ

お疲れさまでした!これで完成です。振り返ってみると、意外とシンプルですよね。やったこととしては、

RetrofitでAPIと通信
ViewModelでデータを管理
・Jetpack Composeで見た目を作る
StateFlowで画面を自動更新

この4つだけです。でもこれが現代のAndroid開発の基本パターンなんです。
このアプリを自分好みにカスタマイズしてみてください。たとえば

・好きなGitHubユーザーを追加
・他の仮想通貨を追加
・ポモドーロの時間を変更可能に
・ダークモード対応

作ったら、ぜひSNSでシェアしてくださいね!


◾️ 参考資料
もっと深く学びたい人向け
Jetpack Compose公式ドキュメントhttps://developer.android.com/jetpack/compose – 公式が一番詳しい
Retrofit公式サイト https://square.github.io/retrofit – API通信の基本
Material Design 3 https://m3.material.io – デザインガイドライン


◾️ 次のステップ
このアプリをもっと良くするアイデア

DataStoreでユーザー設定を保存(お気に入りのGitHubユーザーとか)
プルして更新機能を追加(引っ張って最新データを取得)
ダーク/ライトテーマの切り替え
通知機能(ポモドーロが終わったら通知とか)
ウィジェット対応(ホーム画面に表示)

それでは以上なので、楽しいAndroidライフを!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる