Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/java/com/maazm7d/termuxhub/data/local/ToolDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ interface ToolDao {
@Query("SELECT * FROM tools ORDER BY name COLLATE NOCASE ASC")
fun getAllToolsFlow(): Flow<List<ToolEntity>>

@Query("SELECT * FROM tools")
suspend fun getAllTools(): List<ToolEntity>

@Query("SELECT * FROM tools ORDER BY stars DESC, name COLLATE NOCASE ASC")
fun getToolsByStarsFlow(): Flow<List<ToolEntity>>

Expand All @@ -28,6 +31,9 @@ interface ToolDao {
@Update
suspend fun update(tool: ToolEntity)

@Update
suspend fun updateAll(tools: List<ToolEntity>)

@Query("DELETE FROM tools")
suspend fun clearAll()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import com.maazm7d.termuxhub.domain.model.ToolDetails

fun ToolDto.toEntity(
existing: ToolEntity? = null,
repoStats: Map<String, RepoStatsDto>
repoStats: Map<String, RepoStatsDto> = emptyMap()
): ToolEntity? {
if (id.isBlank() || name.isBlank()) return null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.maazm7d.termuxhub.data.source.local.LocalDataSource
import com.maazm7d.termuxhub.data.source.remote.RemoteDataSource
import com.maazm7d.termuxhub.domain.model.ToolDetails
import com.maazm7d.termuxhub.domain.repository.ToolRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
import javax.inject.Inject
Expand All @@ -28,73 +29,93 @@ class ToolRepositoryImpl @Inject constructor(
localDataSource.getToolById(id)

override suspend fun setFavorite(toolId: String, isFav: Boolean) {
val current = localDataSource.getToolById(toolId) ?: return
localDataSource.updateTool(current.copy(isFavorite = isFav))
try {
val current = localDataSource.getToolById(toolId) ?: return
localDataSource.updateTool(current.copy(isFavorite = isFav))
} catch (e: Exception) {
Timber.e(e, "Error setting favorite for tool: $toolId")
}
}

override suspend fun refreshFromRemote(): Boolean {
return try {
val response = remoteDataSource.fetchMetadata()
if (response.isSuccessful && response.body() != null) {
val repoStats = fetchRepoStats()
val metadata = response.body()!!
val entities = metadata.tools.mapNotNull { dto ->
val existing = localDataSource.getToolById(dto.id)
dto.toEntity(existing, repoStats)
}
if (entities.isNotEmpty()) {
localDataSource.insertTools(entities)
kotlinx.coroutines.coroutineScope {
val metadataDeferred = async { remoteDataSource.fetchMetadata() }
val repoStatsDeferred = async { fetchRepoStats() }
val starsDeferred = async { fetchStars() }

val response = metadataDeferred.await()
val repoStats = repoStatsDeferred.await()
val starsMap = starsDeferred.await()

if (response.isSuccessful && response.body() != null) {
val metadata = response.body()!!
val existingTools = localDataSource.getAllTools().associateBy { it.id }
val entities = metadata.tools.mapNotNull { dto ->
val existing = existingTools[dto.id]
dto.toEntity(existing, repoStats)?.copy(
stars = starsMap[dto.id] ?: existing?.stars ?: 0
)
}
if (entities.isNotEmpty()) {
localDataSource.insertTools(entities)
}
true
} else {
loadFromAssets()
}
applyStars()
true
} else {
loadFromAssets()
}
} catch (e: Exception) {
Timber.e(e, "Error refreshing tools from remote")
loadFromAssets()
}
}

override suspend fun fetchStars(): Map<String, Int> {
return try {
val resp = remoteDataSource.fetchStars()
if (resp.isSuccessful) resp.body()?.stars ?: emptyMap()
else emptyMap()
} catch (e: Exception) {
Timber.e(e, "Error fetching stars")
emptyMap()
}
override suspend fun fetchStars(): Map<String, Int> = runCatching {
val resp = remoteDataSource.fetchStars()
if (resp.isSuccessful) resp.body()?.stars ?: emptyMap()
else emptyMap()
}.getOrElse { e ->
Timber.e(e, "Error fetching stars")
emptyMap()
}

private suspend fun fetchRepoStats(): Map<String, RepoStatsDto> {
return try {
val resp = remoteDataSource.fetchRepoStats()
if (resp.isSuccessful) resp.body()?.stats ?: emptyMap()
else emptyMap()
} catch (e: Exception) {
Timber.e(e, "Error fetching repo stats")
emptyMap()
}
private suspend fun fetchRepoStats(): Map<String, RepoStatsDto> = runCatching {
val resp = remoteDataSource.fetchRepoStats()
if (resp.isSuccessful) resp.body()?.stats ?: emptyMap()
else emptyMap()
}.getOrElse { e ->
Timber.e(e, "Error fetching repo stats")
emptyMap()
}

private suspend fun applyStars() {
val starsMap = fetchStars()
starsMap.forEach { (toolId, starCount) ->
val tool = localDataSource.getToolById(toolId)
if (tool != null && tool.stars != starCount) {
localDataSource.updateTool(tool.copy(stars = starCount))
if (starsMap.isEmpty()) return

val allTools = localDataSource.getAllTools()
val toolsToUpdate = allTools.mapNotNull { tool ->
val remoteStars = starsMap[tool.id]
if (remoteStars != null && tool.stars != remoteStars) {
tool.copy(stars = remoteStars)
} else {
null
}
}

if (toolsToUpdate.isNotEmpty()) {
localDataSource.updateTools(toolsToUpdate)
}
}

private suspend fun loadFromAssets(): Boolean {
return try {
val repoStats = fetchRepoStats()
val dto = localDataSource.loadMetadataFromAssets(assetsFileName)
val existingTools = localDataSource.getAllTools().associateBy { it.id }

val entities = dto?.tools?.mapNotNull { t ->
val existing = localDataSource.getToolById(t.id)
val existing = existingTools[t.id]
t.toEntity(existing, repoStats)
} ?: emptyList()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import javax.inject.Inject

interface LocalDataSource {
fun getAllToolsFlow(): Flow<List<ToolEntity>>
suspend fun getAllTools(): List<ToolEntity>
fun getFavoritesFlow(): Flow<List<ToolEntity>>
suspend fun getToolById(id: String): ToolEntity?
suspend fun insertTools(tools: List<ToolEntity>)
suspend fun updateTool(tool: ToolEntity)
suspend fun updateTools(tools: List<ToolEntity>)
suspend fun loadMetadataFromAssets(fileName: String): MetadataDto?

fun getAllHallOfFameFlow(): Flow<List<HallOfFameEntity>>
Expand All @@ -34,10 +36,12 @@ class LocalDataSourceImpl @Inject constructor(
private val moshi: Moshi
) : LocalDataSource {
override fun getAllToolsFlow(): Flow<List<ToolEntity>> = toolDao.getAllToolsFlow()
override suspend fun getAllTools(): List<ToolEntity> = toolDao.getAllTools()
override fun getFavoritesFlow(): Flow<List<ToolEntity>> = toolDao.getFavoritesFlow()
override suspend fun getToolById(id: String): ToolEntity? = toolDao.getToolById(id)
override suspend fun insertTools(tools: List<ToolEntity>) = toolDao.insertAll(tools)
override suspend fun updateTool(tool: ToolEntity) = toolDao.update(tool)
override suspend fun updateTools(tools: List<ToolEntity>) = toolDao.updateAll(tools)

override suspend fun loadMetadataFromAssets(fileName: String): MetadataDto? = withContext(Dispatchers.IO) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
queryState: MutableState<String>,
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Search tools..."
) {
TextField(
value = queryState.value,
onValueChange = { queryState.value = it },
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth(),
placeholder = {
Expand All @@ -39,8 +40,8 @@ fun SearchBar(
)
},
trailingIcon = {
if (queryState.value.isNotEmpty()) {
IconButton(onClick = { queryState.value = "" }) {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = "Clear",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,6 @@ import com.maazm7d.termuxhub.ui.components.SearchBar
import com.maazm7d.termuxhub.ui.components.ToolCard
import com.maazm7d.termuxhub.utils.UiState

enum class SortType(val label: String) {
NEWEST_FIRST("Newest first"),
OLDEST_FIRST("Oldest first"),
MOST_STARRED("Most starred"),
LEAST_STARRED("Least starred")
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
Expand All @@ -52,7 +45,10 @@ fun HomeScreen(
state = state.data,
onRefresh = { viewModel.refresh() },
onToggleFavorite = { viewModel.toggleFavorite(it) },
onOpenDetails = onOpenDetails
onOpenDetails = onOpenDetails,
onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
onCategorySelected = { viewModel.onCategorySelected(it) },
onSortTypeSelected = { viewModel.onSortTypeSelected(it) }
)
}
}
Expand All @@ -64,40 +60,19 @@ private fun HomeContent(
state: HomeUiState,
onRefresh: () -> Unit,
onToggleFavorite: (String) -> Unit,
onOpenDetails: (String) -> Unit
onOpenDetails: (String) -> Unit,
onSearchQueryChanged: (String) -> Unit,
onCategorySelected: (Int) -> Unit,
onSortTypeSelected: (SortType) -> Unit
) {
val searchQuery = rememberSaveable { mutableStateOf("") }
var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) }
var currentSort by rememberSaveable { mutableStateOf(SortType.NEWEST_FIRST) }

var sortMenuExpanded by remember { mutableStateOf(false) }
var categoryMenuExpanded by remember { mutableStateOf(false) }

val listState = rememberLazyListState()

val categoryCounts = remember(state.tools) { state.tools.groupingBy { it.category }.eachCount() }
val categories = remember(state.tools, categoryCounts) {
listOf("All" to state.tools.size) + categoryCounts.keys.sorted().map { it to (categoryCounts[it] ?: 0) }
}

val filteredTools = remember(state.tools, searchQuery.value, selectedCategoryIndex, currentSort, state.starsMap) {
state.tools
.filter { tool ->
val matchesQuery = searchQuery.value.isBlank() ||
tool.name.contains(searchQuery.value, true) ||
tool.description.contains(searchQuery.value, true)
val matchesCategory = selectedCategoryIndex == 0 ||
tool.category.equals(categories[selectedCategoryIndex].first, true)
matchesQuery && matchesCategory
}
.let { list ->
when (currentSort) {
SortType.NEWEST_FIRST -> list.sortedByDescending { it.getPublishedDate() }
SortType.OLDEST_FIRST -> list.sortedBy { it.getPublishedDate() }
SortType.MOST_STARRED -> list.sortedByDescending { state.starsMap[it.id] ?: 0 }
SortType.LEAST_STARRED -> list.sortedBy { state.starsMap[it.id] ?: 0 }
}
}
// Scroll to top when filters change
LaunchedEffect(state.searchQuery, state.selectedCategoryIndex, state.currentSort) {
listState.animateScrollToItem(0)
}

PullToRefreshBox(
Expand All @@ -116,7 +91,11 @@ private fun HomeContent(
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SearchBar(queryState = searchQuery, modifier = Modifier.weight(1f))
SearchBar(
query = state.searchQuery,
onQueryChange = onSearchQueryChanged,
modifier = Modifier.weight(1f)
)
Box {
IconButton(onClick = { sortMenuExpanded = true }) {
Icon(Icons.Default.FilterList, contentDescription = "Sort")
Expand All @@ -125,14 +104,14 @@ private fun HomeContent(
expanded = sortMenuExpanded,
onDismissRequest = { sortMenuExpanded = false }
) {
SortType.values().forEach { sort ->
SortType.entries.forEach { sort ->
DropdownMenuItem(
text = { Text(sort.label) },
leadingIcon = {
if (currentSort == sort) Icon(Icons.Default.Check, null)
if (state.currentSort == sort) Icon(Icons.Default.Check, null)
},
onClick = {
currentSort = sort
onSortTypeSelected(sort)
sortMenuExpanded = false
}
)
Expand All @@ -156,14 +135,14 @@ private fun HomeContent(
expanded = categoryMenuExpanded,
onDismissRequest = { categoryMenuExpanded = false }
) {
categories.forEachIndexed { index, item ->
state.categories.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text("${item.first} (${item.second})") },
leadingIcon = {
if (selectedCategoryIndex == index) Icon(Icons.Default.Check, null)
if (state.selectedCategoryIndex == index) Icon(Icons.Default.Check, null)
},
onClick = {
selectedCategoryIndex = index
onCategorySelected(index)
categoryMenuExpanded = false
}
)
Expand All @@ -172,9 +151,9 @@ private fun HomeContent(
}

CategoryChips(
chips = categories,
selectedIndex = selectedCategoryIndex,
onChipSelected = { selectedCategoryIndex = it }
chips = state.categories,
selectedIndex = state.selectedCategoryIndex,
onChipSelected = onCategorySelected
)
}

Expand All @@ -185,10 +164,10 @@ private fun HomeContent(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 12.dp)
) {
items(filteredTools, key = { it.id }) { tool ->
items(state.tools, key = { it.id }) { tool ->
ToolCard(
tool = tool,
stars = state.starsMap[tool.id],
stars = tool.stars,
onOpenDetails = onOpenDetails,
onToggleFavorite = { onToggleFavorite(it) },
onSave = { onToggleFavorite(it) }
Expand Down
Loading