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 @@
-
+
+
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_normal.xml b/app/src/main/res/layout/activity_normal.xml
new file mode 100644
index 0000000..52fa3a5
--- /dev/null
+++ b/app/src/main/res/layout/activity_normal.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_item_repository.xml b/app/src/main/res/layout/list_item_repository.xml
new file mode 100644
index 0000000..a8cd315
--- /dev/null
+++ b/app/src/main/res/layout/list_item_repository.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file