Skip to content

Commit 233c038

Browse files
committed
Merge branch 'worktree-searcher'
# Conflicts: # app-common/src/main/res/values/strings.xml # app-workspace-searcher/src/main/res/values/strings.xml
2 parents 408e5c2 + 8e7bf64 commit 233c038

29 files changed

Lines changed: 740 additions & 407 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ fastlane android production
122122
- Reactive programming with Kotlin Flow and StateFlow.
123123
- Centralized error handling with `ErrorEventHandler`.
124124
- DataStore-based settings with kotlinx serialization.
125+
- When accessing settings values, use the `.value()` extension function instead of `.flow.first()`
126+
- Example: `searcherSettings.defaultSearchPath.value()` not `searcherSettings.defaultSearchPath.flow.first()`
127+
- For setting values use: `searcherSettings.someSetting.value(newValue)`
125128
- Jetpack Compose for UI.
126129
- Hilt for dependency injection.
127130
- Kotlin Coroutines & Flow for async operations.

app-common/src/main/res/values/strings.xml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<string name="app_name_subtitle">File Explorer Extraordinaire</string>
44

55
<string name="butler_mascot_description">Butler mascot</string>
6-
6+
77
<string name="slogan_message_0">At your service.</string>
88
<string name="slogan_message_1">Everything in its place.</string>
99
<string name="slogan_message_2">Order, not chaos.</string>
@@ -184,7 +184,7 @@
184184
<string name="ui_theme_color_green_label">Green &amp; Gold</string>
185185
<string name="ui_theme_color_blue_label">Blue &amp; Orange</string>
186186
<string name="ui_theme_color_amoled_label">AMOLED</string>
187-
187+
188188
<!-- Setup module types -->
189189
<string name="setup_usagestats_title">Usage stats</string>
190190
<string name="setup_shizuku_card_title">Shizuku</string>
@@ -195,9 +195,8 @@
195195
<string name="setup_inventory_card_title">App inventory</string>
196196

197197
<!-- Common permission strings -->
198-
<string name="common_permission_required_title">Permission Required</string>
199-
<string name="common_permission_additional_required">Additional permissions are required to access: %1$s.</string>
200-
<string name="common_permission_open_setup_action">Open Setup</string>
201-
<string name="common_permission_storage_manage_description">Grant storage access to manage all files.</string>
202-
<string name="common_permission_saf_required_description">Folder access permission required.</string>
198+
<string name="setup_required_card_title">Setup Required</string>
199+
<string name="setup_required_card_body">Additional setup is required to perform this operation.</string>
200+
<string name="setup_required_card_setup_action">Open Setup</string>
201+
<string name="common_permission_storage_manage_description">Accessing this path requires the \'MANAGE_EXTERNAL_STORAGE\' permission.</string>
203202
</resources>

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/ui/explorer/permissions/PermissionRequestCard.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,11 @@ import androidx.compose.material.icons.Icons
1212
import androidx.compose.material.icons.twotone.FolderOff
1313
import androidx.compose.material.icons.twotone.Storage
1414
import androidx.compose.material.icons.twotone.Folder
15-
import androidx.compose.material.icons.twotone.Security
1615
import androidx.compose.material3.Button
1716
import androidx.compose.material3.Card
1817
import androidx.compose.material3.CardDefaults
1918
import androidx.compose.material3.Icon
2019
import androidx.compose.material3.MaterialTheme
21-
import androidx.compose.material3.OutlinedButton
2220
import androidx.compose.material3.Text
2321
import androidx.compose.runtime.Composable
2422
import androidx.compose.ui.Alignment
@@ -32,7 +30,7 @@ import eu.darken.butler.common.compose.PreviewWrapper
3230
import eu.darken.butler.common.permissions.Permission
3331
import eu.darken.butler.explorer.R
3432
import eu.darken.butler.workspace.core.permissions.PermissionState
35-
import eu.darken.butler.workspace.core.permissions.PermissionRequirement
33+
import eu.darken.butler.workspace.core.permissions.SetupRequirement
3634

