Github RepoSearch
Using Compose And Flow
A sample Github RepoSearch app using Android Compose as it's UI, Kotlin StateFlow & SharedFlow as its data flow, Dagger Hilt as it's dependency injection, Room for offline cache and MVVM architect.
Key Features
The app cover the following features:
- Offline Cache
- Swipe to Refresh
- Error Handling
- Network check
Apk link
Download RepoSearch app at Google Drive .
Screenshot
Watch screen flow in RepoSearch app at youtube .
| repo search list | keyword suggestion list | repo detail |
|---|---|---|
![]() |
![]() |
![]() |
Architecture
SharedFlow usage in HomePage repo list suggestion
Call a ViewModel function, and emit to MutableSharedFlow.
After transformed to hot stream with ViewModelScope, collect safely it with collectAsStateLifecycleAware in Composable View.
@HiltViewModel class RepoListPageViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: RepoSearchBaseRepository, networkStatusDetector: NetworkStatusDetector, private val preferenceProvider: PreferenceProvider, private val application: Application ) : ViewModel() { private val tag: String = "RepoListPageViewModel" private val repoName: String = savedStateHandle.get<String>("repo_name").orEmpty() val searchText: MutableStateFlow<String> = MutableStateFlow(repoName) private var repoListNBRSharedFlow = MutableSharedFlow<Unit>() @Suppress("OPT_IN_IS_NOT_ENABLED") @OptIn(ExperimentalCoroutinesApi::class) var repoListNBR = repoListNBRSharedFlow .map { searchText.value } .flatMapLatest { repository.getRepoListNetworkBoundResource(it)} .stateIn(viewModelScope, SharingStarted.Eagerly, Resource.Start) @OptIn(FlowPreview::class) val networkState = networkStatusDetector.networkStatus .map ( onAvailable = { NetworkConnectionState.Fetched }, onUnavailable = { NetworkConnectionState.Error }, ) val isRefreshing: MutableStateFlow<Boolean> = MutableStateFlow(false) val showSearchTextEmptyToast: MutableStateFlow<Boolean> = MutableStateFlow(false) init { Log.e(tag, "init") Log.e(tag, "Argument: $repoName") Log.e(tag, "SearchText: ${searchText.value}") submit() } @OptIn(FlowPreview::class) fun submit() { Log.e(tag, "fetch RepoList") viewModelScope.launch { Log.e(tag, "in ViewModelScope") Log.e(tag, "preferenceKeyword: ${preferenceProvider.getSearchKeyword()}") if(searchText.value.isEmpty()){ showSearchTextEmptyToast.value = true }else { showSearchTextEmptyToast.value = false if (preferenceProvider.getSearchKeyword() == searchText.value) { Log.e(tag, "Not Need connection") repoListNBRSharedFlow.emit(Unit) } else { if (CurrentNetworkStatus.getNetwork(application.applicationContext)) { repoListNBRSharedFlow.emit(Unit) } else { Log.e(tag, "Need connection") } } } } } }
@OptIn(ExperimentalComposeUiApi::class) @Composable fun RepoListPage( navHostController: NavHostController, repoListPageViewModel: RepoListPageViewModel, ) { val searchText by repoListPageViewModel.searchText.collectAsStateLifecycleAware("") val repoListNBR by repoListPageViewModel.repoListNBR.collectAsStateLifecycleAware(Resource.Start) val networkState by repoListPageViewModel.networkState.collectAsStateLifecycleAware(NetworkConnectionState.Error) val isRefreshing by repoListPageViewModel.isRefreshing.collectAsStateLifecycleAware(false) val isShowSearchTextEmptyToast by repoListPageViewModel.showSearchTextEmptyToast.collectAsStateLifecycleAware(false) val isLoading: Boolean var errorMessage = "" var repoList: List<Repo> = listOf() val context = LocalContext.current val keyboardController = LocalSoftwareKeyboardController.current val needConnectionMessage = stringResource(id = R.string.need_connection_message) val keywordEmptyMessage = stringResource(id = R.string.keyword_empty) val isConnected: Boolean = when (networkState) { NetworkConnectionState.Fetched -> { Log.e(TAG, "Network Status: Fetched") true } else -> { Log.e(TAG, "Network Status: Error") false } } if(isShowSearchTextEmptyToast){ Toast.makeText( context, keywordEmptyMessage, Toast.LENGTH_SHORT ).show() repoListPageViewModel.showSearchTextEmptyToastCollected() } when (repoListNBR) { Resource.Loading -> { Log.e(TAG, "RepoSearch Fetch Loading") isLoading = repoListNBR.isLoading } Resource.Fail("") -> { Log.e(TAG, "RepoSearch Fetch Fail") isLoading = false errorMessage = repoListNBR.errorMessage.orEmpty() } else -> { Log.e(TAG, "RepoSearch Fetch Success") isLoading = false repoList = repoListNBR.data.orEmpty() repoListPageViewModel.onDoneCollectResource() } } }
SharedFlow usage in KeywordSearchPage keyword list
Call a ViewModel function, and emit to MutableSharedFlow.
After transformed to hot stream with ViewModelScope, collect safely it with collectAsStateLifecycleAware in Composable View.
@HiltViewModel class KeywordSearchPageViewModel @Inject constructor( private val appRepository: AppRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val tag: String = "KeywordPageViewModel" private val repoName: String = savedStateHandle.get<String>("repo").orEmpty() val searchText: MutableStateFlow<String> = MutableStateFlow(repoName) private val keywordListShareFlow = MutableSharedFlow<Unit>() @Suppress("OPT_IN_IS_NOT_ENABLED") @OptIn(ExperimentalCoroutinesApi::class) var keywordListNBR = keywordListShareFlow .map { searchText.value } .flatMapLatest { appRepository.getKeywordListNetworkBoundResource(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, Resource.Loading) init { Log.e(tag, "init") Log.e(tag, "Argument: $repoName") Log.e(tag, "SearchText: ${searchText.value}") submit() } }
@Composable fun KeywordSearchPage(navHostController: NavHostController, keywordSearchPageViewModel: KeywordSearchPageViewModel) { val searchText by keywordSearchPageViewModel.searchText.collectAsStateLifecycleAware(initial = "") val keywordListNBR by keywordSearchPageViewModel.keywordListNBR.collectAsStateLifecycleAware() var isLoading = false var errorMessage = "" var keywordList: List<Keyword> = listOf() when (keywordListNBR) { Resource.Loading -> { Log.e(TAG, "keywordListNBR Loading") isLoading = keywordListNBR.isLoading } Resource.Fail("") -> { Log.e(TAG, "keywordListNBR Fail") errorMessage = keywordListNBR.errorMessage.orEmpty() } else -> { Log.e(TAG, "keywordListNBR Success") keywordList = keywordListNBR.data.orEmpty() when (keywordListNBR.data.isNullOrEmpty()) { true -> Log.e(TAG, "keyword list : NullOrEmpty") else -> { Log.e(TAG, "first keyword : ${keywordList.first().name}") } } } } }
Using Room and Network Bound Resource for offline cache
Use Room database for offline storage and cache Room
RepoSearchDatabase
@Database(entities = [Repo::class, Owner::class, Keyword::class], version = 1, exportSchema = false) abstract class RepoSearchDatabase() : RoomDatabase() { abstract fun repoDao(): RepoDao abstract fun ownerDao(): OwnerDao abstract fun repoDetailDao() : RepoDetailDao abstract fun keywordDao(): KeywordDao }
RepoDetail Table Dao
@Dao abstract class RepoDetailDao: RepoDao, OwnerDao { fun insertToRepoDetail(repos: List<Repo>) { // delete previous data deleteAllRepos() deleteAllOwners() // save new data for (r in repos) { r.owner.repoId = r.id upsertOwner(r.owner) } insertReposToRepoDetail(repos) } fun getRepoDetail() : Flow<List<Repo>> { val repoDetail = _getAllFromRepoDetail() val repos: MutableList<Repo> = mutableListOf() for (i in repoDetail) { i.repo.owner = i.owner repos.add(i.repo) } Log.e("RepoDetailDao", "getRepos from RepoDetail: size ${repos.size}") return flow { emit(repos) } } fun getRepoDetailById(repoId: Long) : Flow<Repo> { val repoWithOwner = _getRepoDetailById(repoId) val repo = repoWithOwner.repo repo.owner = repoWithOwner.owner return flow { emit(repo) } } // insert or update if exists @Insert(onConflict = OnConflictStrategy.REPLACE) abstract fun upsertRepos(repos: List<Repo>) @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insertReposToRepoDetail(repos: List<Repo>) @Transaction @Query("SELECT * FROM Repo, Owner WHERE Repo.id = Owner.repoId ORDER BY Repo.stargazersCount DESC") abstract fun _getAllFromRepoDetail() : List<RepoDetail> @Transaction @Query("SELECT * FROM Repo INNER JOIN Owner ON Owner.repoId = Repo.id WHERE Repo.id = :repoId") abstract fun _getRepoDetailById(repoId: Long) : RepoDetail }
Repo Table Dao
@Dao interface RepoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(repos: List<Repo>) @Query("DELETE FROM Repo") suspend fun deleteAll() @Query("SELECT * FROM Repo WHERE name IN (:repoNames)") fun getRepos(repoNames: String): Flow<List<Repo>> @Query("SELECT * FROM Repo WHERE name LIKE '%' || (:repoName) || '%'") fun getFilteredRepos(repoName: String?): Flow<List<Repo>> @Query("DELETE FROM Repo") abstract fun deleteAllRepos() @Insert(onConflict = OnConflictStrategy.REPLACE) abstract fun upsertRepo(vararg repo: Repo) }
Owner Table Dao
@Dao interface OwnerDao { @Insert(onConflict = OnConflictStrategy.REPLACE) abstract fun upsertOwner(owner: Owner) @Query("DELETE FROM Owner") abstract fun deleteAllOwners() }
RepoSearch Repository with Network Bound Resource for offline cache
###Network Bound Resource Network Bound Resource.
class RepoSearchRepository @Inject constructor( private val apiDataSource: RestDataSource, private val dbDataSource: RepoSearchDatabase, private val prefs: PreferenceProvider, private val appContext: Context ): RepoSearchBaseRepository { private val repoDetailDao = dbDataSource.repoDetailDao() override fun getRepoListNetworkBoundResource(s: String): Flow<Resource<List<Repo>>> { return repoSearchNetworkBoundResource( // make request fetchRemote = { Log.e("Repository", "fetchRemote()") apiDataSource.searchRepos(s, 50) }, // extract data getDataFromResponse = { Log.e("Repository", "getDataFromResponse()") it.body()!!.items }, // save data saveFetchResult = { repos -> Log.e("Repository", "saveFetchResult()") prefs.setSearchKeyword(s) repoDetailDao.insertToRepoDetail(repos) }, // return saved data fetchLocal = { Log.e("Repository", "fetchLocal()") repoDetailDao.getRepoDetail() }, // should fetch data from remote api or local db shouldFetch = { Log.e("Repository", "shouldFetch()") CurrentNetworkStatus.getNetwork(appContext) } ).flowOn(Dispatchers.IO) } }
Libraries
Reference Articles
- stateflow-vs-sharedflow_1
- stateflow-vs-sharedflow_2
- stateflow-transformations
- lifecycle aware viewmodel 1
- lifecycle aware viewmodel 2
- lifecycle aware viewmodel 3
- network_bound_resource
Serve me a coffee and my ethereum wallet is
- 0x1e68b09f0A3158a73041a871FeC5037586128873



