diff --git a/app/schemas/us.mitene.practicalexam.data.AppDatabase/1.json b/app/schemas/us.mitene.practicalexam.data.AppDatabase/1.json new file mode 100644 index 0000000..2be6bc7 --- /dev/null +++ b/app/schemas/us.mitene.practicalexam.data.AppDatabase/1.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "6e1226bef5a0160d9fa88e8d46607005", + "entities": [ + { + "tableName": "GithubRepoResponse", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6e1226bef5a0160d9fa88e8d46607005')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0a107b8..efcd3f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,22 +1,26 @@ + + + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.PracticalExam"> + android:name=".MainActivity" + android:exported="true"> + + \ No newline at end of file diff --git a/app/src/main/java/us/mitene/practicalexam/MainActivity.kt b/app/src/main/java/us/mitene/practicalexam/MainActivity.kt index 903cdae..3ec1a33 100644 --- a/app/src/main/java/us/mitene/practicalexam/MainActivity.kt +++ b/app/src/main/java/us/mitene/practicalexam/MainActivity.kt @@ -1,11 +1,23 @@ package us.mitene.practicalexam -import androidx.appcompat.app.AppCompatActivity +import android.content.Intent import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import us.mitene.practicalexam.databinding.ActivityMainBinding +import us.mitene.practicalexam.ui.ComposeActivity +import us.mitene.practicalexam.ui.NormalActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + val binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.normal.setOnClickListener { + startActivity(Intent(this, NormalActivity::class.java)) + } + binding.compose.setOnClickListener { + startActivity(Intent(this, ComposeActivity::class.java)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/us/mitene/practicalexam/data/GithubRepoEntity.kt b/app/src/main/java/us/mitene/practicalexam/data/GithubRepoEntity.kt new file mode 100644 index 0000000..efd3cfc --- /dev/null +++ b/app/src/main/java/us/mitene/practicalexam/data/GithubRepoEntity.kt @@ -0,0 +1,30 @@ +package us.mitene.practicalexam.data + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable + +// response +@Serializable +@Entity +data class GithubRepoResponse( + @PrimaryKey + val id: Int, + val name: String, + val url: String, +) + +// entity +data class GithubRepoEntity( + val name: String, + val url: String, +) + +// mapper +object GithubRepoEntityMapper { + fun toEntity(response: GithubRepoResponse) = GithubRepoEntity( + name = response.name, + url = response.url, + ) +} + diff --git a/app/src/main/java/us/mitene/practicalexam/data/GithubRepository.kt b/app/src/main/java/us/mitene/practicalexam/data/GithubRepository.kt new file mode 100644 index 0000000..bacb893 --- /dev/null +++ b/app/src/main/java/us/mitene/practicalexam/data/GithubRepository.kt @@ -0,0 +1,155 @@ +package us.mitene.practicalexam.data + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import com.google.gson.GsonBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path +import timber.log.Timber +import java.util.concurrent.TimeUnit + +class GithubRepository( + context: Context, + private val remote: GithubService = Service.githubService, + private val local: GithubDao = DatabaseProvider.githubDao(context), +) { + + suspend fun getOrganizationRepositories(organization: String): List { + return withContext(Dispatchers.IO) { + try { + local.getAll().ifEmpty { + // remote + val remoteData = remote.organization(organization) + // save entity + local.upsert(*remoteData.toTypedArray()) + remoteData + }.map { + GithubRepoEntityMapper.toEntity(it) + } + } catch (e: Exception) { + Timber.w(e) + emptyList() + } + } + } +} + +/** + * remote + */ +// api +interface GithubService { + @GET("/orgs/{org}/repos") + suspend fun organization(@Path("org") org: String): List +} + +// client (retrofit, okhttp) +object Service { + private val client: OkHttpClient = createOkhttpClient() + val githubService: GithubService = createGithubService() + + private fun createOkhttpClient(): OkHttpClient { + // create http client + val httpClient = OkHttpClient.Builder() + .addInterceptor(Interceptor { chain -> + val original = chain.request() + + //header + // ref : https://docs.github.com/ja/free-pro-team@latest/rest/repos/repos?apiVersion=2022-11-28#list-organization-repositories + val request = original.newBuilder() + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .method(original.method, original.body) + .build() + + return@Interceptor chain.proceed(request) + }) + .readTimeout(30, TimeUnit.SECONDS) + + // log interceptor + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + httpClient.addInterceptor(loggingInterceptor) + + return httpClient.build() + } + + private fun createGithubService(useGson: Boolean = false): GithubService { + val converter = if (useGson) { + val gson = GsonBuilder().serializeNulls().create() + GsonConverterFactory.create(gson) + } else { + val formatter = Json { ignoreUnknownKeys = true } + formatter.asConverterFactory("application/json".toMediaType()) + } + + // create retrofit + val builder = Retrofit.Builder() + .baseUrl("https://api.github.com") + .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .addConverterFactory(converter) + .client(client) + + return builder.build().create(GithubService::class.java) + } +} + +/** + * local + */ +@Dao +interface GithubDao { + @Query("SELECT * FROM GithubRepoResponse") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(vararg repository: GithubRepoResponse) + + @Delete + suspend fun delete(vararg repository: GithubRepoResponse) +} + +@Database( + entities = [ + GithubRepoResponse::class + ], + version = 1, + exportSchema = true +) +abstract class AppDatabase : RoomDatabase() { + abstract fun githubDao(): GithubDao +} + +object DatabaseProvider { + private var db: AppDatabase? = null + + private fun db(context: Context): AppDatabase { + if (db != null) return db!! + db = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "app-database" + ).build() + return db!! + } + + fun githubDao(context: Context) = db(context).githubDao() +} diff --git a/app/src/main/java/us/mitene/practicalexam/ui/ComposeActivity.kt b/app/src/main/java/us/mitene/practicalexam/ui/ComposeActivity.kt new file mode 100644 index 0000000..704e732 --- /dev/null +++ b/app/src/main/java/us/mitene/practicalexam/ui/ComposeActivity.kt @@ -0,0 +1,63 @@ +package us.mitene.practicalexam.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import us.mitene.practicalexam.data.GithubRepoEntity +import us.mitene.practicalexam.data.GithubRepository +import us.mitene.practicalexam.ui.theme.PracticalExamTheme + +class ComposeActivity : ComponentActivity() { + private val repository by lazy { GithubRepository(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + PracticalExamTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + GithubRepoListScreen(repository = repository) + } + } + } + } +} + +@Suppress("UNCHECKED_CAST") +class ComposeViewModelFactory( + private val repository: GithubRepository +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return ComposeViewModel(repository) as T + } +} + +class ComposeViewModel( + private val repository: GithubRepository +) : ViewModel() { + var uiState by mutableStateOf(ComposeUiState()) + + fun fetchRepos(organization: String = "mixi-inc") { + viewModelScope.launch { + uiState = uiState.copy(repos = repository.getOrganizationRepositories(organization)) + } + } +} + +data class ComposeUiState( + val repos: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/us/mitene/practicalexam/ui/GithubRepoScreen.kt b/app/src/main/java/us/mitene/practicalexam/ui/GithubRepoScreen.kt new file mode 100644 index 0000000..0e6a281 --- /dev/null +++ b/app/src/main/java/us/mitene/practicalexam/ui/GithubRepoScreen.kt @@ -0,0 +1,83 @@ +package us.mitene.practicalexam.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import us.mitene.practicalexam.data.GithubRepoEntity +import us.mitene.practicalexam.data.GithubRepository +import us.mitene.practicalexam.ui.theme.PracticalExamTheme + +@Composable +fun GithubRepoListScreen( + modifier: Modifier = Modifier, + repository: GithubRepository, + viewModel: ComposeViewModel = viewModel(factory = ComposeViewModelFactory(repository)) +) { + val uiState = viewModel.uiState + + GithubRepoListScreen(modifier, uiState.repos) + LaunchedEffect(Unit) { + viewModel.fetchRepos() + } +} + +@Composable +fun GithubRepoListScreen( + modifier: Modifier = Modifier, + repos: List +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(vertical = 4.dp) + ) { + items(repos) { item -> + GithubRepoItem(item.name, item.url) + } + } +} + +@Composable +fun GithubRepoItem( + text: String, + url: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + Text(text = text, Modifier.padding(4.dp)) + Text(text = url, Modifier.padding(4.dp)) + } +} + +@Preview +@Composable +fun GithubRepoListScreenPreview() { + val repos = listOf( + GithubRepoEntity("name", "url"), + GithubRepoEntity("name", "url"), + GithubRepoEntity("name", "url"), + GithubRepoEntity("name", "url"), + ) + PracticalExamTheme { + GithubRepoListScreen(repos = repos) + } +} + +@Preview +@Composable +fun RepositoryItemPreview() { + PracticalExamTheme { + GithubRepoItem("repo", "url") + } +} diff --git a/app/src/main/java/us/mitene/practicalexam/ui/NormalActivity.kt b/app/src/main/java/us/mitene/practicalexam/ui/NormalActivity.kt new file mode 100644 index 0000000..f405a9b --- /dev/null +++ b/app/src/main/java/us/mitene/practicalexam/ui/NormalActivity.kt @@ -0,0 +1,76 @@ +package us.mitene.practicalexam.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import timber.log.Timber +import us.mitene.practicalexam.R +import us.mitene.practicalexam.data.GithubRepoEntity +import us.mitene.practicalexam.data.GithubRepository +import us.mitene.practicalexam.databinding.ActivityNormalBinding +import us.mitene.practicalexam.databinding.ListItemRepositoryBinding + +class NormalActivity : AppCompatActivity() { + lateinit var binding: ActivityNormalBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val adapter = GithubRepoAdapter() + + binding = DataBindingUtil.setContentView(this, R.layout.activity_normal) + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + // fetch data + val context = this@NormalActivity + val entities = GithubRepository(context).getOrganizationRepositories("mixi-inc") + adapter.update(entities) + } + } + } +} + +// adapter +class GithubRepoAdapter : RecyclerView.Adapter() { + private var repositories = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ListItemRepositoryBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(repositories[position]) + } + + override fun getItemCount(): Int = repositories.size + + fun update(data: List) { + repositories = data + notifyItemRangeChanged(0, repositories.size) + } + + class ViewHolder( + private val binding: ListItemRepositoryBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: GithubRepoEntity) { + binding.name.text = item.name + binding.url.text = item.url + binding.root.setOnClickListener { + Timber.d("TESTTEST ${item.name}") + } + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c08b7a3..02dbe6c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,18 +1,23 @@ - + +