3735
@Composable
3836
fun PermissionRequestCard(
@@ -129,11 +127,10 @@ private fun PermissionRequestCardPreview() {
129127
PermissionRequestCard(
130128
permissionState = PermissionState(
131129
requirements = listOf(
132-
PermissionRequirement(
130+
SetupRequirement(
133131
permission = Permission.MANAGE_EXTERNAL_STORAGE,
134132
isRequired = true,
135-
reason = "Access files and folders".toCaString(),
136-
alternativeAccess = null,
133+
description = "Access files and folders".toCaString(),
137134
)
138135
),
139136
hasSufficientPermissions = false,

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/core/SearchHistory.kt

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,47 @@ class SearchHistory @Inject constructor(
3737

3838
suspend fun addSearch(query: SearchQuery): String {
3939
log(TAG, INFO) { "Adding search to history: ${query.query}" }
40-
41-
val entity = SearchHistoryEntity(
42-
baseQuery = query.query,
43-
rawQuery = converter.fromSearchQuery(query),
44-
searchedAt = Clock.System.now()
45-
)
46-
40+
41+
val now = Clock.System.now()
42+
43+
// Check if we have a recent identical search (within last 5 minutes)
44+
val existingEntry = searchHistoryDao.getLatestByQuery(query.query)
45+
46+
val entityId = if (existingEntry != null) {
47+
val timeDiff = now - existingEntry.searchedAt
48+
if (timeDiff.inWholeMinutes < 5) {
49+
// Update existing entry's timestamp instead of creating new one
50+
log(TAG) { "Updating timestamp for existing search: ${query.query}" }
51+
searchHistoryDao.updateTimestamp(existingEntry.id, now)
52+
existingEntry.id
53+
} else {
54+
// More than 5 minutes old, create new entry
55+
createNewSearchEntry(query, now)
56+
}
57+
} else {
58+
// No existing entry, create new one
59+
createNewSearchEntry(query, now)
60+
}
61+
4762
appScope.launch {
48-
// Insert new search
49-
searchHistoryDao.insert(entity)
50-
5163
// Clean up old entries if exceeding max
5264
val maxItems = searcherSettings.maxHistoryItems.value()
5365
val currentCount = searchHistoryDao.getCount()
5466
if (currentCount > maxItems) {
5567
searchHistoryDao.deleteOldest(currentCount - maxItems)
5668
}
5769
}
58-
70+
71+
return entityId
72+
}
73+
74+
private suspend fun createNewSearchEntry(query: SearchQuery, timestamp: kotlin.time.Instant): String {
75+
val entity = SearchHistoryEntity(
76+
baseQuery = query.query,
77+
rawQuery = converter.fromSearchQuery(query),
78+
searchedAt = timestamp
79+
)
80+
searchHistoryDao.insert(entity)
5981
return entity.id
6082
}
6183

@@ -92,6 +114,10 @@ class SearchHistory @Inject constructor(
92114
log(TAG) { "Clearing all search history" }
93115
searchHistoryDao.deleteAll()
94116
}
117+
118+
suspend fun getHistoryCount(): Int {
119+
return searchHistoryDao.getCount()
120+
}
95121

96122
companion object {
97123
private val TAG = logTag("Searcher", "History")

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/core/SearchQuery.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,7 @@ data class SearchQuery(
4646
val caseSensitive: Boolean = false,
4747
val useRegex: Boolean = false,
4848
val wholeWord: Boolean = false
49-
) : Parcelable {
50-
companion object {
51-
val DEFAULT = Filter()
52-
}
53-
}
49+
) : Parcelable
5450

5551
companion object {
5652
fun create(

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/core/SearcherSettings.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import eu.darken.butler.common.datastore.PreferenceStoreMapper
1010
import eu.darken.butler.common.datastore.createValue
1111
import eu.darken.butler.common.debug.DebugSettings
1212
import eu.darken.butler.common.debug.logging.logTag
13+
import eu.darken.butler.common.files.APath
1314
import kotlinx.serialization.json.Json
1415
import javax.inject.Inject
1516
import javax.inject.Singleton
@@ -29,9 +30,11 @@ class SearcherSettings @Inject constructor(
2930
val caseSensitive = dataStore.createValue("searcher.case_sensitive", false)
3031
val wholeWord = dataStore.createValue("searcher.whole_word", false)
3132
val useRegex = dataStore.createValue("searcher.use_regex", false)
32-
val maxHistoryItems = dataStore.createValue("searcher.max_history_items", 50)
33-
val saveHistory = dataStore.createValue("searcher.save_history", true)
34-
val maxSearchResults = dataStore.createValue("searcher.max_search_results", 1000)
33+
34+
val defaultSearchPath = dataStore.createValue<APath?>("searcher.default.path", null, json)
35+
val maxSearchResults = dataStore.createValue("searcher.results.maximum", 1000)
36+
val saveHistory = dataStore.createValue("searcher.history.enabled", true)
37+
val maxHistoryItems = dataStore.createValue("searcher.history.maximum", 50)
3538

3639
override val mapper = PreferenceStoreMapper(
3740
debugSettings.isDebugMode,
@@ -41,6 +44,7 @@ class SearcherSettings @Inject constructor(
4144
maxHistoryItems,
4245
saveHistory,
4346
maxSearchResults,
47+
defaultSearchPath,
4448
)
4549

4650
companion object {

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/core/SearcherWorkspace.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import eu.darken.butler.common.ca.toCaString
77
import eu.darken.butler.common.debug.logging.Logging.Priority.*
88
import eu.darken.butler.common.debug.logging.log
99
import eu.darken.butler.common.debug.logging.logTag
10+
import eu.darken.butler.common.files.APath
1011
import eu.darken.butler.workspace.core.Workspace
1112
import eu.darken.butler.workspace.core.preview.SearcherPreviewData
1213
import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,7 +38,7 @@ class SearcherWorkspace @AssistedInject constructor(
3738

3839
@Parcelize
3940
data class Arguments(
40-
val placeholder: String,
41+
val startPath: APath? = null,
4142
) : Workspace.Arguments {
4243
override val type: Workspace.Type
4344
get() = Workspace.Type.SEARCHER

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/core/db/SearchHistoryDao.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,10 @@ interface SearchHistoryDao {
4242

4343
@Query("DELETE FROM search_history WHERE id IN (SELECT id FROM search_history ORDER BY searchedAt ASC LIMIT :count)")
4444
suspend fun deleteOldest(count: Int)
45+
46+
@Query("SELECT * FROM search_history WHERE baseQuery = :query ORDER BY searchedAt DESC LIMIT 1")
47+
suspend fun getLatestByQuery(query: String): SearchHistoryEntity?
48+
49+
@Query("UPDATE search_history SET searchedAt = :timestamp WHERE id = :id")
50+
suspend fun updateTimestamp(id: String, timestamp: kotlin.time.Instant)
4551
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package eu.darken.butler.searcher.ui.search
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.Spacer
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.layout.size
10+
import androidx.compose.foundation.layout.width
11+
import androidx.compose.material.icons.Icons
12+
import androidx.compose.material.icons.twotone.Storage
13+
import androidx.compose.material.icons.twotone.Tune
14+
import androidx.compose.material3.Button
15+
import androidx.compose.material3.ButtonDefaults
16+
import androidx.compose.material3.Card
17+
import androidx.compose.material3.CardDefaults
18+
import androidx.compose.material3.Icon
19+
import androidx.compose.material3.MaterialTheme
20+
import androidx.compose.material3.Text
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.ui.Alignment
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.platform.LocalContext
25+
import androidx.compose.ui.res.stringResource
26+
import androidx.compose.ui.text.font.FontFamily
27+
import androidx.compose.ui.text.style.TextAlign
28+
import androidx.compose.ui.unit.dp
29+
import eu.darken.butler.common.ca.toCaString
30+
import eu.darken.butler.common.compose.Preview2
31+
import eu.darken.butler.common.compose.PreviewWrapper
32+
import eu.darken.butler.common.files.APath
33+
import eu.darken.butler.common.files.RawPath
34+
import eu.darken.butler.common.permissions.Permission
35+
import eu.darken.butler.workspace.core.permissions.PermissionState
36+
import eu.darken.butler.workspace.core.permissions.SetupRequirement
37+
38+
@Composable
39+
fun PermissionSetupCard(
40+
searchPath: APath,
41+
permissionState: PermissionState,
42+
onOpenSetup: () -> Unit,
43+
modifier: Modifier = Modifier,
44+
) {
45+
Card(
46+
modifier = modifier.fillMaxWidth(),
47+
colors = CardDefaults.cardColors(
48+
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
49+
)
50+
) {
51+
Column(
52+
modifier = Modifier
53+
.fillMaxWidth()
54+
.padding(16.dp),
55+
) {
56+
Row(
57+
modifier = Modifier.fillMaxWidth(),
58+
verticalAlignment = Alignment.CenterVertically,
59+
horizontalArrangement = Arrangement.spacedBy(12.dp)
60+
) {
61+
val primaryPermission = permissionState.requirements.firstOrNull()?.permission
62+
Icon(
63+
imageVector = when (primaryPermission) {
64+
is Permission.MANAGE_EXTERNAL_STORAGE -> Icons.TwoTone.Storage
65+
is Permission.WRITE_EXTERNAL_STORAGE -> Icons.TwoTone.Storage
66+
else -> Icons.TwoTone.Storage
67+
},
68+
contentDescription = null,
69+
modifier = Modifier.size(24.dp),
70+
tint = MaterialTheme.colorScheme.error
71+
)
72+
73+
Text(
74+
text = stringResource(eu.darken.butler.common.R.string.setup_required_card_title),
75+
style = MaterialTheme.typography.titleSmall,
76+
color = MaterialTheme.colorScheme.error
77+
)
78+
}
79+
80+
Text(
81+
text = stringResource(eu.darken.butler.common.R.string.setup_required_card_body),
82+
style = MaterialTheme.typography.bodyMedium,
83+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
84+
modifier = Modifier
85+
.fillMaxWidth()
86+
.padding(top = 8.dp)
87+
)
88+
89+
Text(
90+
text = searchPath.path,
91+
style = MaterialTheme.typography.bodySmall,
92+
fontFamily = FontFamily.Monospace,
93+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
94+
textAlign = TextAlign.Center,
95+
modifier = Modifier
96+
.fillMaxWidth()
97+
.padding(top = 8.dp)
98+
)
99+
100+
// Setup requirement description if available
101+
val primaryRequirement = permissionState.requirements.firstOrNull()
102+
primaryRequirement?.let { requirement ->
103+
Text(
104+
text = requirement.description.get(LocalContext.current),
105+
style = MaterialTheme.typography.bodySmall,
106+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
107+
modifier = Modifier
108+
.fillMaxWidth()
109+
.padding(top = 8.dp)
110+
)
111+
}
112+
113+
Button(
114+
onClick = onOpenSetup,
115+
modifier = Modifier
116+
.fillMaxWidth()
117+
.padding(top = 16.dp),
118+
colors = ButtonDefaults.buttonColors(
119+
containerColor = MaterialTheme.colorScheme.error
120+
)
121+
) {
122+
Icon(
123+
imageVector = Icons.TwoTone.Tune,
124+
contentDescription = null,
125+
modifier = Modifier.size(16.dp)
126+
)
127+
Spacer(modifier = Modifier.width(8.dp))
128+
Text(
129+
text = stringResource(eu.darken.butler.common.R.string.setup_required_card_setup_action),
130+
style = MaterialTheme.typography.labelMedium
131+
)
132+
}
133+
}
134+
}
135+
}
136+
137+
@Preview2
138+
@Composable
139+
private fun PermissionSetupCardPreview() {
140+
PreviewWrapper {
141+
PermissionSetupCard(
142+
searchPath = RawPath.build("/storage/emulated/0/Documents"),
143+
permissionState = PermissionState(
144+
requirements = listOf(
145+
SetupRequirement(
146+
permission = Permission.MANAGE_EXTERNAL_STORAGE,
147+
isRequired = true,
148+
description = "Grant access to manage all files on device storage".toCaString(),
149+
)
150+
),
151+
hasSufficientPermissions = false,
152+
missingCritical = listOf(Permission.MANAGE_EXTERNAL_STORAGE),
153+
),
154+
onOpenSetup = {},
155+
)
156+
}
157+
}

0 commit comments

Comments
 (0)