Skip to content

A sample repo search 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.

License

Notifications You must be signed in to change notification settings

kyawsanoo/RepoSearchComposeWithFlow

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

Serve me a coffee and my ethereum wallet is

  • 0x1e68b09f0A3158a73041a871FeC5037586128873

About

A sample repo search 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.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages