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

正直に言うと、毎朝同じことの繰り返しにうんざりしてました。まず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ライフを!



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