なぜこのアプリを作るのか?
正直に言うと、毎朝同じことの繰り返しにうんざりしてました。まず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)// ViewModelimplementation(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.GitHubUserとGitHubRepoは、APIから返ってくるJSONデータを入れる箱です
2.GitHubApiインターフェースは、Retrofitが実際のネットワークコードを自動生成するための設計図
3.suspendキーワードは、この処理は時間がかかるよという目印です
◾️ CoinGecko API
同じように、data/CryptoApi.ktも作りましょう!
import retrofit2.http.GETimport retrofit2.http.Querydata 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.GETimport retrofit2.http.Pathdata 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.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.tester.data.*import kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.asStateFlowimport 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を作ります。時間を表示して、開始/停止/リセットボタンを付けたシンプルなタイマーです。コードが長いので、完全版は付属のファイルを見てください。
コード@Composablefun 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を作ります。ユーザーのアバター画像、名前、統計(リポジトリ数、フォロワーなど)と、スター数の多いリポジトリを表示します。
コード@Composablefun 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 ) } } }}@Composableprivate 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 ) }}@Composableprivate 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ライフを!








