Skip to content

Commit 895a2f6

Browse files
authored
add subfolders count to file manager (#991)
**Background** Current file manager doesn't display subitems count inside folders **Changes** - Add subitems count - Add `ExtendedListingItem` for better scalability - Add `visible` parameter for PlaceholderConnecting so there will be animation when it's loaded **Test plan** - Open sample app - Open folders and see items are loading and each after each without blocking the listing itself
1 parent bcebc1c commit 895a2f6

File tree

14 files changed

+223
-29
lines changed

14 files changed

+223
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- [FIX] Fix empty response in faphub category
66
- [FIX] New file manager uploading progress
77
- [FIX] Fix build when no metrics enabled
8+
- [Feature] Add count subfolders for new file manager
89

910
# 1.8.0
1011
Attention: don't forget to add the flag for F-Droid before release

components/core/ui/ktx/src/commonMain/kotlin/com/flipperdevices/core/ui/ktx/PlaceholderKtx.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import io.github.fornewid.placeholder.foundation.placeholder
1414
import io.github.fornewid.placeholder.foundation.shimmer
1515

1616
@Suppress("ModifierComposed") // MOB-1039
17-
fun Modifier.placeholderConnecting(shape: Int = 4) = composed {
17+
fun Modifier.placeholderConnecting(
18+
shape: Int = 4,
19+
visible: Boolean = true
20+
) = composed {
1821
this.then(
1922
placeholder(
20-
visible = true,
23+
visible = visible,
2124
shape = RoundedCornerShape(shape.dp),
2225
color = LocalPallet.current.placeholder.copy(alpha = 0.2f),
2326
highlight = PlaceholderHighlight.shimmer(

components/filemngr/listing/impl/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
<string name="fml_appbar_title">File Manager</string>
2525

26+
<string name="fml_items_in_folder">%1$s items</string>
2627
<string name="fml_no_files">No Files Yet</string>
2728
<string name="fml_upload_files">Upload Files</string>
2829
<string name="fml_selection_select_all">Select All</string>

components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LoadedFilesComposable.kt

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
1010
import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType
1111
import com.flipperdevices.core.ktx.jre.toFormattedSize
1212
import com.flipperdevices.core.preference.pb.FileManagerOrientation
13+
import com.flipperdevices.filemanager.listing.impl.model.ExtendedListingItem
1314
import com.flipperdevices.filemanager.listing.impl.model.PathWithType
1415
import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel
1516
import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel
@@ -19,9 +20,12 @@ import com.flipperdevices.filemanager.ui.components.itemcard.FolderCardPlacehold
1920
import com.flipperdevices.filemanager.ui.components.itemcard.components.asPainter
2021
import com.flipperdevices.filemanager.ui.components.itemcard.components.asTint
2122
import com.flipperdevices.filemanager.ui.components.itemcard.model.ItemUiSelectionState
23+
import flipperapp.components.filemngr.listing.impl.generated.resources.fml_items_in_folder
2224
import okio.Path
25+
import org.jetbrains.compose.resources.stringResource
26+
import flipperapp.components.filemngr.listing.impl.generated.resources.Res as FML
2327

24-
@Suppress("FunctionNaming", "LongParameterList")
28+
@Suppress("FunctionNaming", "LongParameterList", "LongMethod")
2529
fun LazyGridScope.LoadedFilesComposable(
2630
path: Path,
2731
deleteFileState: DeleteFilesViewModel.State,
@@ -37,7 +41,7 @@ fun LazyGridScope.LoadedFilesComposable(
3741
) {
3842
items(filesState.files) { file ->
3943
val isFileLoading = remember(deleteFileState.fileNamesOrNull) {
40-
deleteFileState.fileNamesOrNull.orEmpty().contains(file.fileName)
44+
deleteFileState.fileNamesOrNull.orEmpty().contains(file.itemName)
4145
}
4246
Crossfade(isFileLoading) { animatedIsFileLoading ->
4347
if (animatedIsFileLoading) {
@@ -49,41 +53,49 @@ fun LazyGridScope.LoadedFilesComposable(
4953
orientation = orientation,
5054
)
5155
} else {
52-
val filePathWithType = remember(path, file.fileName) {
53-
val fullPath = path.resolve(file.fileName)
54-
PathWithType(file.fileType ?: FileType.FILE, fullPath)
56+
val filePathWithType = remember(path, file.itemName) {
57+
val fullPath = path.resolve(file.itemName)
58+
PathWithType(file.itemType, fullPath)
5559
}
5660
FolderCardComposable(
5761
modifier = Modifier
5862
.fillMaxWidth()
5963
.animateItem()
6064
.animateContentSize(),
61-
painter = file.asPainter(),
62-
iconTint = file.asTint(),
63-
title = file.fileName,
65+
painter = file.asListingItem().asPainter(),
66+
iconTint = file.asListingItem().asTint(),
67+
title = file.itemName,
6468
canDeleteFiles = canDeleteFiles,
65-
subtitle = file.size.toFormattedSize(),
69+
subtitle = when (file) {
70+
is ExtendedListingItem.File -> file.size.toFormattedSize()
71+
is ExtendedListingItem.Folder -> stringResource(
72+
resource = FML.string.fml_items_in_folder,
73+
file.itemsCount ?: 0
74+
)
75+
},
76+
isSubtitleLoading = when (file) {
77+
is ExtendedListingItem.File -> false
78+
is ExtendedListingItem.Folder -> file.itemsCount == null
79+
},
6680
selectionState = when {
6781
selectionState.selected.contains(filePathWithType) -> ItemUiSelectionState.SELECTED
6882
selectionState.isEnabled -> ItemUiSelectionState.UNSELECTED
6983
else -> ItemUiSelectionState.NONE
7084
},
7185
onClick = {
72-
when (file.fileType) {
86+
when (file.itemType) {
7387
FileType.DIR -> {
7488
onPathChanged.invoke(filePathWithType.fullPath)
7589
}
7690

7791
FileType.FILE -> {
7892
onEditFileClick(filePathWithType.fullPath)
7993
}
80-
81-
null -> Unit
8294
}
8395
},
8496
onCheckChange = { onCheckToggle.invoke(filePathWithType) },
8597
onMoreClick = { onFileMoreClick.invoke(filePathWithType) },
86-
onDelete = { onDelete.invoke(path.resolve(file.fileName)) },
98+
onDelete = { onDelete.invoke(path.resolve(file.itemName)) },
8799
orientation = orientation
88100
)
89101
}

components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/appbar/FileListAppBar.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ fun FileListAppBar(
7070
.orEmpty()
7171
.map {
7272
PathWithType(
73-
fileType = it.fileType ?: FileType.FILE,
74-
fullPath = path.resolve(it.fileName)
73+
fileType = it.itemType,
74+
fullPath = path.resolve(it.itemName)
7575
)
7676
}
7777
selectionViewModel.select(paths)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.flipperdevices.filemanager.listing.impl.model
2+
3+
import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType
4+
import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem
5+
import okio.Path
6+
7+
sealed interface ExtendedListingItem {
8+
/**
9+
* Local file-only path
10+
* example: file.txt, item.svg
11+
*/
12+
val path: Path
13+
14+
val itemType: FileType
15+
16+
val itemName: String
17+
get() = path.name
18+
19+
fun asListingItem() = ListingItem(
20+
fileName = itemName,
21+
fileType = itemType,
22+
size = (this as? File)?.size ?: 0
23+
)
24+
25+
/**
26+
* @param path file name path. Not full path
27+
* @param size file size in bytes
28+
*/
29+
data class File(
30+
override val path: Path,
31+
val size: Long
32+
) : ExtendedListingItem {
33+
override val itemType: FileType = FileType.FILE
34+
}
35+
36+
/**
37+
* @param path file name path. Not full path
38+
* @param itemsCount amount of items inside
39+
*/
40+
data class Folder(
41+
override val path: Path,
42+
val itemsCount: Int? = null,
43+
) : ExtendedListingItem {
44+
override val itemType: FileType = FileType.DIR
45+
}
46+
}

components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/FilesViewModel.kt

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import com.flipperdevices.bridge.connection.feature.provider.api.get
77
import com.flipperdevices.bridge.connection.feature.provider.api.getSync
88
import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi
99
import com.flipperdevices.bridge.connection.feature.storage.api.fm.FListingStorageApi
10+
import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType
1011
import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem
1112
import com.flipperdevices.core.ktx.jre.launchWithLock
1213
import com.flipperdevices.core.ktx.jre.toThrowableFlow
1314
import com.flipperdevices.core.ktx.jre.withLock
1415
import com.flipperdevices.core.log.LogTagProvider
16+
import com.flipperdevices.core.log.error
1517
import com.flipperdevices.core.preference.pb.FileManagerSort
1618
import com.flipperdevices.core.preference.pb.Settings
1719
import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel
20+
import com.flipperdevices.filemanager.listing.impl.model.ExtendedListingItem
1821
import dagger.assisted.Assisted
1922
import dagger.assisted.AssistedFactory
2023
import dagger.assisted.AssistedInject
@@ -24,12 +27,17 @@ import kotlinx.coroutines.flow.MutableStateFlow
2427
import kotlinx.coroutines.flow.SharingStarted
2528
import kotlinx.coroutines.flow.catch
2629
import kotlinx.coroutines.flow.combine
30+
import kotlinx.coroutines.flow.distinctUntilChangedBy
31+
import kotlinx.coroutines.flow.filterIsInstance
2732
import kotlinx.coroutines.flow.launchIn
33+
import kotlinx.coroutines.flow.map
2834
import kotlinx.coroutines.flow.onEach
2935
import kotlinx.coroutines.flow.stateIn
3036
import kotlinx.coroutines.flow.update
37+
import kotlinx.coroutines.flow.updateAndGet
3138
import kotlinx.coroutines.sync.Mutex
3239
import okio.Path
40+
import okio.Path.Companion.toPath
3341

3442
class FilesViewModel @AssistedInject constructor(
3543
private val featureProvider: FFeatureProvider,
@@ -56,15 +64,22 @@ class FilesViewModel @AssistedInject constructor(
5664
if (settings.show_hidden_files_on_flipper) {
5765
true
5866
} else {
59-
!it.fileName.startsWith(".")
67+
!it.path.name.startsWith(".")
6068
}
6169
}
6270
.sortedByDescending {
6371
when (settings.file_manager_sort) {
6472
is FileManagerSort.Unrecognized,
6573
FileManagerSort.DEFAULT -> null
6674

67-
FileManagerSort.SIZE -> it.size
75+
FileManagerSort.SIZE -> {
76+
when (it) {
77+
is ExtendedListingItem.File -> it.size
78+
// The default size for folder is 0
79+
// Here's placed 0 so sort works as on flipper
80+
is ExtendedListingItem.Folder -> 0
81+
}
82+
}
6883
}
6984
}
7085
.toImmutableList()
@@ -74,12 +89,62 @@ class FilesViewModel @AssistedInject constructor(
7489
}
7590
).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading)
7691

92+
private suspend fun updateFiles(
93+
items: List<ExtendedListingItem>,
94+
listingApi: FListingStorageApi
95+
) {
96+
items
97+
.filterIsInstance<ExtendedListingItem.Folder>()
98+
.filter { directory -> directory.itemsCount == null }
99+
.onEach { directory ->
100+
_state.update { state ->
101+
val loadedState = (state as? State.Loaded)
102+
if (loadedState == null) {
103+
error { "#updateFiles state changed during update" }
104+
return@update state
105+
}
106+
val newList = loadedState.files.toMutableList()
107+
val i = newList.indexOfFirst { item -> item == directory }
108+
if (i == -1) {
109+
error { "#updateFiles could not find item in list" }
110+
return@update loadedState
111+
}
112+
val itemsCount = listingApi.ls(path.resolve(directory.path).toString())
113+
.getOrNull()
114+
.orEmpty()
115+
.size
116+
val updatedDirectory = directory.copy(itemsCount = itemsCount)
117+
newList[i] = updatedDirectory
118+
loadedState.copy(files = newList.toImmutableList())
119+
}
120+
}
121+
}
122+
123+
private fun ListingItem.toExtended(): ExtendedListingItem {
124+
return when (fileType) {
125+
FileType.DIR -> {
126+
ExtendedListingItem.Folder(
127+
path = fileName.toPath(),
128+
itemsCount = null
129+
)
130+
}
131+
132+
null, FileType.FILE -> {
133+
ExtendedListingItem.File(
134+
path = fileName.toPath(),
135+
size = size
136+
)
137+
}
138+
}
139+
}
140+
77141
private suspend fun listFiles(listingApi: FListingStorageApi) {
78142
listingApi.lsFlow(path.toString())
79143
.toThrowableFlow()
80144
.catch { _state.emit(State.CouldNotListPath) }
145+
.map { items -> items.map { item -> item.toExtended() } }
81146
.onEach { files ->
82-
_state.update { state ->
147+
_state.updateAndGet { state ->
83148
when (state) {
84149
is State.Loaded -> {
85150
state.copy(state.files.plus(files).toImmutableList())
@@ -97,7 +162,7 @@ class FilesViewModel @AssistedInject constructor(
97162
val loadedState = _state.value as? State.Loaded ?: return
98163
_state.update {
99164
val newFileList = loadedState.files
100-
.filter { it.fileName != path.name }
165+
.filter { it.path.name != path.name }
101166
.toImmutableList()
102167
loadedState.copy(files = newFileList)
103168
}
@@ -120,8 +185,8 @@ class FilesViewModel @AssistedInject constructor(
120185
(state as? State.Loaded)?.let { loadedState ->
121186
val newItemsNames = items.map(ListingItem::fileName)
122187
val newFiles = loadedState.files
123-
.filter { item -> !newItemsNames.contains(item.fileName) }
124-
.plus(items)
188+
.filter { item -> !newItemsNames.contains(item.itemName) }
189+
.plus(items.map { item -> item.toExtended() })
125190
.toImmutableList()
126191
loadedState.copy(files = newFiles)
127192
} ?: state
@@ -152,14 +217,28 @@ class FilesViewModel @AssistedInject constructor(
152217
.get<FStorageFeatureApi>()
153218
.onEach { featureStatus -> invalidate(featureStatus) }
154219
.launchIn(viewModelScope)
220+
combine(
221+
flow = featureProvider
222+
.get<FStorageFeatureApi>()
223+
.filterIsInstance<FFeatureStatus.Supported<FStorageFeatureApi>>(),
224+
flow2 = state
225+
.filterIsInstance<State.Loaded>()
226+
.distinctUntilChangedBy { it.files.size },
227+
transform = { feature, state ->
228+
updateFiles(
229+
items = state.files,
230+
listingApi = feature.featureApi.listingApi()
231+
)
232+
}
233+
).launchIn(viewModelScope)
155234
}
156235

157236
sealed interface State {
158237
data object Loading : State
159238
data object Unsupported : State
160239
data object CouldNotListPath : State
161240
data class Loaded(
162-
val files: ImmutableList<ListingItem>,
241+
val files: ImmutableList<ExtendedListingItem>,
163242
) : State
164243
}
165244

components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/FolderCardListLazyComposable.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ fun LazyListScope.FolderCardListLazyComposable(
2929
subtitle = file.fullPath.parent
3030
?.toString()
3131
?: file.instance.size.toFormattedSize(),
32+
isSubtitleLoading = false,
3233
selectionState = ItemUiSelectionState.NONE,
3334
onClick = {
3435
when (file.instance.fileType) {

components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/itemcard/FolderCardGridComposablePreview.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ private fun FolderCardGridComposablePreview() {
2424
selectionState = selectionState,
2525
onClick = {},
2626
onCheckChange = {},
27-
onMoreClick = {}
27+
onMoreClick = {},
28+
isSubtitleLoading = false
2829
)
2930
}
3031
ItemUiSelectionState.entries.forEach { selectionState ->
@@ -35,7 +36,20 @@ private fun FolderCardGridComposablePreview() {
3536
selectionState = selectionState,
3637
onClick = {},
3738
onCheckChange = {},
38-
onMoreClick = {}
39+
onMoreClick = {},
40+
isSubtitleLoading = false
41+
)
42+
}
43+
ItemUiSelectionState.entries.forEach { selectionState ->
44+
FolderCardGridComposable(
45+
painter = painterResource(FR.drawable.ic_folder_black),
46+
title = "A very very ultra mega super duper log title with some message at the end",
47+
subtitle = "A very very ultra mega super duper log title with some message at the end",
48+
selectionState = selectionState,
49+
onClick = {},
50+
onCheckChange = {},
51+
onMoreClick = {},
52+
isSubtitleLoading = true
3953
)
4054
}
4155
}

0 commit comments

Comments
 (0)