NASA APIを使って「Astronomy Picture of the Day」のデータを取得してAndroidアプリを作ってみた
2023.06.19
Jetpack Composeの勉強をしています。
それなりに作れるようにはなってきましたが業務に携わっているわけではないので推奨された作り方を知りません。
「Jetpack ComposeによるAndroid MVVMアーキテクチャ入門」を参考に、NASA APIを使ってAndroidアプリを作ってみました。
発行日:2021/09/03 となっていましたので、Android デベロッパー の アプリ アーキテクチャ ガイド と照らし合わせてみても 知識がないため2023年でも同じかよくわかりません。
作成したアプリをいろいろといじくりながら知識を深めていきたいと思っています。
今回作成した、備忘録です。
準備
環境
Android Studio: Flamingo 2022.2.1 Patch2
作成
1. プロジェクトの準備
- ・Retrofit/Gson: Web Apiの連携
- ・Hilt: DI (Dependency Injection: 依存関係の注入) *ここが本当にできているか課題
- ・Coil: 画像読み込み
build.gradle(:app)
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
namespace 'com.example.astronomypicture'
compileSdk 33
defaultConfig {
applicationId "com.example.astronomypicture"
minSdk 24
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
implementation "com.google.code.gson:gson:2.8.9"
implementation "io.coil-kt:coil-compose:2.0.0"
implementation("com.google.dagger:hilt-android:2.44")
kapt("com.google.dagger:hilt-android-compiler:2.44")
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
build.gradle(Project)
plugins {
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
id("com.google.dagger.hilt.android") version "2.44" apply false
}
HiltによるDIを行うためのApplicationクラス作成
AstronomyApplication.kt
@HiltAndroidApp
class AstronomyApplication : Application()
Web API連携する際のインターネット接続のパーミッション、Applicationクラスをマニフェストに登録
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".AstronomyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AstronomyPicture"
tools:targetApi="31">
<activity
android:name=".view.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.AstronomyPicture">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="API_KEY"
android:value = "${API_KEY}" />
</application>
</manifest>
2. Modelにあたる部分作成
ApodItem.kt
interface ApiService {
@GET("planetary/apod?")
suspend fun getApodData(
@Query("start_date") startDate: String,
@Query("end_date") endDate: String,
@Query("api_key") apiKey: String
): List<ApodItem>
}
ApiRepos.kt
class ApiRepos @Inject constructor(){
companion object{
const val BASE_URL = "https://api.nasa.gov/"
}
fun getRetrofit(): ApiService {
return Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(BASE_URL)
.build()
.create(ApiService::class.java)
}
}
ApiModule.kt
@Module
@InstallIn(SingletonComponent::class)
class ApiModule {
@Provides
@Singleton
fun provideApiService(apiRepos: ApiRepos): ApiService{
return apiRepos.getRetrofit()
}
}
DataSource.kt
interface DataSource {
suspend fun getApodData(
startDate: String,
endDate: String,
apiKey: String
):List<ApodItem>
}
DataSourceImpl.kt
class DataSourceImpl @Inject constructor(
private val apiService: ApiService
): DataSource{
private var errorMessage: String by mutableStateOf("")
override suspend fun getApodData(
startDate: String,
endDate: String,
apiKey: String
): List<ApodItem> {
val response = apiService.getApodData(
startDate=startDate,
endDate=endDate,
apiKey=apiKey
)
Log.d("Response", "$response")
try {
apopUser.clear()
apopUser.addAll(response)
} catch (e: Exception) {
errorMessage = e.message.toString()
}
return response
}
}
DataSourceModule.kt
@Module
@InstallIn(ViewModelComponent::class)
class DataSourceModule {
@Provides
fun provideDataSource(dataSourceImpl: DataSourceImpl): DataSource{
return dataSourceImpl
}
}
Repository.kt
val apopUser = mutableStateListOf<ApodItem>()
3. ViewModel 作成
MainViewModel.kt
@HiltViewModel
class MainViewModel @Inject constructor (
private val dataSource: DataSource
) : ViewModel() {
sealed class UiState {
object Initial : UiState()
object Loading : UiState()
data class Success(val user: MutableList<ApodItem>) : UiState()
object Failure : UiState()
}
val uiState: MutableState<UiState> = mutableStateOf(UiState.Initial)
val startSearchQuery: MutableState<String> = mutableStateOf("")
val endSearchQuery: MutableState<String> = mutableStateOf("")
fun onSearchTapped() {
val startSearchQuery: String = startSearchQuery.value
val endSearchQuery: String = endSearchQuery.value
viewModelScope.launch {
uiState.value = UiState.Loading
runCatching {
dataSource.getApodData(
startDate = startSearchQuery,
endDate = endSearchQuery,
apiKey = BuildConfig.API_KEY
)
}.onSuccess {
uiState.value = UiState.Success(user = apopUser)
}.onFailure {
uiState.value = UiState.Failure
}
}
}
}
4. Viewにあたる部分作成
MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainView(viewModel)
}
}
}
MainView.kt
@Composable
fun MainView(viewModel: MainViewModel) {
val uiState: MainViewModel.UiState by viewModel.uiState
Column(Modifier
.fillMaxSize()
.background(color = Color.Black)
) {
SearchView(
startSearchQuery = viewModel.startSearchQuery,
endSearchQuery = viewModel.endSearchQuery,
onSearchButtonTapped = viewModel::onSearchTapped,
)
Spacer(Modifier.size(10.dp))
when(uiState){
is MainViewModel.UiState.Initial ->{InitialView()}
is MainViewModel.UiState.Loading -> {LoadingView()}
is MainViewModel.UiState.Success ->{DetailView(apopUser)}
is MainViewModel.UiState.Failure -> {ErrorView()}
}
}
}
SearchView.kt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun SearchView(startSearchQuery: MutableState<String>, endSearchQuery: MutableState<String>, onSearchButtonTapped: () -> Unit){
val keyboardController = LocalSoftwareKeyboardController.current
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 8.dp, end = 10.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(value = startSearchQuery.value ,
onValueChange = { text -> startSearchQuery.value = text},
label = {Text(stringResource(id = R.string.first_day), color = Color.White)},
modifier = Modifier
.width(130.dp),
textStyle = TextStyle.Default.copy(fontSize = 16.sp),
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White,
focusedLabelColor = Color.White,
focusedBorderColor = Color.White
)
)
Spacer(modifier = Modifier.width(5.dp))
OutlinedTextField(value = endSearchQuery.value ,
onValueChange = { text -> endSearchQuery.value = text},
label = {Text(stringResource(id = R.string.day_to), color = Color.White)},
modifier = Modifier
.width(130.dp),
textStyle = TextStyle.Default.copy(fontSize = 16.sp),
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White,
focusedLabelColor = Color.White,
focusedBorderColor = Color.White,
)
)
Spacer(Modifier.size(10.dp))
Button(onClick = {onSearchButtonTapped()
keyboardController?.hide() },
contentPadding = PaddingValues(8.dp),
colors = ButtonDefaults.textButtonColors(
Color.Blue,
contentColor = Color.White,
disabledContentColor = Color.LightGray),
) {
Text(stringResource(id = R.string.search ))
}
}
}
DetailView.kt
画像だけだと思ってたら、YouTubeもたまに混ざっており画像と同じように表示して再生できないか検索等してみましたが断念、URL表示させてURLをタップしたらリンクする形としました。
@Composable
fun DetailView(data: SnapshotStateList<ApodItem>){
LazyColumn(modifier = Modifier
.fillMaxSize()
.padding(8.dp)
){
items(data){item -> DetailItem(item = item) }
}
}
fun createAnnotatedString(
image: String, color: androidx.compose.ui.graphics.Color
): AnnotatedString {
return buildAnnotatedString {
pushStringAnnotation(
tag = "URL",
annotation = image
)
withStyle(
style = SpanStyle(
color = color,
fontWeight = FontWeight.Bold
)
) {
append(image)
}
pop()
}
}
@Composable
fun DetailItem(item: ApodItem) {
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Black),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
Modifier
.fillMaxWidth()
.wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = item.date,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 0.dp, end = 0.dp, bottom = 0.dp),
fontSize = 20.sp,
color = Color.White
)
Text(text = item.title,
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 0.dp, end = 0.dp, bottom = 8.dp),
fontSize = 24.sp,
color = Color.White
)
val image = item.url
val urlStr = "apod.nasa.gov/apod"
when(Regex(urlStr).containsMatchIn(image)){
true ->{
Image(
painter = rememberAsyncImagePainter(image),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(width = 400.dp, height = 275.dp),
alignment = Alignment.Center
)
}
false -> {
val annotatedString = createAnnotatedString(image, Color.Magenta)
val uriHandler = AndroidUriHandler(LocalContext.current)
Column {
Text(text = stringResource(id = R.string.youtube),
color = Color.White,
modifier = Modifier.padding(5.dp),
style = TextStyle(fontSize = 18.sp)
)
ClickableText(text = annotatedString,
onClick = { offset ->
annotatedString.getStringAnnotations(
tag = "URL",
start = offset,
end = offset
)
uriHandler.openUri(image)
},
style = TextStyle(fontSize = 20.sp),
modifier = Modifier.padding(8.dp)
)
}
}
}
}
}
Text(text = item.explanation,
Modifier
.fillMaxWidth()
.padding(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
fontSize = 20.sp,
color = Color.White
)
}
Spacer(modifier = Modifier.height(20.dp))
}
InitialView.kt
@Composable
fun InitialView() {
Text(
text = "yyyy-mm-ddで日付を入力して検索してください",
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
fontSize = 16.sp,
color = Color.White
)
}
LoadingView.kt
@Composable
fun LoadingView() {
Text(
text = "読み込み中・・・",
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
fontSize = 16.sp,
color = Color.White
)
}
ErrorView.kt
@Composable
fun ErrorView() {
Text(
text = "読み込み失敗",
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
fontSize = 16.sp,
color = Color.White
)
}