diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bfeadd8..9723360 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) } android { @@ -52,6 +53,10 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) implementation(libs.google.code.scanner) implementation(libs.zxing.core) implementation(libs.androidx.camera.camera2) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a17de8..7bfab66 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -29,7 +30,6 @@ diff --git a/app/src/main/java/app/boxmanifest/BoxManifestApi.kt b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt index 83f6333..b1e01bd 100644 --- a/app/src/main/java/app/boxmanifest/BoxManifestApi.kt +++ b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt @@ -3,6 +3,7 @@ package app.boxmanifest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject +import org.json.JSONArray import java.net.HttpURLConnection import java.net.URL @@ -16,8 +17,12 @@ data class ApiNode( val description: String? = null, val typeId: String? = null, val tagIds: List = emptyList(), + val revision: Long = 1, + val pending: Boolean = false, ) +class ApiException(val status: Int, val code: String?, message: String) : Exception(message) + data class ImportNode( val name: String, val description: String?, @@ -33,6 +38,19 @@ data class TreeImportPreview( val nodes: List, ) +data class ServerInfo( + val serverId: String, + val name: String, + val apiVersion: Int, +) + +interface MetadataOption { val id: String; val name: String } +data class NodeType(override val id: String, override val name: String, val description: String?, val iconKey: String?, val color: String?) : MetadataOption +data class Tag(override val id: String, override val name: String, val color: String?) : MetadataOption + +const val DEFAULT_TYPE_COLOR = "#9b9b9b" +const val DEFAULT_TAG_COLOR = "#8c9ea5" + enum class ChildHandling { WITH_SUBTREE, PROMOTE_CHILDREN, @@ -40,33 +58,68 @@ enum class ChildHandling { } class BoxManifestApi( - private val baseUrl: String = "http://127.0.0.1:8080", + baseUrl: String = app.boxmanifest.data.DEFAULT_SERVER_URL, ) { - fun canonicalNodeUrl(lookupCode: String): String = "$baseUrl/n/$lookupCode" + @Volatile + private var baseUrl = baseUrl.trimEnd('/') + + fun setBaseUrl(value: String) { + baseUrl = value.trimEnd('/') + } + + suspend fun getSystemInfo(): ServerInfo { + val response = request("GET", "/api/system/info") + return ServerInfo( + serverId = response.getString("serverId"), + name = response.getString("name"), + apiVersion = response.getInt("apiVersion"), + ) + } suspend fun getTree(): ApiNode = request("GET", "/api/tree").toNode() - suspend fun createNode(parentId: String?, name: String, description: String?, quantity: Long?): ApiNode { + suspend fun getNodeTypes(): List = requestArray("GET", "/api/node-types").let { values -> + List(values.length()) { values.getJSONObject(it).toNodeType() } + } + + suspend fun getTags(): List = requestArray("GET", "/api/tags").let { values -> + List(values.length()) { values.getJSONObject(it).toTag() } + } + + suspend fun createNodeType(name: String, color: String = DEFAULT_TYPE_COLOR): NodeType = + request("POST", "/api/node-types", JSONObject().put("name", name).put("color", color).put("iconKey", "cube")).toNodeType() + + suspend fun updateNodeType(type: NodeType): NodeType = + request("PUT", "/api/node-types/${type.id}", JSONObject().put("name", type.name).put("description", type.description ?: JSONObject.NULL).put("color", type.color ?: JSONObject.NULL).put("iconKey", type.iconKey ?: "cube")).toNodeType() + + suspend fun deleteNodeType(id: String) { request("DELETE", "/api/node-types/$id", expectBody = false) } + + suspend fun createTag(name: String, color: String = DEFAULT_TAG_COLOR): Tag = + request("POST", "/api/tags", JSONObject().put("name", name).put("color", color)).toTag() + + suspend fun updateTag(tag: Tag): Tag = + request("PUT", "/api/tags/${tag.id}", JSONObject().put("name", tag.name).put("color", tag.color ?: JSONObject.NULL)).toTag() + + suspend fun deleteTag(id: String) { request("DELETE", "/api/tags/$id", expectBody = false) } + + suspend fun classifyNodes(ids: Set, typeId: String? = null, setType: Boolean = false, addTagIds: List = emptyList(), removeTagIds: List = emptyList()) { + request( + "POST", "/api/nodes/bulk/classification", + JSONObject().put("nodeIds", JSONArray(ids.toList())).put("setType", setType) + .put("typeId", typeId ?: JSONObject.NULL).put("addTagIds", JSONArray(addTagIds)).put("removeTagIds", JSONArray(removeTagIds)), + ) + } + + suspend fun createNode(parentId: String?, name: String, description: String?, quantity: Long?, clientId: String? = null): ApiNode { val body = JSONObject() .put("name", name) .put("description", description ?: JSONObject.NULL) .put("quantity", quantity ?: JSONObject.NULL) if (parentId != null) body.put("parentId", parentId) + if (clientId != null) body.put("clientId", clientId) return request("POST", "/api/nodes", body).toNode() } - suspend fun createNodes(parentId: String, names: List, description: String?): List { - val values = org.json.JSONArray() - names.forEach(values::put) - val response = request( - "POST", - "/api/nodes/bulk", - JSONObject().put("parentId", parentId).put("names", values).put("description", description ?: JSONObject.NULL), - ) - val nodes = response.getJSONArray("nodes") - return List(nodes.length()) { index -> nodes.getJSONObject(index).toNode() } - } - suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview { val manifest = JSONObject(manifestJson) val response = request( @@ -94,14 +147,15 @@ class BoxManifestApi( ) } - suspend fun updateNode(id: String, name: String, description: String?, quantity: Long?): ApiNode = + suspend fun updateNode(id: String, name: String, description: String?, quantity: Long?, baseRevision: Long? = null): ApiNode = request( "PATCH", "/api/nodes/$id", JSONObject() .put("name", name) .put("description", description ?: JSONObject.NULL) - .put("quantity", quantity ?: JSONObject.NULL), + .put("quantity", quantity ?: JSONObject.NULL) + .apply { if (baseRevision != null) put("baseRevision", baseRevision) }, ).toNode() suspend fun moveNode(id: String, targetParentId: String, handling: ChildHandling): ApiNode = @@ -163,18 +217,37 @@ class BoxManifestApi( val stream = if (status in 200..299) connection.inputStream else connection.errorStream val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty() if (status !in 200..299) { - val message = runCatching { JSONObject(text).getString("message") } - .getOrDefault("Request failed with status $status") - error(message) + val error = runCatching { JSONObject(text) }.getOrNull() + val message = error?.optString("message")?.takeIf(String::isNotBlank) + ?: "Request failed with status $status" + throw ApiException(status, error?.optString("code")?.takeIf(String::isNotBlank), message) } if (!expectBody || text.isBlank()) JSONObject() else JSONObject(text) } finally { connection.disconnect() } } + + private suspend fun requestArray(method: String, path: String): JSONArray = withContext(Dispatchers.IO) { + val connection = URL(baseUrl + path).openConnection() as HttpURLConnection + try { + connection.requestMethod = method + connection.connectTimeout = 5_000 + connection.readTimeout = 10_000 + connection.setRequestProperty("Accept", "application/json") + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + if (status !in 200..299) throw ApiException(status, null, "Request failed with status $status") + JSONArray(text) + } finally { connection.disconnect() } + } } -private fun JSONObject.toNode(): ApiNode { +private fun JSONObject.toNodeType() = NodeType(getString("id"), getString("name"), optString("description").takeUnless { isNull("description") }, optString("iconKey").takeUnless { isNull("iconKey") }, optString("color").takeUnless { isNull("color") }) +private fun JSONObject.toTag() = Tag(getString("id"), getString("name"), optString("color").takeUnless { isNull("color") }) + +internal fun JSONObject.toNode(): ApiNode { val childValues = optJSONArray("children") val children = if (childValues == null) { emptyList() @@ -192,10 +265,29 @@ private fun JSONObject.toNode(): ApiNode { tagIds = optJSONArray("tagIds")?.let { values -> List(values.length()) { index -> values.getString(index) } } ?: emptyList(), + revision = optLong("revision", 1), children = children, ) } +internal fun ApiNode.toJson(): JSONObject { + val childValues = org.json.JSONArray() + children.forEach { childValues.put(it.toJson()) } + val tagValues = org.json.JSONArray() + tagIds.forEach(tagValues::put) + return JSONObject() + .put("id", id) + .put("lookupCode", lookupCode) + .put("parentId", parentId ?: JSONObject.NULL) + .put("name", name) + .put("description", description ?: JSONObject.NULL) + .put("quantity", quantity ?: JSONObject.NULL) + .put("typeId", typeId ?: JSONObject.NULL) + .put("tagIds", tagValues) + .put("revision", revision) + .put("children", childValues) +} + private fun JSONObject.toImportNode(): ImportNode { val childrenJson = optJSONArray("children") return ImportNode( diff --git a/app/src/main/java/app/boxmanifest/MainActivity.kt b/app/src/main/java/app/boxmanifest/MainActivity.kt index 453f6e9..f980b32 100644 --- a/app/src/main/java/app/boxmanifest/MainActivity.kt +++ b/app/src/main/java/app/boxmanifest/MainActivity.kt @@ -19,7 +19,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { ManifestTheme { - val treeViewModel: TreeViewModel = viewModel(factory = TreeViewModel.factory()) + val treeViewModel: TreeViewModel = viewModel(factory = TreeViewModel.factory(applicationContext)) Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> TreeScreen( viewModel = treeViewModel, diff --git a/app/src/main/java/app/boxmanifest/data/ConnectivityMonitor.kt b/app/src/main/java/app/boxmanifest/data/ConnectivityMonitor.kt new file mode 100644 index 0000000..bef7ef0 --- /dev/null +++ b/app/src/main/java/app/boxmanifest/data/ConnectivityMonitor.kt @@ -0,0 +1,29 @@ +package app.boxmanifest.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +class ConnectivityMonitor(context: Context) { + private val manager = context.applicationContext.getSystemService(ConnectivityManager::class.java) + + val connected: Flow = callbackFlow { + fun current(): Boolean = manager.activeNetwork?.let(manager::getNetworkCapabilities) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { trySend(current()) } + override fun onLost(network: Network) { trySend(current()) } + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { trySend(current()) } + } + trySend(current()) + manager.registerNetworkCallback(NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(), callback) + awaitClose { manager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +} diff --git a/app/src/main/java/app/boxmanifest/data/LocalInventoryDatabase.kt b/app/src/main/java/app/boxmanifest/data/LocalInventoryDatabase.kt new file mode 100644 index 0000000..ae8a7d6 --- /dev/null +++ b/app/src/main/java/app/boxmanifest/data/LocalInventoryDatabase.kt @@ -0,0 +1,127 @@ +package app.boxmanifest.data + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction + +@Entity(tableName = "nodes", indices = [Index("parentId")]) +data class LocalNode( + @PrimaryKey val id: String, + val lookupCode: String, + val parentId: String?, + val name: String, + val description: String?, + val quantity: Long?, + val typeId: String?, + val tagIdsJson: String, + val revision: Long, + val sortOrder: Int, + val pendingCreate: Boolean = false, +) + +@Entity(tableName = "pending_operations", indices = [Index(value = ["nodeId"], unique = true)]) +data class PendingOperation( + @PrimaryKey(autoGenerate = true) val operationId: Long = 0, + val kind: String, + val nodeId: String, + val parentId: String?, + val name: String, + val description: String?, + val quantity: Long?, + val baseRevision: Long?, + val status: String = STATUS_PENDING, + val error: String? = null, +) { + companion object { + const val CREATE = "CREATE" + const val UPDATE = "UPDATE" + const val STATUS_PENDING = "PENDING" + const val STATUS_CONFLICT = "CONFLICT" + } +} + +@Dao +interface InventoryDao { + @Query("SELECT * FROM nodes ORDER BY sortOrder") + suspend fun nodes(): List + + @Query("SELECT * FROM nodes WHERE id = :id") + suspend fun node(id: String): LocalNode? + + @Query("SELECT COALESCE(MAX(sortOrder), -1) + 1 FROM nodes") + suspend fun nextSortOrder(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putNode(node: LocalNode) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putNodes(nodes: List) + + @Query("DELETE FROM nodes") + suspend fun deleteNodes() + + @Transaction + suspend fun replaceNodes(nodes: List) { + deleteNodes() + putNodes(nodes) + } + + @Query("SELECT * FROM pending_operations ORDER BY operationId") + suspend fun operations(): List + + @Query("SELECT * FROM pending_operations WHERE nodeId = :nodeId") + suspend fun operationFor(nodeId: String): PendingOperation? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putOperation(operation: PendingOperation) + + @Query("DELETE FROM pending_operations WHERE operationId = :id") + suspend fun deleteOperation(id: Long) + + @Query("UPDATE pending_operations SET status = :status, error = :error WHERE operationId = :id") + suspend fun markOperation(id: Long, status: String, error: String?) + + @Query("DELETE FROM pending_operations") + suspend fun deleteOperations() + + @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'PENDING'") + suspend fun pendingCount(): Int + + @Query("SELECT error FROM pending_operations WHERE status = 'CONFLICT' ORDER BY operationId LIMIT 1") + suspend fun firstConflict(): String? + + @Query("SELECT * FROM pending_operations WHERE status = 'CONFLICT' ORDER BY operationId LIMIT 1") + suspend fun firstConflictOperation(): PendingOperation? + + @Transaction + suspend fun clearAll() { + deleteOperations() + deleteNodes() + } +} + +@Database(entities = [LocalNode::class, PendingOperation::class], version = 1, exportSchema = false) +abstract class LocalInventoryDatabase : RoomDatabase() { + abstract fun inventory(): InventoryDao + + companion object { + @Volatile private var instance: LocalInventoryDatabase? = null + + fun get(context: Context): LocalInventoryDatabase = instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + LocalInventoryDatabase::class.java, + "box-manifest-local.db", + ).build().also { instance = it } + } + } +} diff --git a/app/src/main/java/app/boxmanifest/data/NodeRepository.kt b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt index c09d8e4..9c9d89e 100644 --- a/app/src/main/java/app/boxmanifest/data/NodeRepository.kt +++ b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt @@ -1,47 +1,267 @@ package app.boxmanifest.data +import androidx.room.withTransaction +import app.boxmanifest.ApiException import app.boxmanifest.ApiNode import app.boxmanifest.BoxManifestApi import app.boxmanifest.ChildHandling +import app.boxmanifest.ServerInfo +import app.boxmanifest.NodeType +import app.boxmanifest.Tag +import app.boxmanifest.DEFAULT_TAG_COLOR +import app.boxmanifest.DEFAULT_TYPE_COLOR import app.boxmanifest.TreeImportPreview +import org.json.JSONArray +import kotlinx.coroutines.CancellationException +import java.util.UUID + +data class TreeLoadResult( + val root: ApiNode, + val fromCache: Boolean, + val pendingOperations: Int = 0, + val syncConflict: String? = null, +) class NodeRepository( + private val database: LocalInventoryDatabase, private val api: BoxManifestApi = BoxManifestApi(), ) { - suspend fun loadTree(): ApiNode = api.getTree() + private val dao = database.inventory() - suspend fun createChild(parentId: String, name: String, description: String?, quantity: Long?) { - api.createNode(parentId, name, description, quantity) + fun setServerUrl(url: String) = api.setBaseUrl(url) + suspend fun testServer(url: String): ServerInfo = BoxManifestApi(url).getSystemInfo() + suspend fun nodeTypes(): List = api.getNodeTypes() + suspend fun tags(): List = api.getTags() + suspend fun createNodeType(name: String, color: String = DEFAULT_TYPE_COLOR): NodeType = api.createNodeType(name, color) + suspend fun updateNodeType(type: NodeType): NodeType = api.updateNodeType(type) + suspend fun deleteNodeType(id: String) = api.deleteNodeType(id) + suspend fun createTag(name: String, color: String = DEFAULT_TAG_COLOR): Tag = api.createTag(name, color) + suspend fun updateTag(tag: Tag): Tag = api.updateTag(tag) + suspend fun deleteTag(id: String) = api.deleteTag(id) + suspend fun clearCache() = dao.clearAll() + + suspend fun loadTree(): TreeLoadResult { + synchronize() + val pending = dao.pendingCount() + val conflict = dao.firstConflict() + if (pending == 0 && conflict == null) { + try { + return TreeLoadResult(loadTreeFromServer(), fromCache = false) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + // Fall through to the durable local view. + } + } + val local = loadLocalTree() ?: throw IllegalStateException("No saved inventory is available") + return TreeLoadResult(local, fromCache = true, pendingOperations = pending, syncConflict = conflict) } - suspend fun createChildren(parentId: String, names: List, description: String?) { - api.createNodes(parentId, names, description) + suspend fun loadTreeFromServer(): ApiNode { + val root = api.getTree() + dao.replaceNodes(flatten(root)) + return root } - suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview = - api.previewTreeImport(parentId, manifestJson) - - suspend fun commitTreeImport(planId: String) { - api.commitTreeImport(planId) + suspend fun createChild(parentId: String, name: String, description: String?, quantity: Long?): TreeLoadResult { + queueCreates(parentId, listOf(PendingCreate(name, description, quantity))) + return loadTree() } - suspend fun update(id: String, name: String, description: String?, quantity: Long?) { - api.updateNode(id, name, description, quantity) + suspend fun createChildren(parentId: String, names: List, description: String?): TreeLoadResult { + queueCreates(parentId, names.map { PendingCreate(it, description, null) }) + return loadTree() } + suspend fun update(id: String, name: String, description: String?, quantity: Long?): TreeLoadResult { + database.withTransaction { + val node = dao.node(id) ?: error("Node is not stored locally") + val existing = dao.operationFor(id) + if (existing?.status == PendingOperation.STATUS_CONFLICT) { + error("Resolve the existing sync conflict before editing this node") + } + val changed = node.copy(name = name.trim(), description = description, quantity = quantity) + dao.putNode(changed) + dao.putOperation( + PendingOperation( + operationId = existing?.operationId ?: 0, + kind = existing?.kind ?: PendingOperation.UPDATE, + nodeId = id, + parentId = if (existing?.kind == PendingOperation.CREATE) existing.parentId else null, + name = changed.name, + description = changed.description, + quantity = changed.quantity, + baseRevision = existing?.baseRevision ?: node.revision, + ), + ) + } + return loadTree() + } + + suspend fun synchronize(): SyncSummary { + for (operation in dao.operations()) { + if (operation.status == PendingOperation.STATUS_CONFLICT) break + try { + when (operation.kind) { + PendingOperation.CREATE -> api.createNode( + parentId = operation.parentId, + name = operation.name, + description = operation.description, + quantity = operation.quantity, + clientId = operation.nodeId, + ) + PendingOperation.UPDATE -> api.updateNode( + id = operation.nodeId, + name = operation.name, + description = operation.description, + quantity = operation.quantity, + baseRevision = operation.baseRevision, + ) + else -> error("Unknown queued operation ${operation.kind}") + } + dao.deleteOperation(operation.operationId) + } catch (failure: ApiException) { + if (failure.status >= 500) break + dao.markOperation( + operation.operationId, + PendingOperation.STATUS_CONFLICT, + if (failure.code == "REVISION_CONFLICT") "${operation.name} changed on the server" + else failure.message ?: "Queued change was rejected", + ) + break + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + break + } + } + return SyncSummary(dao.pendingCount(), dao.firstConflict()) + } + + suspend fun resolveConflict(keepLocal: Boolean): TreeLoadResult { + val operation = dao.firstConflictOperation() ?: return loadTree() + if (keepLocal) { + dao.putOperation(operation.copy(baseRevision = null, status = PendingOperation.STATUS_PENDING, error = null)) + } else { + dao.deleteOperation(operation.operationId) + } + return loadTree() + } + + suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview = api.previewTreeImport(parentId, manifestJson) + suspend fun commitTreeImport(planId: String) = api.commitTreeImport(planId) + suspend fun move(id: String, targetParentId: String, childHandling: ChildHandling) { + requireOnlineReady() api.moveNode(id, targetParentId, childHandling) } suspend fun moveMany(ids: Set, targetParentId: String, childHandling: ChildHandling) { + requireOnlineReady() api.moveNodes(ids, targetParentId, childHandling) } suspend fun delete(id: String) { + requireOnlineReady() api.deleteNode(id) } suspend fun deleteMany(ids: Set) { + requireOnlineReady() api.deleteNodes(ids) } + + suspend fun classify(ids: Set, typeId: String? = null, setType: Boolean = false, addTags: List = emptyList(), removeTags: List = emptyList()) { + requireOnlineReady() + api.classifyNodes(ids, typeId, setType, addTags, removeTags) + } + + private suspend fun requireOnlineReady() { + val summary = synchronize() + check(summary.pendingOperations == 0 && summary.conflict == null) { "Pending offline changes must sync first" } + api.getSystemInfo() + } + + private suspend fun queueCreates(parentId: String, creates: List) { + database.withTransaction { + check(dao.node(parentId) != null) { "Parent is not stored locally" } + var order = dao.nextSortOrder() + creates.forEach { create -> + val id = UUID.randomUUID().toString().replace("-", "") + val node = LocalNode( + id = id, + lookupCode = id.take(6).uppercase(), + parentId = parentId, + name = create.name.trim(), + description = create.description, + quantity = create.quantity, + typeId = null, + tagIdsJson = "[]", + revision = 1, + sortOrder = order++, + pendingCreate = true, + ) + dao.putNode(node) + dao.putOperation( + PendingOperation( + kind = PendingOperation.CREATE, + nodeId = id, + parentId = parentId, + name = node.name, + description = create.description, + quantity = create.quantity, + baseRevision = null, + ), + ) + } + } + } + + private suspend fun loadLocalTree(): ApiNode? { + val values = dao.nodes() + if (values.isEmpty()) return null + val children = values.groupBy(LocalNode::parentId) + fun build(value: LocalNode): ApiNode = value.toApiNode(children[value.id].orEmpty().map(::build)) + return values.firstOrNull { it.id == "root" }?.let(::build) + } + + private fun flatten(root: ApiNode): List { + var order = 0 + val result = mutableListOf() + fun visit(node: ApiNode) { + result += node.toLocalNode(order++) + node.children.forEach(::visit) + } + visit(root) + return result + } } + +data class SyncSummary(val pendingOperations: Int, val conflict: String?) + +private data class PendingCreate(val name: String, val description: String?, val quantity: Long?) + +private fun LocalNode.toApiNode(children: List) = ApiNode( + id = id, + lookupCode = lookupCode, + parentId = parentId, + name = name, + description = description, + quantity = quantity, + typeId = typeId, + tagIds = JSONArray(tagIdsJson).let { values -> List(values.length()) { values.getString(it) } }, + revision = revision, + pending = pendingCreate, + children = children, +) + +private fun ApiNode.toLocalNode(order: Int) = LocalNode( + id = id, + lookupCode = lookupCode, + parentId = parentId, + name = name, + description = description, + quantity = quantity, + typeId = typeId, + tagIdsJson = JSONArray(tagIds).toString(), + revision = revision, + sortOrder = order, +) diff --git a/app/src/main/java/app/boxmanifest/data/ServerSettings.kt b/app/src/main/java/app/boxmanifest/data/ServerSettings.kt new file mode 100644 index 0000000..cd75c00 --- /dev/null +++ b/app/src/main/java/app/boxmanifest/data/ServerSettings.kt @@ -0,0 +1,63 @@ +package app.boxmanifest.data + +import android.content.Context +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.IOException +import java.net.URI + +const val DEFAULT_SERVER_URL = "http://box-manifest.hosts.uuard.com:8080" + +data class ServerConfig( + val url: String = DEFAULT_SERVER_URL, + val serverId: String? = null, + val serverName: String? = null, +) + +private val Context.serverDataStore by preferencesDataStore(name = "server") + +class ServerSettings(context: Context) { + private val appContext = context.applicationContext + private val urlKey = stringPreferencesKey("url") + private val idKey = stringPreferencesKey("server_id") + private val nameKey = stringPreferencesKey("server_name") + + val config: Flow = appContext.serverDataStore.data + .catch { failure -> + if (failure is IOException) emit(emptyPreferences()) else throw failure + } + .map { preferences -> + ServerConfig( + url = preferences[urlKey] ?: DEFAULT_SERVER_URL, + serverId = preferences[idKey], + serverName = preferences[nameKey], + ) + } + + suspend fun load(): ServerConfig = config.first() + + suspend fun save(config: ServerConfig) { + appContext.serverDataStore.edit { preferences -> + preferences[urlKey] = config.url + config.serverId?.let { preferences[idKey] = it } ?: preferences.remove(idKey) + config.serverName?.let { preferences[nameKey] = it } ?: preferences.remove(nameKey) + } + } +} + +fun normalizeServerUrl(value: String): String { + val candidate = value.trim().let { if ("://" in it) it else "http://$it" }.trimEnd('/') + val uri = runCatching { URI(candidate) }.getOrElse { throw IllegalArgumentException("Enter a valid server URL") } + require(uri.scheme == "http" || uri.scheme == "https") { "Server URL must use http or https" } + require(!uri.host.isNullOrBlank()) { "Server URL must include a host" } + require(uri.rawQuery == null && uri.rawFragment == null && (uri.path.isNullOrBlank() || uri.path == "/")) { + "Server URL cannot include a path, query, or fragment" + } + return candidate +} diff --git a/app/src/main/java/app/boxmanifest/labels/FindNodeScreen.kt b/app/src/main/java/app/boxmanifest/labels/FindNodeScreen.kt index c03cf62..510ed0b 100644 --- a/app/src/main/java/app/boxmanifest/labels/FindNodeScreen.kt +++ b/app/src/main/java/app/boxmanifest/labels/FindNodeScreen.kt @@ -3,9 +3,7 @@ package app.boxmanifest.labels import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.os.Build import android.os.VibrationEffect -import android.os.Vibrator import android.os.VibratorManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -197,16 +195,6 @@ private fun createBarcodeScanner(): BarcodeScanner = BarcodeScanning.getClient( ) private fun vibrate(context: Context) { - val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - context.getSystemService(VibratorManager::class.java).defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(120) - } + context.getSystemService(VibratorManager::class.java).defaultVibrator + .vibrate(VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)) } diff --git a/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt index d3ca00b..6f2ddff 100644 --- a/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt +++ b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt @@ -84,7 +84,7 @@ fun scanNodeCode(activity: Activity, onCode: (String) -> Unit, onError: (String) val code = barcode.rawValue?.let(::extractLookupCode) if (code == null) onError("That QR code is not a Box Manifest label") else onCode(code) } - .addOnFailureListener { onError(it.message ?: "Could not open QR scanner") } + .addOnFailureListener { onError("Failed to scan code") } } fun extractLookupCode(value: String): String? { diff --git a/app/src/main/java/app/boxmanifest/tree/NodeFilters.kt b/app/src/main/java/app/boxmanifest/tree/NodeFilters.kt new file mode 100644 index 0000000..68502d0 --- /dev/null +++ b/app/src/main/java/app/boxmanifest/tree/NodeFilters.kt @@ -0,0 +1,50 @@ +package app.boxmanifest.tree + +import app.boxmanifest.ApiNode + +internal const val NO_TYPE_FILTER = "__no_type__" +internal enum class FilterMode { AND, OR } +internal enum class QuantityFilter { SET, NOT_SET, ZERO, ONE } + +internal data class SearchFilters( + val name: String = "", + val description: String = "", + val typeId: String? = null, + val tagIds: Set = emptySet(), + val quantity: QuantityFilter? = null, +) { + val count: Int + get() = listOf(name.isNotBlank(), description.isNotBlank(), typeId != null, quantity != null).count { it } + tagIds.size + + fun matches(node: ApiNode, mode: FilterMode): Boolean { + val matches = buildList { + if (name.isNotBlank()) add(node.name.contains(name.trim(), ignoreCase = true)) + if (description.isNotBlank()) add(node.description.orEmpty().contains(description.trim(), ignoreCase = true)) + if (typeId != null) add(if (typeId == NO_TYPE_FILTER) node.typeId == null else node.typeId == typeId) + tagIds.forEach { tagId -> add(tagId in node.tagIds) } + quantity?.let { filter -> + add(when (filter) { + QuantityFilter.SET -> node.quantity != null + QuantityFilter.NOT_SET -> node.quantity == null + QuantityFilter.ZERO -> node.quantity == 0L + QuantityFilter.ONE -> node.quantity == 1L + }) + } + } + return matches.isEmpty() || if (mode == FilterMode.AND) matches.all { it } else matches.any { it } + } +} + +internal fun List.matchingFilters(typeId: String?, tagIds: Set, mode: FilterMode = FilterMode.AND): List = + filter { node -> + val matches = buildList { + if (typeId != null) add( + if (typeId == NO_TYPE_FILTER) node.typeId == null else node.typeId == typeId, + ) + tagIds.forEach { tagId -> add(tagId in node.tagIds) } + } + matches.isEmpty() || if (mode == FilterMode.AND) matches.all { it } else matches.any { it } + } + +internal fun List.selectableIds(): Set = + filterNot { it.id == "root" || it.id == "unsorted" }.mapTo(mutableSetOf(), ApiNode::id) diff --git a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt index 668a261..ebcca7a 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt @@ -4,10 +4,14 @@ package app.boxmanifest.tree import android.app.Activity import androidx.compose.foundation.Image +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.isSystemInDarkTheme import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,13 +22,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme @@ -32,10 +41,9 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,42 +51,57 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.boxmanifest.ApiNode -import app.boxmanifest.BoxManifestApi import app.boxmanifest.ChildHandling import app.boxmanifest.ImportNode +import app.boxmanifest.MetadataOption import app.boxmanifest.TreeImportPreview +import app.boxmanifest.NodeType +import app.boxmanifest.Tag +import app.boxmanifest.DEFAULT_TAG_COLOR +import app.boxmanifest.DEFAULT_TYPE_COLOR import app.boxmanifest.ui.components.NodeRow +import app.boxmanifest.ui.components.NodeTypeIcon import app.boxmanifest.ui.components.PropertyRow import app.boxmanifest.ui.components.HierarchyPicker import app.boxmanifest.ui.components.BackIcon import app.boxmanifest.ui.components.CloseIcon import app.boxmanifest.ui.components.MoreIcon +import app.boxmanifest.ui.components.EditIcon +import app.boxmanifest.ui.components.FilterIcon import app.boxmanifest.ui.components.NodeBreadcrumbs import app.boxmanifest.ui.components.NewNodeIcon import app.boxmanifest.ui.components.SearchIcon +import app.boxmanifest.ui.components.AppMenuIcon +import app.boxmanifest.ui.components.RightChevronIcon +import app.boxmanifest.ui.components.showErrorMessage import app.boxmanifest.labels.createLabelBitmap import app.boxmanifest.labels.FindNodeScreen import app.boxmanifest.labels.scanNodeCode import app.boxmanifest.labels.shareLabel -import kotlinx.coroutines.launch -private enum class OpenSheet { CREATE, IMPORT, EDIT, ACTIONS, LABEL, MOVE, MULTI_MOVE, MULTI_DELETE } +private enum class OpenSheet { CREATE, IMPORT, EDIT, LABEL, MOVE, MULTI_MOVE, MULTI_DELETE, TYPE, TAGS, FILTERS } +private enum class SettingsPage(val title: String) { + INDEX("Settings"), SERVER("Server URL") +} @Composable fun TreeScreen( @@ -87,14 +110,20 @@ fun TreeScreen( ) { val state by viewModel.state.collectAsState() val current = state.currentNode - val snackbarHost = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() val activity = LocalContext.current as Activity var openSheet by remember { mutableStateOf(null) } var searchOpen by remember { mutableStateOf(false) } + var settingsOpen by remember { mutableStateOf(false) } + var conflictOpen by remember { mutableStateOf(false) } var findTarget by remember { mutableStateOf(null) } var selectedIds by remember(current?.id) { mutableStateOf>(emptySet()) } - val selectedNodes = current?.children.orEmpty().filter { it.id in selectedIds } + var typeFilterId by remember(current?.id) { mutableStateOf(null) } + var tagFilterIds by remember(current?.id) { mutableStateOf>(emptySet()) } + var filterMode by remember(current?.id) { mutableStateOf(FilterMode.AND) } + val visibleChildren = current?.children.orEmpty().matchingFilters(typeFilterId, tagFilterIds, filterMode) + val activeFilterCount = (if (typeFilterId == null) 0 else 1) + tagFilterIds.size + val selectedNodes = visibleChildren.filter { it.id in selectedIds } + val selectableIds = visibleChildren.selectableIds() val selectionCanDelete = selectedNodes.isNotEmpty() && selectedNodes.all { it.children.isEmpty() } findTarget?.let { target -> @@ -102,8 +131,9 @@ fun TreeScreen( return } - BackHandler(enabled = state.canGoBack || openSheet != null || selectedIds.isNotEmpty() || searchOpen) { + BackHandler(enabled = settingsOpen || state.canGoBack || openSheet != null || selectedIds.isNotEmpty() || searchOpen) { when { + settingsOpen -> settingsOpen = false openSheet != null -> openSheet = null searchOpen -> searchOpen = false selectedIds.isNotEmpty() -> selectedIds = emptySet() @@ -113,30 +143,77 @@ fun TreeScreen( LaunchedEffect(state.error) { state.error?.let { - snackbarHost.showSnackbar(it) + activity.showErrorMessage(it) viewModel.clearError() } } + LaunchedEffect(state.usingCachedTree) { + if (state.usingCachedTree) { + openSheet = null + selectedIds = emptySet() + } + } + + if (settingsOpen) { + SettingsScreen( + initialUrl = state.serverUrl, + serverName = state.serverName, + serverId = state.serverId, + busy = state.serverSettingsBusy, + status = state.serverTestStatus, + message = state.serverTestMessage, + onBack = { settingsOpen = false }, + onTest = viewModel::testServerUrl, + onSave = viewModel::saveServerUrl, + onClearStatus = viewModel::clearServerTestStatus, + modifier = modifier.fillMaxSize(), + ) + return + } Column(modifier = modifier.fillMaxSize()) { NodeTopBar( - canGoBack = state.canGoBack, - protectedNode = current?.id == "root" || current?.id == "unsorted", - onBack = viewModel::goBack, - onRefresh = viewModel::refresh, - onActions = { openSheet = OpenSheet.ACTIONS }, + protectedNode = current?.id == "unsorted", + onSettings = { settingsOpen = true }, + currentName = current?.name.orEmpty(), + currentCanMove = current != null && current.id != "root" && current.id != "unsorted", + currentCanDelete = current != null && current.id != "root" && current.id != "unsorted" && current.children.isEmpty(), + currentPending = current?.pending == true, + onEditCurrent = { openSheet = OpenSheet.EDIT }, + onMoveCurrent = { openSheet = OpenSheet.MOVE }, + onTypeCurrent = { openSheet = OpenSheet.TYPE }, + onTagsCurrent = { openSheet = OpenSheet.TAGS }, + onLabelCurrent = { openSheet = OpenSheet.LABEL }, + onFindCurrent = { current?.let { findTarget = it } }, + onDeleteCurrent = { viewModel.deleteCurrent() }, selectedCount = selectedIds.size, + canSelectAll = selectedIds.size < selectableIds.size, selectionCanDelete = selectionCanDelete, onCancelSelection = { selectedIds = emptySet() }, + onSelectAll = { selectedIds = selectableIds }, onMoveSelection = { openSheet = OpenSheet.MULTI_MOVE }, onDeleteSelection = { openSheet = OpenSheet.MULTI_DELETE }, + onTypeSelection = { openSheet = OpenSheet.TYPE }, + onTagSelection = { openSheet = OpenSheet.TAGS }, + readOnly = state.usingCachedTree, ) + if (state.usingCachedTree || state.pendingOperations > 0 || state.syncConflict != null) { + SyncBanner( + offline = state.usingCachedTree, + retrying = state.loading, + pending = state.pendingOperations, + conflict = state.syncConflict, + onConflict = { conflictOpen = true }, + onRetry = { viewModel.refresh(showCacheFailure = true) }, + ) + } if (state.loading && current == null) { Text("Loading…", modifier = Modifier.padding(20.dp)) } else if (current != null) { Box(modifier = Modifier.weight(1f)) { NodeScreen( node = current, + children = visibleChildren, breadcrumbs = state.breadcrumbs, onNavigateBreadcrumb = viewModel::openBreadcrumb, onOpenChild = viewModel::openNode, @@ -144,7 +221,11 @@ fun TreeScreen( onToggleSelection = { id -> selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id }, - onStartSelection = { id -> selectedIds = setOf(id) }, + onStartSelection = if (state.usingCachedTree) null else { id -> selectedIds = setOf(id) }, + nodeTypes = state.nodeTypes, + tags = state.tags, + activeFilterCount = activeFilterCount, + onFilters = { openSheet = OpenSheet.FILTERS }, ) if (selectedIds.isEmpty()) { FloatingActionButton( @@ -166,11 +247,31 @@ fun TreeScreen( } } - SnackbarHost(hostState = snackbarHost) + if (conflictOpen && state.syncConflict != null) { + AlertDialog( + onDismissRequest = { conflictOpen = false }, + title = { Text("Sync conflict") }, + text = { Text("${state.syncConflict}\n\nChoose which version should win.") }, + confirmButton = { + Button(onClick = { + conflictOpen = false + viewModel.resolveSyncConflict(keepLocal = true) + }) { Text("Keep mine") } + }, + dismissButton = { + OutlinedButton(onClick = { + conflictOpen = false + viewModel.resolveSyncConflict(keepLocal = false) + }) { Text("Use server") } + }, + ) + } if (searchOpen && state.root != null) { SearchDialog( root = state.root!!, + nodeTypes = state.nodeTypes, + tags = state.tags, onDismiss = { searchOpen = false }, onOpen = { id -> viewModel.openBreadcrumb(id) @@ -182,13 +283,13 @@ fun TreeScreen( onCode = { code -> val node = state.root?.allNodes()?.find { it.lookupCode == code } if (node == null) { - coroutineScope.launch { snackbarHost.showSnackbar("No node has code $code") } + activity.showErrorMessage("No node has code $code") } else { viewModel.openBreadcrumb(node.id) searchOpen = false } }, - onError = { message -> coroutineScope.launch { snackbarHost.showSnackbar(message) } }, + onError = activity::showErrorMessage, ) }, ) @@ -198,6 +299,7 @@ fun TreeScreen( OpenSheet.CREATE -> current?.let { node -> QuickAddDialog( parentName = node.name, + allowImport = !state.usingCachedTree && state.pendingOperations == 0, onDismiss = { openSheet = null }, onAddOne = { name, description, quantity -> viewModel.createChild(name, description, quantity) @@ -244,27 +346,11 @@ fun TreeScreen( }, ) } - OpenSheet.ACTIONS -> current?.let { node -> - ActionSheet( - node = node, - onDismiss = { openSheet = null }, - onLabel = { openSheet = OpenSheet.LABEL }, - onFind = { - findTarget = node - openSheet = null - }, - onEdit = { openSheet = OpenSheet.EDIT }, - onMove = { openSheet = OpenSheet.MOVE }, - onDelete = { - viewModel.deleteCurrent() - openSheet = null - }, - ) - } OpenSheet.LABEL -> current?.let { node -> LabelSheet( node = node, breadcrumb = state.breadcrumbs.joinToString(" / ") { it.name }, + serverUrl = state.serverUrl, onDismiss = { openSheet = null }, ) } @@ -308,6 +394,47 @@ fun TreeScreen( }, ) } + OpenSheet.TYPE -> { + val targets = if (selectedIds.isEmpty()) current?.let { setOf(it.id) }.orEmpty() else selectedIds + val targetNodes = targets.mapNotNull { state.root?.find(it) } + ClassificationSheet( + options = state.nodeTypes, + selectedIds = targetNodes.map { it.typeId }.toSet(), + onDismiss = { openSheet = null }, + onSelect = { typeId -> viewModel.assignType(targets, typeId) }, + onCreate = { name, color -> viewModel.createType(name, color) }, + onUpdate = viewModel::updateType, + ) + } + OpenSheet.TAGS -> { + val targets = if (selectedIds.isEmpty()) current?.let { setOf(it.id) }.orEmpty() else selectedIds + val targetNodes = targets.mapNotNull { state.root?.find(it) } + TagSheet( + tags = state.tags, + nodes = targetNodes, + onDismiss = { openSheet = null }, + onToggle = { id, apply -> viewModel.toggleTag(targets, id, apply) }, + onClear = { ids -> viewModel.clearTags(targets, ids) }, + onCreate = { name, color -> viewModel.createTag(name, color) }, + onUpdate = viewModel::updateTag, + onDelete = viewModel::deleteTag, + ) + } + OpenSheet.FILTERS -> FilterSheet( + nodeTypes = state.nodeTypes, + tags = state.tags, + typeFilterId = typeFilterId, + tagFilterIds = tagFilterIds, + mode = filterMode, + onModeChange = { filterMode = it; selectedIds = emptySet() }, + onTypeChange = { typeFilterId = it; selectedIds = emptySet() }, + onTagToggle = { id -> + tagFilterIds = if (id in tagFilterIds) tagFilterIds - id else tagFilterIds + id + selectedIds = emptySet() + }, + onClear = { typeFilterId = null; tagFilterIds = emptySet(); selectedIds = emptySet() }, + onDismiss = { openSheet = null }, + ) null -> Unit } } @@ -315,6 +442,7 @@ fun TreeScreen( @Composable private fun QuickAddDialog( parentName: String, + allowImport: Boolean, onDismiss: () -> Unit, onAddOne: (String, String?, Long?) -> Unit, onAddMultiple: (List, String?) -> Unit, @@ -428,8 +556,8 @@ private fun QuickAddDialog( } Text( "Import a tree manifest", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable(onClick = onImportTree).padding(vertical = 6.dp), + color = if (allowImport) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, + modifier = Modifier.clickable(enabled = allowImport, onClick = onImportTree).padding(vertical = 6.dp), ) } } @@ -545,33 +673,35 @@ private data class SearchResult(val node: ApiNode, val location: String) @Composable private fun SearchDialog( root: ApiNode, + nodeTypes: List, + tags: List, onDismiss: () -> Unit, onOpen: (String) -> Unit, onScan: () -> Unit, ) { var query by remember { mutableStateOf("") } + var filters by remember { mutableStateOf(SearchFilters()) } + var filterMode by remember { mutableStateOf(FilterMode.AND) } + var editingFilters by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val results = remember(root, query) { - if (query.isBlank()) { - emptyList() - } else { - root.allNodes() - .asSequence() - .filter { - it.id != "root" && ( - it.name.contains(query.trim(), ignoreCase = true) || - it.lookupCode.contains(query.trim(), ignoreCase = true) - ) - } - .map { node -> - val ancestors = root.pathTo(node.id).orEmpty().dropLast(1) - SearchResult(node, ancestors.joinToString(" / ") { it.name }) - } - .sortedBy { it.node.name.lowercase() } - .toList() - } + val results = remember(root, query, filters, filterMode) { + val text = query.trim() + root.allNodes() + .asSequence() + .filter { node -> + node.id != "root" && + (text.isBlank() || node.name.contains(text, ignoreCase = true) || node.lookupCode.contains(text, ignoreCase = true)) && + filters.matches(node, filterMode) + } + .map { node -> + val ancestors = root.pathTo(node.id).orEmpty().dropLast(1) + SearchResult(node, ancestors.joinToString(" / ") { it.name }) + } + .sortedBy { it.node.name.lowercase() } + .let { if (text.isBlank() && filters.count == 0) it.take(12) else it } + .toList() } LaunchedEffect(Unit) { @@ -597,11 +727,25 @@ private fun SearchDialog( singleLine = true, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), ) - OutlinedButton(onClick = onScan, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Text("Scan QR code") + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { editingFilters = !editingFilters }, modifier = Modifier.weight(1f)) { + FilterIcon(Modifier.size(18.dp)) + Text(if (filters.count == 0) "Filter" else "Filter · ${filters.count}", modifier = Modifier.padding(start = 7.dp)) + } + OutlinedButton(onClick = onScan, modifier = Modifier.weight(1f)) { Text("Scan QR code") } } - when { - query.isBlank() -> Unit + if (editingFilters) { + SearchFilterEditor( + filters = filters, + mode = filterMode, + nodeTypes = nodeTypes, + tags = tags, + modifier = Modifier.weight(1f), + onChange = { filters = it }, + onModeChange = { filterMode = it }, + onDone = { editingFilters = false }, + ) + } else when { results.isEmpty() -> Text( "No results", color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -612,23 +756,21 @@ private fun SearchDialog( contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp), ) { items(results, key = { it.node.id }) { result -> - Column( + val nodeType = nodeTypes.find { it.id == result.node.typeId } + Row( modifier = Modifier .fillMaxWidth() .clickable { onOpen(result.node.id) } .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - result.node.name, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - "${result.location} · ${result.node.lookupCode}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + Surface(Modifier.size(40.dp), shape = RoundedCornerShape(8.dp), color = nodeType?.let { metadataColor(it.color).copy(alpha = .16f) } ?: MaterialTheme.colorScheme.surfaceVariant) { + Box(contentAlignment = Alignment.Center) { if (nodeType == null) Text(result.node.name.take(1).uppercase(), fontWeight = FontWeight.SemiBold) else NodeTypeIcon(nodeType, Modifier.size(22.dp)) } + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(result.node.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text(result.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } } } @@ -637,19 +779,131 @@ private fun SearchDialog( } } +@Composable +private fun SearchFilterEditor( + filters: SearchFilters, + mode: FilterMode, + nodeTypes: List, + tags: List, + modifier: Modifier = Modifier, + onChange: (SearchFilters) -> Unit, + onModeChange: (FilterMode) -> Unit, + onDone: () -> Unit, +) { + LazyColumn(modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 10.dp)) { + item { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (filters.count > 0) TextButton(onClick = { onChange(SearchFilters()) }) { Text("Clear") } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDone) { Text("Done") } + } + } + if (filters.count > 1) item { FilterModeToggle(mode, onModeChange) } + item { + OutlinedTextField(filters.name, { onChange(filters.copy(name = it)) }, label = { Text("Name contains") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp)) + OutlinedTextField(filters.description, { onChange(filters.copy(description = it)) }, label = { Text("Description contains") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp)) + SearchFilterChoice( + label = "Type", + selected = filters.typeId ?: "", + choices = listOf("" to "Any type", NO_TYPE_FILTER to "No type") + nodeTypes.map { it.id to it.name }, + onSelect = { onChange(filters.copy(typeId = it.ifEmpty { null })) }, + ) + SearchFilterChoice( + label = "Quantity", + selected = filters.quantity?.name.orEmpty(), + choices = listOf("" to "Any quantity", QuantityFilter.SET.name to "is set", QuantityFilter.NOT_SET.name to "is not set", QuantityFilter.ZERO.name to "is 0", QuantityFilter.ONE.name to "is 1"), + onSelect = { value -> onChange(filters.copy(quantity = value.takeIf(String::isNotEmpty)?.let(QuantityFilter::valueOf))) }, + ) + SearchTagFilterChoice( + tags = tags, + selectedIds = filters.tagIds, + onToggle = { id -> onChange(filters.copy(tagIds = if (id in filters.tagIds) filters.tagIds - id else filters.tagIds + id)) }, + ) + } + } +} + +@Composable +private fun SearchFilterChoice(label: String, selected: String, choices: List>, onSelect: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp)) { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(choices.find { it.first == selected }?.second.orEmpty()) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + choices.forEach { choice -> + DropdownMenuItem( + text = { Text(choice.second) }, + trailingIcon = { if (choice.first == selected) MetadataSelectionIndicator(1, mixed = false) }, + onClick = { onSelect(choice.first); expanded = false }, + ) + } + } + } +} + +@Composable +private fun SearchTagFilterChoice(tags: List, selectedIds: Set, onToggle: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val summary = when (selectedIds.size) { + 0 -> "Any tag" + 1 -> tags.find { it.id in selectedIds }?.name.orEmpty() + else -> "${selectedIds.size} tags" + } + Box(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp)) { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text("Tags", modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(summary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (tags.isEmpty()) { + DropdownMenuItem(text = { Text("No tags available") }, enabled = false, onClick = {}) + } else { + tags.forEach { tag -> + DropdownMenuItem( + leadingIcon = { + val color = metadataColor(tag.color) + Surface(Modifier.size(10.dp), color = color, shape = CircleShape) {} + }, + text = { Text(tag.name) }, + trailingIcon = { if (tag.id in selectedIds) MetadataSelectionIndicator(1, mixed = false) }, + onClick = { onToggle(tag.id) }, + ) + } + } + } + } +} + @Composable private fun NodeTopBar( - canGoBack: Boolean, protectedNode: Boolean, - onBack: () -> Unit, - onRefresh: () -> Unit, - onActions: () -> Unit, + onSettings: () -> Unit, + currentName: String, + currentCanMove: Boolean, + currentCanDelete: Boolean, + currentPending: Boolean, + onEditCurrent: () -> Unit, + onMoveCurrent: () -> Unit, + onTypeCurrent: () -> Unit, + onTagsCurrent: () -> Unit, + onLabelCurrent: () -> Unit, + onFindCurrent: () -> Unit, + onDeleteCurrent: () -> Unit, selectedCount: Int, + canSelectAll: Boolean, selectionCanDelete: Boolean, onCancelSelection: () -> Unit, + onSelectAll: () -> Unit, onMoveSelection: () -> Unit, onDeleteSelection: () -> Unit, + onTypeSelection: () -> Unit, + onTagSelection: () -> Unit, + readOnly: Boolean, ) { + var selectionMenuOpen by remember { mutableStateOf(false) } + var currentMenuOpen by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -657,39 +911,342 @@ private fun NodeTopBar( if (selectedCount > 0) { IconButton(onClick = onCancelSelection) { CloseIcon(Modifier.size(19.dp)) } Text("$selectedCount selected") + Text( + "Select all", + color = if (canSelectAll) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable(enabled = canSelectAll, onClick = onSelectAll).padding(10.dp), + ) } else { - if (canGoBack) { - IconButton(onClick = onBack) { BackIcon(Modifier.size(24.dp)) } - } else { - Spacer(Modifier.size(48.dp)) + IconButton(onClick = onSettings) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant) { + Box(Modifier.size(32.dp), contentAlignment = Alignment.Center) { + AppMenuIcon(Modifier.size(20.dp)) + } + } } } Spacer(modifier = Modifier.weight(1f)) if (selectedCount > 0) { - Text("Move", modifier = Modifier.clickable(onClick = onMoveSelection).padding(10.dp)) - Text( - "Delete", - color = if (selectionCanDelete) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable(enabled = selectionCanDelete, onClick = onDeleteSelection).padding(10.dp), - ) + Box { + IconButton(onClick = { selectionMenuOpen = true }) { MoreIcon(Modifier.size(24.dp), "Selected node actions") } + NodeActionsMenu( + title = "$selectedCount selected", + expanded = selectionMenuOpen, + onDismiss = { selectionMenuOpen = false }, + enabled = !readOnly, + canDelete = selectionCanDelete && !readOnly, + onMove = onMoveSelection, + onType = onTypeSelection, + onTags = onTagSelection, + onDelete = onDeleteSelection, + ) + } } else { - Text("Refresh", modifier = Modifier.clickable(onClick = onRefresh).padding(12.dp)) if (!protectedNode) { - IconButton(onClick = onActions) { MoreIcon(Modifier.size(24.dp)) } + Box { + IconButton(onClick = { currentMenuOpen = true }) { MoreIcon(Modifier.size(24.dp)) } + NodeActionsMenu( + title = currentName, + expanded = currentMenuOpen, + onDismiss = { currentMenuOpen = false }, + enabled = !readOnly && !currentPending, + canMove = currentCanMove && !readOnly && !currentPending, + canDelete = currentCanDelete && !readOnly && !currentPending, + onEdit = onEditCurrent, + onMove = onMoveCurrent, + onType = onTypeCurrent, + onTags = onTagsCurrent, + onLabel = onLabelCurrent, + onFind = onFindCurrent, + onDelete = onDeleteCurrent, + ) + } } } } } +@Composable +private fun NodeActionsMenu( + title: String, + expanded: Boolean, + onDismiss: () -> Unit, + enabled: Boolean, + canDelete: Boolean, + canMove: Boolean = enabled, + onEdit: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onType: (() -> Unit)? = null, + onTags: (() -> Unit)? = null, + onLabel: (() -> Unit)? = null, + onFind: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, +) { + fun run(action: () -> Unit) { onDismiss(); action() } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, modifier = Modifier.width(224.dp)) { + Text( + title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + ) + onEdit?.let { action -> NodeActionItem("Edit", app.boxmanifest.R.drawable.ic_action_edit, enabled) { run(action) } } + onMove?.let { action -> NodeActionItem("Move", app.boxmanifest.R.drawable.ic_action_move, canMove) { run(action) } } + if ((onEdit != null || onMove != null) && (onType != null || onTags != null)) MenuDivider() + onType?.let { action -> NodeActionItem("Edit type", app.boxmanifest.R.drawable.type_icon_shapes, enabled) { run(action) } } + onTags?.let { action -> NodeActionItem("Edit tags", app.boxmanifest.R.drawable.type_icon_pricetags, enabled) { run(action) } } + if ((onLabel != null || onFind != null) && (onType != null || onTags != null)) MenuDivider() + onLabel?.let { action -> NodeActionItem("Create label", app.boxmanifest.R.drawable.ic_action_label, enabled) { run(action) } } + onFind?.let { action -> NodeActionItem("Find", app.boxmanifest.R.drawable.ic_action_find, enabled) { run(action) } } + if (onDelete != null) { + MenuDivider() + DropdownMenuItem( + text = { Text("Delete", style = MaterialTheme.typography.bodyMedium, color = if (canDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)) }, + leadingIcon = { Icon(painterResource(app.boxmanifest.R.drawable.ic_action_delete), null, Modifier.size(20.dp), tint = if (canDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)) }, + enabled = canDelete, + onClick = { run(onDelete) }, + modifier = Modifier.height(44.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) + } + } +} + +@Composable +private fun NodeActionItem(label: String, icon: Int, enabled: Boolean, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(label, style = MaterialTheme.typography.bodyMedium) }, + leadingIcon = { Icon(painterResource(icon), null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + enabled = enabled, + onClick = onClick, + modifier = Modifier.height(44.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) +} + +@Composable +private fun MenuDivider() { + HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 5.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = .7f)) +} + +@Composable +private fun SyncBanner(offline: Boolean, retrying: Boolean, pending: Int, conflict: String?, onConflict: () -> Unit, onRetry: () -> Unit) { + Surface(color = MaterialTheme.colorScheme.secondaryContainer) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + when { + conflict != null -> "Sync conflict · $conflict" + pending > 0 && offline -> "Offline · $pending change${if (pending == 1) "" else "s"} pending" + pending > 0 -> "Syncing $pending change${if (pending == 1) "" else "s"}…" + else -> "Offline · showing saved inventory" + }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + Text( + if (conflict != null) "Review" else if (retrying) "Checking…" else "Retry", + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable(enabled = !retrying, onClick = if (conflict != null) onConflict else onRetry).padding(6.dp), + ) + } + } +} + +@Composable +private fun SettingsScreen( + initialUrl: String, + serverName: String?, + serverId: String?, + busy: Boolean, + status: ServerTestStatus, + message: String?, + onBack: () -> Unit, + onTest: (String) -> Unit, + onSave: (String) -> Unit, + onClearStatus: () -> Unit, + modifier: Modifier = Modifier, +) { + var url by remember(initialUrl) { mutableStateOf(initialUrl) } + var page by remember { mutableStateOf(SettingsPage.INDEX) } + + BackHandler(enabled = page != SettingsPage.INDEX) { page = SettingsPage.INDEX } + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { if (page == SettingsPage.INDEX) onBack() else page = SettingsPage.INDEX }) { + BackIcon(Modifier.size(24.dp)) + } + Text(page.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + HorizontalDivider() + when (page) { + SettingsPage.INDEX -> Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier.fillMaxWidth().height(52.dp), + contentAlignment = Alignment.BottomStart, + ) { + Text( + "Application", + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SettingsNavigationRow(title = "Server URL", onClick = { page = SettingsPage.SERVER }) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + SettingsPage.SERVER -> Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = url, + onValueChange = { + url = it + onClearStatus() + }, + label = { Text("Server URL") }, + placeholder = { Text("http://server:8080") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + serverName?.let { Text(it, fontWeight = FontWeight.Bold) } + serverId?.let { + Text( + "Server ID: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { if (!busy) onTest(url) }, + enabled = url.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("Test") } + Button( + onClick = { if (!busy) onSave(url) }, + enabled = url.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("Save") } + } + ServerConnectionFeedback(status = status, message = message) + } + } + } +} + +@Composable +private fun ServerConnectionFeedback(status: ServerTestStatus, message: String?) { + if (status == ServerTestStatus.IDLE) return + val failed = status == ServerTestStatus.ERROR + val working = status == ServerTestStatus.TESTING || status == ServerTestStatus.SAVING + val darkTheme = isSystemInDarkTheme() + val foreground = when { + failed && darkTheme -> Color(0xFFFF9D7D) + failed -> Color(0xFFE9410B) + working -> Color(0xFF5DD400) + darkTheme -> Color(0xFFACDE83) + else -> Color(0xFF4DAE00) + } + val background = when { + failed && darkTheme -> Color(0xFF2D1D18) + failed -> Color(0xFFFEE7E0) + working -> MaterialTheme.colorScheme.surfaceVariant + darkTheme -> Color(0xFF202919) + else -> Color(0xFFE5F8D6) + } + val title = when (status) { + ServerTestStatus.TESTING -> "Testing connection" + ServerTestStatus.SAVING -> "Saving server" + ServerTestStatus.SUCCESS -> "Connection successful" + ServerTestStatus.ERROR -> "Couldn’t connect" + ServerTestStatus.IDLE -> return + } + + Surface( + color = background, + contentColor = foreground, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (working) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = foreground, + ) + } else { + ConnectionStatusIcon(success = !failed, color = foreground, modifier = Modifier.size(20.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + message?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = foreground.copy(alpha = 0.76f)) + } + } + } + } +} + +@Composable +private fun ConnectionStatusIcon(success: Boolean, color: Color, modifier: Modifier = Modifier) { + Canvas(modifier) { + val stroke = Stroke(width = size.minDimension * 0.09f, cap = StrokeCap.Round) + drawCircle(color = color, style = stroke) + if (success) { + drawLine(color, start = center.copy(x = size.width * 0.28f, y = size.height * 0.52f), end = center.copy(x = size.width * 0.44f, y = size.height * 0.68f), strokeWidth = stroke.width, cap = StrokeCap.Round) + drawLine(color, start = center.copy(x = size.width * 0.44f, y = size.height * 0.68f), end = center.copy(x = size.width * 0.74f, y = size.height * 0.35f), strokeWidth = stroke.width, cap = StrokeCap.Round) + } else { + drawLine(color, start = center.copy(y = size.height * 0.28f), end = center.copy(y = size.height * 0.58f), strokeWidth = stroke.width, cap = StrokeCap.Round) + drawCircle(color, radius = stroke.width / 2, center = center.copy(y = size.height * 0.73f)) + } + } +} + +@Composable +private fun SettingsNavigationRow(title: String, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(52.dp).clickable(onClick = onClick).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = androidx.compose.ui.res.painterResource(app.boxmanifest.R.drawable.ic_server_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text(title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 10.dp).weight(1f)) + RightChevronIcon(Modifier.size(18.dp)) + } +} + @Composable private fun NodeScreen( node: ApiNode, + children: List, breadcrumbs: List, onNavigateBreadcrumb: (String) -> Unit, onOpenChild: (String) -> Unit, selectedIds: Set, onToggleSelection: (String) -> Unit, - onStartSelection: (String) -> Unit, + onStartSelection: ((String) -> Unit)?, + nodeTypes: List, + tags: List, + activeFilterCount: Int, + onFilters: () -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -704,26 +1261,34 @@ private fun NodeScreen( ) node.description?.let { PropertyRow("Description", it) } node.quantity?.let { PropertyRow("Quantity", it.toString()) } + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = onFilters, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)) { + FilterIcon(Modifier.size(18.dp)) + Text(if (activeFilterCount == 0) "Filter" else "Filter · $activeFilterCount", modifier = Modifier.padding(start = 7.dp)) + } + } } - if (node.children.isEmpty()) { + if (children.isEmpty()) { item { Text( - "Nothing here yet", + if (activeFilterCount == 0) "Nothing here yet" else "No nodes match these filters", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 24.dp), ) } } else { - items(node.children, key = { it.id }) { child -> + items(children, key = { it.id }) { child -> NodeRow( node = child, onClick = { if (selectedIds.isEmpty()) onOpenChild(child.id) else if (child.id != "unsorted") onToggleSelection(child.id) }, - onLongClick = if (child.id == "root" || child.id == "unsorted") null else { + onLongClick = if (child.id == "root" || child.id == "unsorted" || onStartSelection == null) null else { { onStartSelection(child.id) } }, selected = child.id in selectedIds, + nodeType = nodeTypes.find { it.id == child.typeId }, + tags = tags.filter { it.id in child.tagIds }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp), ) } @@ -783,43 +1348,15 @@ private fun NodeEditorSheet( } } -@Composable -private fun ActionSheet( - node: ApiNode, - onDismiss: () -> Unit, - onLabel: () -> Unit, - onFind: () -> Unit, - onEdit: () -> Unit, - onMove: () -> Unit, - onDelete: () -> Unit, -) { - ModalBottomSheet(onDismissRequest = onDismiss) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(node.name, style = MaterialTheme.typography.titleLarge) - OutlinedButton(onClick = onLabel, modifier = Modifier.fillMaxWidth()) { Text("Label") } - OutlinedButton(onClick = onFind, modifier = Modifier.fillMaxWidth()) { Text("Find") } - OutlinedButton(onClick = onEdit, modifier = Modifier.fillMaxWidth()) { Text("Edit") } - OutlinedButton(onClick = onMove, modifier = Modifier.fillMaxWidth()) { Text("Move") } - OutlinedButton( - onClick = onDelete, - enabled = node.children.isEmpty(), - modifier = Modifier.fillMaxWidth(), - ) { Text(if (node.children.isEmpty()) "Delete" else "Delete (must be empty)") } - } - } -} - @Composable private fun LabelSheet( node: ApiNode, breadcrumb: String, + serverUrl: String, onDismiss: () -> Unit, ) { val context = LocalContext.current - val canonicalUrl = remember(node.lookupCode) { BoxManifestApi().canonicalNodeUrl(node.lookupCode) } + val canonicalUrl = remember(node.lookupCode, serverUrl) { "$serverUrl/n/${node.lookupCode}" } val bitmap = remember(node.name, breadcrumb, node.lookupCode) { createLabelBitmap(node.name, breadcrumb, node.lookupCode, canonicalUrl) } @@ -843,6 +1380,360 @@ private fun LabelSheet( } } +@Composable +private fun FilterSheet( + nodeTypes: List, + tags: List, + typeFilterId: String?, + tagFilterIds: Set, + mode: FilterMode, + onModeChange: (FilterMode) -> Unit, + onTypeChange: (String?) -> Unit, + onTagToggle: (String) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + val hasFilters = typeFilterId != null || tagFilterIds.isNotEmpty() + ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.fillMaxWidth().padding(bottom = 20.dp)) { + Box(Modifier.fillMaxWidth().height(48.dp), contentAlignment = Alignment.Center) { + if (hasFilters) TextButton(onClick = onClear, modifier = Modifier.align(Alignment.CenterStart).padding(start = 8.dp)) { Text("Clear") } + Text("Filters", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 8.dp)) { Text("Done") } + } + HorizontalDivider() + LazyColumn(Modifier.fillMaxWidth().height(420.dp)) { + if ((if (typeFilterId == null) 0 else 1) + tagFilterIds.size > 1) { + item { FilterModeToggle(mode, onModeChange) } + } + item { TagSectionLabel("Type") } + item { FilterValueRow("All types", typeFilterId == null) { onTypeChange(null) } } + item { FilterValueRow("No type", typeFilterId == NO_TYPE_FILTER) { onTypeChange(NO_TYPE_FILTER) } } + items(nodeTypes, key = { "filter-type-${it.id}" }) { type -> + FilterValueRow(type.name, typeFilterId == type.id, leading = { + Surface(Modifier.size(28.dp), shape = RoundedCornerShape(6.dp), color = metadataColor(type.color).copy(alpha = .16f)) { + Box(contentAlignment = Alignment.Center) { NodeTypeIcon(type, Modifier.size(17.dp)) } + } + }) { onTypeChange(type.id) } + } + if (tags.isNotEmpty()) { + item { TagSectionLabel("Tags") } + items(tags, key = { "filter-tag-${it.id}" }) { tag -> + FilterValueRow(tag.name, tag.id in tagFilterIds, leading = { + val color = metadataColor(tag.color) + Surface(color = color.copy(alpha = .16f), contentColor = color, shape = RoundedCornerShape(3.dp)) { + Text(tag.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } + }, showLabel = false) { onTagToggle(tag.id) } + } + } + } + } + } +} + +@Composable +private fun FilterModeToggle(mode: FilterMode, onChange: (FilterMode) -> Unit) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Match", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterMode.entries.forEach { option -> + Surface( + color = if (mode == option) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + contentColor = if (mode == option) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.clickable { onChange(option) }, + ) { + Text(option.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp)) + } + } + } + } +} + +@Composable +private fun FilterValueRow( + label: String, + selected: Boolean, + leading: (@Composable () -> Unit)? = null, + showLabel: Boolean = true, + onClick: () -> Unit, +) { + Row(Modifier.fillMaxWidth().height(48.dp).clickable(onClick = onClick).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + leading?.invoke() + if (showLabel) Text(label, modifier = Modifier.weight(1f).padding(start = if (leading == null) 0.dp else 10.dp)) else Spacer(Modifier.weight(1f)) + MetadataSelectionIndicator(if (selected) 1 else null, mixed = false) + } +} + +@Composable +private fun ClassificationSheet( + options: List, + selectedIds: Set, + onDismiss: () -> Unit, + onSelect: (String?) -> Unit, + onCreate: (String, String) -> Unit, + onUpdate: (NodeType) -> Unit, +) { + var query by remember { mutableStateOf("") } + var creating by remember { mutableStateOf(false) } + var editing by remember { mutableStateOf(null) } + val selectedTypeIds = selectedIds.filterNotNull().toSet() + ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) { + if (creating || editing != null) { + MetadataEditor( + title = if (editing == null) "New type" else "Edit type", + fieldName = "Type name", + initialColor = editing?.color ?: typePalette.first(), + palette = typePalette, + initialName = query.trim(), + existingName = editing?.name, + onBack = { creating = false; editing = null }, + onSave = { name, color -> + editing?.let { onUpdate(it.copy(name = name, color = color)) } ?: onCreate(name, color) + creating = false; editing = null; query = "" + }, + ) + } else { + MetadataPicker( + title = "Type", + searchHint = "Search types", + emptyMessage = "No types", + options = options, + selectedIds = selectedTypeIds, + query = query, + onQueryChange = { query = it }, + onClear = if (selectedTypeIds.isNotEmpty()) ({ onSelect(null) }) else null, + onCreate = { creating = true }, + leadingRow = { + NoTypeRow( + selected = null in selectedIds, + mixed = selectedIds.size > 1, + onClick = { onSelect(null) }, + ) + }, + ) { type, selectionNumber -> + TypeOptionRow(type, selectionNumber, mixed = selectedIds.size > 1, onClick = { onSelect(type.id) }, onEdit = { editing = type }) + } + } + } +} + +@Composable +private fun TypeOptionRow(type: NodeType, selectionNumber: Int?, mixed: Boolean, onClick: () -> Unit, onEdit: () -> Unit) { + val color = metadataColor(type.color) + Row(Modifier.fillMaxWidth().height(52.dp).clickable(onClick = onClick).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(Modifier.size(30.dp), shape = RoundedCornerShape(6.dp), color = color.copy(alpha = .16f)) { + Box(contentAlignment = Alignment.Center) { NodeTypeIcon(type, Modifier.size(18.dp)) } + } + Text(type.name, modifier = Modifier.weight(1f).padding(start = 10.dp)) + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { EditIcon(Modifier.size(18.dp)) } + MetadataSelectionIndicator(selectionNumber, mixed) + } +} + +private val typePalette = listOf(DEFAULT_TYPE_COLOR, "#dcc000", "#f08a00", "#f04520", "#f030a0", "#a840f0", "#4a60f0", "#2aa7ee", "#00bba7", "#5ec400") +private val tagPalette = listOf(DEFAULT_TAG_COLOR, "#b2a616", "#d3720d", "#e2400c", "#ca1b8e", "#9e30c4", "#4f6af0", "#1c8bca", "#0caaa3", "#64b90f") + +@Composable +private fun TagSheet( + tags: List, + nodes: List, + onDismiss: () -> Unit, + onToggle: (String, Boolean) -> Unit, + onClear: (List) -> Unit, + onCreate: (String, String) -> Unit, + onUpdate: (Tag) -> Unit, + onDelete: (String) -> Unit, +) { + var query by remember { mutableStateOf("") } + var editing by remember { mutableStateOf(null) } + var creating by remember { mutableStateOf(false) } + val selected = tags.filter { tag -> nodes.any { tag.id in it.tagIds } } + val selectedIds = selected.mapTo(mutableSetOf()) { it.id } + + ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) { + if (creating || editing != null) { + MetadataEditor( + title = if (editing == null) "New tag" else "Edit tag", + fieldName = "Tag name", + initialColor = editing?.color ?: tagPalette.first(), + palette = tagPalette, + initialName = if (creating) query.trim() else "", + existingName = editing?.name, + onBack = { creating = false; editing = null }, + onSave = { name, color -> + editing?.let { onUpdate(it.copy(name = name, color = color)) } ?: onCreate(name, color) + creating = false + editing = null + query = "" + }, + onDelete = editing?.let { tag -> { onDelete(tag.id); editing = null } }, + ) + } else { + MetadataPicker( + title = "Tags", + searchHint = "Search tags", + emptyMessage = "No tags", + options = tags, + selectedIds = selectedIds, + query = query, + onQueryChange = { query = it }, + onClear = if (selected.isNotEmpty()) ({ onClear(selected.map { it.id }) }) else null, + onCreate = { creating = true }, + ) { tag, selectionNumber -> + val all = nodes.isNotEmpty() && nodes.all { tag.id in it.tagIds } + TagOptionRow(tag, selectionNumber, mixed = selectionNumber != null && !all, onClick = { onToggle(tag.id, selectionNumber == null) }, onEdit = { editing = tag }) + } + } + } +} + +@Composable +private fun MetadataPicker( + title: String, + searchHint: String, + emptyMessage: String, + options: List, + selectedIds: Set, + query: String, + onQueryChange: (String) -> Unit, + onClear: (() -> Unit)?, + onCreate: () -> Unit, + leadingRow: (@Composable () -> Unit)? = null, + optionRow: @Composable (T, Int?) -> Unit, +) { + val filtered = options.filter { it.name.contains(query, ignoreCase = true) } + val selected = filtered.filter { it.id in selectedIds } + val available = filtered.filterNot { it.id in selectedIds } + val canCreate = query.isNotBlank() && options.none { it.name.equals(query.trim(), ignoreCase = true) } + Column(Modifier.fillMaxWidth().padding(bottom = 20.dp)) { + MetadataPickerHeader(title, searchHint, query, onQueryChange, onClear, onCreate) + LazyColumn(Modifier.fillMaxWidth().height(340.dp)) { + leadingRow?.let { row -> item { row() } } + if (selected.isNotEmpty()) { + item { TagSectionLabel("Selected") } + items(selected, key = { "selected-${it.id}" }) { option -> optionRow(option, selected.indexOf(option) + 1) } + } + if (available.isNotEmpty()) { + item { TagSectionLabel("All values") } + items(available, key = { "available-${it.id}" }) { option -> optionRow(option, null) } + } + if (canCreate) item { CreateMetadataRow(query, onCreate) } + if (filtered.isEmpty() && !canCreate) item { Text(emptyMessage, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(20.dp)) } + } + } +} + +@Composable +private fun NoTypeRow(selected: Boolean, mixed: Boolean, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().height(52.dp).clickable(onClick = onClick).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(Modifier.size(30.dp), shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant) { + Box(contentAlignment = Alignment.Center) { Text("—", color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + Text("No type", modifier = Modifier.weight(1f).padding(start = 10.dp)) + MetadataSelectionIndicator(if (selected) 1 else null, mixed) + } +} + +@Composable +private fun TagSectionLabel(text: String) { + Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 4.dp)) +} + +@Composable +private fun MetadataPickerHeader(title: String, searchHint: String, query: String, onQueryChange: (String) -> Unit, onClear: (() -> Unit)?, onCreate: () -> Unit) { + Box(Modifier.fillMaxWidth().height(48.dp), contentAlignment = Alignment.Center) { + onClear?.let { TextButton(onClick = it, modifier = Modifier.align(Alignment.CenterStart).padding(start = 8.dp)) { Text("Clear") } } + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + IconButton(onClick = onCreate, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 8.dp)) { Text("+", style = MaterialTheme.typography.headlineSmall) } + } + OutlinedTextField(query, onQueryChange, placeholder = { Text(searchHint) }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) + HorizontalDivider() +} + +@Composable +private fun CreateMetadataRow(query: String, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().height(52.dp).clickable(onClick = onClick).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Text("+", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(end = 12.dp)) + Text("Create “${query.trim()}”") + } +} + +@Composable +private fun TagOptionRow(tag: Tag, selectionNumber: Int?, mixed: Boolean, onClick: () -> Unit, onEdit: () -> Unit) { + val color = metadataColor(tag.color) + Row(Modifier.fillMaxWidth().height(52.dp).clickable(onClick = onClick).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(color = color.copy(alpha = .16f), contentColor = color, shape = RoundedCornerShape(3.dp)) { + Text(tag.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { EditIcon(Modifier.size(18.dp)) } + MetadataSelectionIndicator(selectionNumber, mixed) + } +} + +@Composable +private fun MetadataSelectionIndicator(selectionNumber: Int?, mixed: Boolean) { + val selected = selectionNumber != null + Surface( + Modifier.size(24.dp), + shape = CircleShape, + color = if (selected) Color(0xff2f80ed) else Color.Transparent, + border = if (selected) null else androidx.compose.foundation.BorderStroke(1.5.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + if (selected) Box(contentAlignment = Alignment.Center) { + Text(if (mixed) "–" else selectionNumber.toString(), color = Color.White, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun MetadataEditor( + title: String, + fieldName: String, + initialName: String, + existingName: String? = null, + initialColor: String, + palette: List, + onBack: () -> Unit, + onSave: (String, String) -> Unit, + onDelete: (() -> Unit)? = null, +) { + var name by remember(existingName, initialName) { mutableStateOf(existingName ?: initialName) } + var color by remember(initialColor) { mutableStateOf(initialColor) } + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box(Modifier.fillMaxWidth().height(48.dp), contentAlignment = Alignment.Center) { + IconButton(onClick = onBack, modifier = Modifier.align(Alignment.CenterStart)) { BackIcon(Modifier.size(20.dp)) } + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + OutlinedTextField(name, { name = it }, placeholder = { Text(fieldName) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Text("Color", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + palette.forEach { option -> + val optionColor = metadataColor(option) + Surface( + Modifier.size(if (color == option) 28.dp else 24.dp).clickable { color = option }, + shape = CircleShape, + color = optionColor, + border = if (color == option) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.onSurface) else null, + ) {} + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onDelete != null) TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) } + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = onBack) { Text("Cancel") } + Button(onClick = { onSave(name.trim(), color) }, enabled = name.isNotBlank()) { Text(if (existingName == null) "Create" else "Save") } + } + } +} + +private fun metadataColor(value: String?): Color = value?.let { runCatching { Color(android.graphics.Color.parseColor(it)) }.getOrNull() } ?: Color(android.graphics.Color.parseColor(DEFAULT_TAG_COLOR)) + @Composable private fun MoveSheet( node: ApiNode, diff --git a/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt b/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt index 807608f..cb67f04 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt @@ -2,6 +2,9 @@ package app.boxmanifest.tree import app.boxmanifest.ApiNode import app.boxmanifest.TreeImportPreview +import app.boxmanifest.NodeType +import app.boxmanifest.Tag +import app.boxmanifest.data.DEFAULT_SERVER_URL data class TreeUiState( val root: ApiNode? = null, @@ -10,6 +13,17 @@ data class TreeUiState( val loading: Boolean = false, val error: String? = null, val importPreview: TreeImportPreview? = null, + val usingCachedTree: Boolean = false, + val serverUrl: String = DEFAULT_SERVER_URL, + val serverId: String? = null, + val serverName: String? = null, + val serverSettingsBusy: Boolean = false, + val serverTestStatus: ServerTestStatus = ServerTestStatus.IDLE, + val serverTestMessage: String? = null, + val pendingOperations: Int = 0, + val syncConflict: String? = null, + val nodeTypes: List = emptyList(), + val tags: List = emptyList(), ) { val currentNode: ApiNode? get() = root?.find(currentNodeId) @@ -21,6 +35,8 @@ data class TreeUiState( get() = root?.pathTo(currentNodeId).orEmpty() } +enum class ServerTestStatus { IDLE, TESTING, SAVING, SUCCESS, ERROR } + fun ApiNode.find(id: String): ApiNode? { if (this.id == id) return this return children.firstNotNullOfOrNull { it.find(id) } diff --git a/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt index 26de6c2..baec914 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt @@ -1,27 +1,169 @@ package app.boxmanifest.tree +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import app.boxmanifest.ChildHandling +import app.boxmanifest.NodeType +import app.boxmanifest.Tag import app.boxmanifest.data.NodeRepository +import app.boxmanifest.data.ConnectivityMonitor +import app.boxmanifest.data.LocalInventoryDatabase +import app.boxmanifest.data.ServerConfig +import app.boxmanifest.data.ServerSettings +import app.boxmanifest.data.TreeLoadResult +import app.boxmanifest.data.normalizeServerUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class TreeViewModel( private val repository: NodeRepository, + private val serverSettings: ServerSettings, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(TreeUiState()) val state: StateFlow = _state.asStateFlow() init { - refresh() + viewModelScope.launch { + val config = serverSettings.load() + repository.setServerUrl(config.url) + _state.update { + it.copy(serverUrl = config.url, serverId = config.serverId, serverName = config.serverName) + } + refresh() + connectivityMonitor.connected.drop(1).collectLatest { connected -> + if (connected) refresh() + } + } + viewModelScope.launch { + state.map { Triple(it.pendingOperations, it.syncConflict, it.usingCachedTree) } + .distinctUntilChanged() + .collectLatest { (pending, conflict, cached) -> + if (conflict == null && (pending > 0 || cached)) { + while (true) { + delay(15_000) + refresh() + } + } + } + } } - fun refresh() = perform { repository.loadTree() } + fun testServerUrl(value: String) { + viewModelScope.launch { + _state.update { + it.copy( + serverSettingsBusy = true, + serverTestStatus = ServerTestStatus.TESTING, + serverTestMessage = null, + ) + } + runCatching { + val url = normalizeServerUrl(value) + repository.testServer(url) + }.onSuccess { info -> + _state.update { + it.copy( + serverSettingsBusy = false, + serverTestStatus = ServerTestStatus.SUCCESS, + serverTestMessage = "Connected to ${info.name} (API ${info.apiVersion})", + ) + } + }.onFailure { failure -> + _state.update { + it.copy( + serverSettingsBusy = false, + serverTestStatus = ServerTestStatus.ERROR, + serverTestMessage = failure.message ?: "Connection failed", + ) + } + } + } + } + + fun saveServerUrl(value: String) { + viewModelScope.launch { + _state.update { + it.copy( + serverSettingsBusy = true, + serverTestStatus = ServerTestStatus.SAVING, + serverTestMessage = null, + ) + } + runCatching { + val url = normalizeServerUrl(value) + val info = repository.testServer(url) + val previousId = _state.value.serverId + if (previousId != null && previousId != info.serverId) repository.clearCache() + serverSettings.save(ServerConfig(url, info.serverId, info.name)) + repository.setServerUrl(url) + Triple(url, info.serverId, info.name) + }.onSuccess { (url, id, name) -> + _state.update { + it.copy( + serverUrl = url, + serverId = id, + serverName = name, + serverSettingsBusy = false, + serverTestStatus = ServerTestStatus.SUCCESS, + serverTestMessage = "Server saved and connected", + ) + } + refresh() + }.onFailure { failure -> + _state.update { + it.copy( + serverSettingsBusy = false, + serverTestStatus = ServerTestStatus.ERROR, + serverTestMessage = failure.message ?: "Connection failed", + ) + } + } + } + } + + fun clearServerTestStatus() { + _state.update { + it.copy(serverTestStatus = ServerTestStatus.IDLE, serverTestMessage = null) + } + } + + fun refresh(showCacheFailure: Boolean = false) { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + runCatching { repository.loadTree() } + .onSuccess { result -> + val metadata = if (result.fromCache) null else runCatching { repository.nodeTypes() to repository.tags() }.getOrNull() + _state.update { previous -> + val validTarget = result.root.find(previous.currentNodeId)?.id ?: result.root.id + previous.copy( + root = result.root, + currentNodeId = validTarget, + loading = false, + usingCachedTree = result.fromCache, + pendingOperations = result.pendingOperations, + syncConflict = result.syncConflict, + error = if (result.fromCache && showCacheFailure) "Server is still unavailable" else null, + nodeTypes = metadata?.first ?: previous.nodeTypes, + tags = metadata?.second ?: previous.tags, + ) + } + } + .onFailure { failure -> + _state.update { it.copy(loading = false, error = failure.message ?: "Request failed") } + } + } + } fun openNode(id: String) { val current = _state.value @@ -51,14 +193,12 @@ class TreeViewModel( return true } - fun createChild(name: String, description: String?, quantity: Long?) = perform { + fun createChild(name: String, description: String?, quantity: Long?) = performLoad { repository.createChild(_state.value.currentNodeId, name, description, quantity) - repository.loadTree() } - fun createChildren(names: List, description: String?) = perform { + fun createChildren(names: List, description: String?) = performLoad { repository.createChildren(_state.value.currentNodeId, names, description) - repository.loadTree() } fun previewTreeImport(manifestJson: String) { @@ -84,23 +224,22 @@ class TreeViewModel( _state.update { it.copy(importPreview = null) } perform { repository.commitTreeImport(planId) - repository.loadTree() + repository.loadTreeFromServer() } } - fun updateCurrent(name: String, description: String?, quantity: Long?) = perform { + fun updateCurrent(name: String, description: String?, quantity: Long?) = performLoad { repository.update(_state.value.currentNodeId, name, description, quantity) - repository.loadTree() } fun moveCurrent(targetParentId: String, handling: ChildHandling) = perform { repository.move(_state.value.currentNodeId, targetParentId, handling) - repository.loadTree() + repository.loadTreeFromServer() } fun moveMany(ids: Set, targetParentId: String, handling: ChildHandling) = perform { repository.moveMany(ids, targetParentId, handling) - repository.loadTree() + repository.loadTreeFromServer() } fun deleteCurrent() { @@ -108,19 +247,78 @@ class TreeViewModel( val destination = current.parentId ?: return perform(targetNodeId = destination, resetBackStack = true) { repository.delete(current.id) - repository.loadTree() + repository.loadTreeFromServer() } } fun deleteMany(ids: Set) = perform { repository.deleteMany(ids) - repository.loadTree() + repository.loadTreeFromServer() + } + + fun assignType(ids: Set, typeId: String?) = perform { + repository.classify(ids, typeId = typeId, setType = true) + repository.loadTreeFromServer() + } + + fun toggleTag(ids: Set, tagId: String, apply: Boolean) = perform { + repository.classify(ids, addTags = if (apply) listOf(tagId) else emptyList(), removeTags = if (apply) emptyList() else listOf(tagId)) + repository.loadTreeFromServer() + } + + fun clearTags(ids: Set, tagIds: List) = perform { + repository.classify(ids, removeTags = tagIds) + repository.loadTreeFromServer() + } + + fun createType(name: String) = mutateMetadata { repository.createNodeType(name) } + fun createType(name: String, color: String) = mutateMetadata { repository.createNodeType(name, color) } + fun updateType(type: NodeType) = mutateMetadata { repository.updateNodeType(type) } + fun deleteType(id: String) = mutateMetadata { repository.deleteNodeType(id) } + fun createTag(name: String) = mutateMetadata { repository.createTag(name) } + fun createTag(name: String, color: String) = mutateMetadata { repository.createTag(name, color) } + fun updateTag(tag: Tag) = mutateMetadata { repository.updateTag(tag) } + fun deleteTag(id: String) = mutateMetadata { repository.deleteTag(id) } + + private fun mutateMetadata(action: suspend () -> Any?) { + viewModelScope.launch { + runCatching { action() } + .onSuccess { refresh() } + .onFailure { failure -> _state.update { it.copy(error = failure.message) } } + } } fun clearError() { _state.update { it.copy(error = null) } } + fun resolveSyncConflict(keepLocal: Boolean) = performLoad { + repository.resolveConflict(keepLocal) + } + + private fun performLoad(action: suspend () -> TreeLoadResult) { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + runCatching { action() } + .onSuccess { result -> + _state.update { previous -> + val validTarget = result.root.find(previous.currentNodeId)?.id ?: result.root.id + previous.copy( + root = result.root, + currentNodeId = validTarget, + loading = false, + usingCachedTree = result.fromCache, + pendingOperations = result.pendingOperations, + syncConflict = result.syncConflict, + ) + } + } + .onFailure { failure -> + _state.update { it.copy(loading = false, error = failure.message ?: "Request failed") } + } + } + } + private fun perform( targetNodeId: String? = null, resetBackStack: Boolean = false, @@ -138,6 +336,9 @@ class TreeViewModel( currentNodeId = validTarget, backStack = if (resetBackStack) emptyList() else previous.backStack, loading = false, + usingCachedTree = false, + pendingOperations = 0, + syncConflict = null, ) } } @@ -150,11 +351,16 @@ class TreeViewModel( } companion object { - fun factory(repository: NodeRepository = NodeRepository()): ViewModelProvider.Factory = + fun factory( + context: Context, + repository: NodeRepository = NodeRepository(LocalInventoryDatabase.get(context)), + serverSettings: ServerSettings = ServerSettings(context), + connectivityMonitor: ConnectivityMonitor = ConnectivityMonitor(context), + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - TreeViewModel(repository) as T + TreeViewModel(repository, serverSettings, connectivityMonitor) as T } } } diff --git a/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt b/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt index cb121c9..d4b1773 100644 --- a/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt +++ b/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt @@ -27,6 +27,18 @@ fun CloseIcon(modifier: Modifier = Modifier, contentDescription: String? = "Clos fun MoreIcon(modifier: Modifier = Modifier, contentDescription: String? = "More options") = ManifestIcon(R.drawable.ic_action_more_24, contentDescription, modifier) +@Composable +fun EditIcon(modifier: Modifier = Modifier, contentDescription: String? = "Edit") = + ManifestIcon(R.drawable.ic_action_edit, contentDescription, modifier) + +@Composable +fun FilterIcon(modifier: Modifier = Modifier, contentDescription: String? = "Filter") = + ManifestIcon(R.drawable.ic_filter_24, contentDescription, modifier) + +@Composable +fun AppMenuIcon(modifier: Modifier = Modifier, contentDescription: String? = "Open settings") = + ManifestIcon(R.drawable.ic_app_mark_24, contentDescription, modifier) + @Composable private fun ManifestIcon( @DrawableRes drawable: Int, diff --git a/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt b/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt index 162ea91..f449644 100644 --- a/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt +++ b/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt @@ -8,16 +8,26 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.boxmanifest.ApiNode +import app.boxmanifest.NodeType +import app.boxmanifest.Tag +import androidx.compose.ui.graphics.Color +import android.graphics.Color as AndroidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource @Composable @OptIn(ExperimentalFoundationApi::class) @@ -27,6 +37,8 @@ fun NodeRow( modifier: Modifier = Modifier, selected: Boolean = false, onLongClick: (() -> Unit)? = null, + nodeType: NodeType? = null, + tags: List = emptyList(), ) { Row( modifier = modifier @@ -34,17 +46,18 @@ fun NodeRow( color = if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(10.dp), ) - .combinedClickable(onClick = onClick, onLongClick = onLongClick), + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Surface( modifier = Modifier.size(40.dp), shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant, + color = nodeType?.color?.let { runCatching { Color(AndroidColor.parseColor(it)) }.getOrNull()?.copy(alpha = .16f) } ?: MaterialTheme.colorScheme.surfaceVariant, ) { Box(contentAlignment = Alignment.Center) { - Text(if (selected) "✓" else node.name.firstOrNull()?.uppercase() ?: "•") + if (nodeType != null) NodeTypeIcon(nodeType) else Text(node.name.firstOrNull()?.uppercase() ?: "•") } } Column(modifier = Modifier.weight(1f)) { @@ -65,7 +78,31 @@ fun NodeRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (tags.isNotEmpty()) { + Row(Modifier.padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + tags.take(3).forEach { TagChip(it) } + if (tags.size > 3) Text("+${tags.size - 3}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } } RightChevronIcon(Modifier.size(18.dp)) } } + +@Composable +fun NodeTypeIcon(type: NodeType, modifier: Modifier = Modifier) { + val context = LocalContext.current + val resources = LocalResources.current + val resource = remember(type.iconKey, resources) { type.iconKey?.let { resources.getIdentifier("type_icon_${it.replace('-', '_')}", "drawable", context.packageName) } ?: 0 } + val color = type.color?.let { runCatching { Color(AndroidColor.parseColor(it)) }.getOrNull() } ?: MaterialTheme.colorScheme.onSurfaceVariant + if (resource != 0) Icon(painterResource(resource), contentDescription = type.name, tint = color, modifier = modifier.size(22.dp)) + else Text(type.name.firstOrNull()?.uppercase() ?: "T", color = color) +} + +@Composable +private fun TagChip(tag: Tag) { + val color = tag.color?.let { runCatching { Color(AndroidColor.parseColor(it)) }.getOrNull() } ?: MaterialTheme.colorScheme.onSurfaceVariant + Surface(color = color.copy(alpha = .16f), contentColor = color, shape = RoundedCornerShape(3.dp)) { + Text(tag.name, style = MaterialTheme.typography.labelSmall, maxLines = 1, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } +} diff --git a/app/src/main/java/app/boxmanifest/ui/components/TransientMessages.kt b/app/src/main/java/app/boxmanifest/ui/components/TransientMessages.kt new file mode 100644 index 0000000..2cbb1e5 --- /dev/null +++ b/app/src/main/java/app/boxmanifest/ui/components/TransientMessages.kt @@ -0,0 +1,8 @@ +package app.boxmanifest.ui.components + +import android.content.Context +import android.widget.Toast + +fun Context.showErrorMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml new file mode 100644 index 0000000..c8c4207 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_delete.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_action_edit.xml b/app/src/main/res/drawable/ic_action_edit.xml new file mode 100644 index 0000000..3d2e6fd --- /dev/null +++ b/app/src/main/res/drawable/ic_action_edit.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_find.xml b/app/src/main/res/drawable/ic_action_find.xml new file mode 100644 index 0000000..53d8550 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_find.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_action_label.xml b/app/src/main/res/drawable/ic_action_label.xml new file mode 100644 index 0000000..77e2c1b --- /dev/null +++ b/app/src/main/res/drawable/ic_action_label.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_move.xml b/app/src/main/res/drawable/ic_action_move.xml new file mode 100644 index 0000000..2523c12 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_move.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_app_mark_24.xml b/app/src/main/res/drawable/ic_app_mark_24.xml new file mode 100644 index 0000000..b8e7bb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_app_mark_24.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_24.xml b/app/src/main/res/drawable/ic_filter_24.xml new file mode 100644 index 0000000..e42be68 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_24.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_server_24.xml b/app/src/main/res/drawable/ic_server_24.xml new file mode 100644 index 0000000..3d659d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_server_24.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_accessibility.xml b/app/src/main/res/drawable/type_icon_accessibility.xml new file mode 100644 index 0000000..9ca3d03 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_accessibility.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_add_circle.xml b/app/src/main/res/drawable/type_icon_add_circle.xml new file mode 100644 index 0000000..c6b99aa --- /dev/null +++ b/app/src/main/res/drawable/type_icon_add_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_airplane.xml b/app/src/main/res/drawable/type_icon_airplane.xml new file mode 100644 index 0000000..0b7d040 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_airplane.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_alarm.xml b/app/src/main/res/drawable/type_icon_alarm.xml new file mode 100644 index 0000000..234b350 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_alarm.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_albums.xml b/app/src/main/res/drawable/type_icon_albums.xml new file mode 100644 index 0000000..9e67016 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_albums.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_alert_circle.xml b/app/src/main/res/drawable/type_icon_alert_circle.xml new file mode 100644 index 0000000..56f0d28 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_alert_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_american_football.xml b/app/src/main/res/drawable/type_icon_american_football.xml new file mode 100644 index 0000000..21c7e36 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_american_football.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_analytics.xml b/app/src/main/res/drawable/type_icon_analytics.xml new file mode 100644 index 0000000..850f3e1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_analytics.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_aperture.xml b/app/src/main/res/drawable/type_icon_aperture.xml new file mode 100644 index 0000000..367e48d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_aperture.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_apps.xml b/app/src/main/res/drawable/type_icon_apps.xml new file mode 100644 index 0000000..196b1b5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_apps.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_archive.xml b/app/src/main/res/drawable/type_icon_archive.xml new file mode 100644 index 0000000..645c2eb --- /dev/null +++ b/app/src/main/res/drawable/type_icon_archive.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_back_circle.xml b/app/src/main/res/drawable/type_icon_arrow_back_circle.xml new file mode 100644 index 0000000..75ffc93 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_back_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_down_circle.xml b/app/src/main/res/drawable/type_icon_arrow_down_circle.xml new file mode 100644 index 0000000..7b536ad --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_down_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_forward_circle.xml b/app/src/main/res/drawable/type_icon_arrow_forward_circle.xml new file mode 100644 index 0000000..dbb7111 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_forward_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_redo.xml b/app/src/main/res/drawable/type_icon_arrow_redo.xml new file mode 100644 index 0000000..8b0664b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_redo.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_redo_circle.xml b/app/src/main/res/drawable/type_icon_arrow_redo_circle.xml new file mode 100644 index 0000000..9996d59 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_redo_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_undo.xml b/app/src/main/res/drawable/type_icon_arrow_undo.xml new file mode 100644 index 0000000..d2a0956 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_undo.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_undo_circle.xml b/app/src/main/res/drawable/type_icon_arrow_undo_circle.xml new file mode 100644 index 0000000..29eb6c7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_undo_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_arrow_up_circle.xml b/app/src/main/res/drawable/type_icon_arrow_up_circle.xml new file mode 100644 index 0000000..ad631f5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_arrow_up_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_at_circle.xml b/app/src/main/res/drawable/type_icon_at_circle.xml new file mode 100644 index 0000000..739958a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_at_circle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_attach.xml b/app/src/main/res/drawable/type_icon_attach.xml new file mode 100644 index 0000000..5e92ebe --- /dev/null +++ b/app/src/main/res/drawable/type_icon_attach.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_backspace.xml b/app/src/main/res/drawable/type_icon_backspace.xml new file mode 100644 index 0000000..6a5e78c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_backspace.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bag.xml b/app/src/main/res/drawable/type_icon_bag.xml new file mode 100644 index 0000000..b67878d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bag.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bag_add.xml b/app/src/main/res/drawable/type_icon_bag_add.xml new file mode 100644 index 0000000..3d563a3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bag_add.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bag_check.xml b/app/src/main/res/drawable/type_icon_bag_check.xml new file mode 100644 index 0000000..8fb3df3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bag_check.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bag_handle.xml b/app/src/main/res/drawable/type_icon_bag_handle.xml new file mode 100644 index 0000000..190aba2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bag_handle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bag_remove.xml b/app/src/main/res/drawable/type_icon_bag_remove.xml new file mode 100644 index 0000000..0554c34 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bag_remove.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_balloon.xml b/app/src/main/res/drawable/type_icon_balloon.xml new file mode 100644 index 0000000..29856ff --- /dev/null +++ b/app/src/main/res/drawable/type_icon_balloon.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_ban.xml b/app/src/main/res/drawable/type_icon_ban.xml new file mode 100644 index 0000000..5d4d44f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ban.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_bandage.xml b/app/src/main/res/drawable/type_icon_bandage.xml new file mode 100644 index 0000000..c8cf43c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bandage.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_bar_chart.xml b/app/src/main/res/drawable/type_icon_bar_chart.xml new file mode 100644 index 0000000..57b1e6b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bar_chart.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_barbell.xml b/app/src/main/res/drawable/type_icon_barbell.xml new file mode 100644 index 0000000..63a56f5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_barbell.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_barcode.xml b/app/src/main/res/drawable/type_icon_barcode.xml new file mode 100644 index 0000000..de85a97 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_barcode.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_baseball.xml b/app/src/main/res/drawable/type_icon_baseball.xml new file mode 100644 index 0000000..5065d7f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_baseball.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_basket.xml b/app/src/main/res/drawable/type_icon_basket.xml new file mode 100644 index 0000000..b254fa5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_basket.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_basketball.xml b/app/src/main/res/drawable/type_icon_basketball.xml new file mode 100644 index 0000000..19ab30e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_basketball.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_battery_charging.xml b/app/src/main/res/drawable/type_icon_battery_charging.xml new file mode 100644 index 0000000..6617994 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_battery_charging.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_battery_dead.xml b/app/src/main/res/drawable/type_icon_battery_dead.xml new file mode 100644 index 0000000..066f18d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_battery_dead.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_battery_full.xml b/app/src/main/res/drawable/type_icon_battery_full.xml new file mode 100644 index 0000000..c8f6169 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_battery_full.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_battery_half.xml b/app/src/main/res/drawable/type_icon_battery_half.xml new file mode 100644 index 0000000..7764743 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_battery_half.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_beaker.xml b/app/src/main/res/drawable/type_icon_beaker.xml new file mode 100644 index 0000000..164d106 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_beaker.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bed.xml b/app/src/main/res/drawable/type_icon_bed.xml new file mode 100644 index 0000000..9281aa8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bed.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_beer.xml b/app/src/main/res/drawable/type_icon_beer.xml new file mode 100644 index 0000000..5c90001 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_beer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bicycle.xml b/app/src/main/res/drawable/type_icon_bicycle.xml new file mode 100644 index 0000000..f4ab65c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bicycle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_binoculars.xml b/app/src/main/res/drawable/type_icon_binoculars.xml new file mode 100644 index 0000000..2a09235 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_binoculars.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bluetooth.xml b/app/src/main/res/drawable/type_icon_bluetooth.xml new file mode 100644 index 0000000..807b0c9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bluetooth.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_boat.xml b/app/src/main/res/drawable/type_icon_boat.xml new file mode 100644 index 0000000..06cb190 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_boat.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_body.xml b/app/src/main/res/drawable/type_icon_body.xml new file mode 100644 index 0000000..54596ca --- /dev/null +++ b/app/src/main/res/drawable/type_icon_body.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_bonfire.xml b/app/src/main/res/drawable/type_icon_bonfire.xml new file mode 100644 index 0000000..cdbdd94 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bonfire.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_book.xml b/app/src/main/res/drawable/type_icon_book.xml new file mode 100644 index 0000000..0a5f82c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_book.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bookmark.xml b/app/src/main/res/drawable/type_icon_bookmark.xml new file mode 100644 index 0000000..d9cf787 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bookmark.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bookmarks.xml b/app/src/main/res/drawable/type_icon_bookmarks.xml new file mode 100644 index 0000000..7566a1c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bookmarks.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_bowling_ball.xml b/app/src/main/res/drawable/type_icon_bowling_ball.xml new file mode 100644 index 0000000..9c2c767 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bowling_ball.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_briefcase.xml b/app/src/main/res/drawable/type_icon_briefcase.xml new file mode 100644 index 0000000..4e47f2d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_briefcase.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_browsers.xml b/app/src/main/res/drawable/type_icon_browsers.xml new file mode 100644 index 0000000..3f6cdae --- /dev/null +++ b/app/src/main/res/drawable/type_icon_browsers.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_brush.xml b/app/src/main/res/drawable/type_icon_brush.xml new file mode 100644 index 0000000..2278604 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_brush.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bug.xml b/app/src/main/res/drawable/type_icon_bug.xml new file mode 100644 index 0000000..b33ad5a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bug.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_build.xml b/app/src/main/res/drawable/type_icon_build.xml new file mode 100644 index 0000000..613e865 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_build.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bulb.xml b/app/src/main/res/drawable/type_icon_bulb.xml new file mode 100644 index 0000000..433caf3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bulb.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_bus.xml b/app/src/main/res/drawable/type_icon_bus.xml new file mode 100644 index 0000000..2a242e9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_bus.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_business.xml b/app/src/main/res/drawable/type_icon_business.xml new file mode 100644 index 0000000..ef5cc7e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_business.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_cafe.xml b/app/src/main/res/drawable/type_icon_cafe.xml new file mode 100644 index 0000000..708bfe6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cafe.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_calculator.xml b/app/src/main/res/drawable/type_icon_calculator.xml new file mode 100644 index 0000000..54f9606 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_calculator.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_calendar.xml b/app/src/main/res/drawable/type_icon_calendar.xml new file mode 100644 index 0000000..12a13a0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_calendar.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_calendar_clear.xml b/app/src/main/res/drawable/type_icon_calendar_clear.xml new file mode 100644 index 0000000..66b6960 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_calendar_clear.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_calendar_number.xml b/app/src/main/res/drawable/type_icon_calendar_number.xml new file mode 100644 index 0000000..cfa83f2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_calendar_number.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_call.xml b/app/src/main/res/drawable/type_icon_call.xml new file mode 100644 index 0000000..360854a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_call.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_camera.xml b/app/src/main/res/drawable/type_icon_camera.xml new file mode 100644 index 0000000..a4e6325 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_camera.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_camera_reverse.xml b/app/src/main/res/drawable/type_icon_camera_reverse.xml new file mode 100644 index 0000000..d12eb13 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_camera_reverse.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_car.xml b/app/src/main/res/drawable/type_icon_car.xml new file mode 100644 index 0000000..adf7618 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_car.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_car_sport.xml b/app/src/main/res/drawable/type_icon_car_sport.xml new file mode 100644 index 0000000..ca999cb --- /dev/null +++ b/app/src/main/res/drawable/type_icon_car_sport.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_card.xml b/app/src/main/res/drawable/type_icon_card.xml new file mode 100644 index 0000000..bce14cc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_card.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_back.xml b/app/src/main/res/drawable/type_icon_caret_back.xml new file mode 100644 index 0000000..b1c681f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_back.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_back_circle.xml b/app/src/main/res/drawable/type_icon_caret_back_circle.xml new file mode 100644 index 0000000..c08db36 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_back_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_down.xml b/app/src/main/res/drawable/type_icon_caret_down.xml new file mode 100644 index 0000000..6d3a1bf --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_down.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_down_circle.xml b/app/src/main/res/drawable/type_icon_caret_down_circle.xml new file mode 100644 index 0000000..757cb55 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_down_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_forward.xml b/app/src/main/res/drawable/type_icon_caret_forward.xml new file mode 100644 index 0000000..c1a434f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_forward.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_forward_circle.xml b/app/src/main/res/drawable/type_icon_caret_forward_circle.xml new file mode 100644 index 0000000..a6e4af7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_forward_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_up.xml b/app/src/main/res/drawable/type_icon_caret_up.xml new file mode 100644 index 0000000..1c7200a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_up.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_caret_up_circle.xml b/app/src/main/res/drawable/type_icon_caret_up_circle.xml new file mode 100644 index 0000000..95bb9ea --- /dev/null +++ b/app/src/main/res/drawable/type_icon_caret_up_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cart.xml b/app/src/main/res/drawable/type_icon_cart.xml new file mode 100644 index 0000000..058de00 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cart.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_cash.xml b/app/src/main/res/drawable/type_icon_cash.xml new file mode 100644 index 0000000..89a2beb --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cash.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_cellular.xml b/app/src/main/res/drawable/type_icon_cellular.xml new file mode 100644 index 0000000..17b2811 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cellular.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chatbox.xml b/app/src/main/res/drawable/type_icon_chatbox.xml new file mode 100644 index 0000000..53a7a92 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chatbox.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chatbox_ellipses.xml b/app/src/main/res/drawable/type_icon_chatbox_ellipses.xml new file mode 100644 index 0000000..24cc236 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chatbox_ellipses.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chatbubble.xml b/app/src/main/res/drawable/type_icon_chatbubble.xml new file mode 100644 index 0000000..5e932b6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chatbubble.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chatbubble_ellipses.xml b/app/src/main/res/drawable/type_icon_chatbubble_ellipses.xml new file mode 100644 index 0000000..55ec148 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chatbubble_ellipses.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chatbubbles.xml b/app/src/main/res/drawable/type_icon_chatbubbles.xml new file mode 100644 index 0000000..497b31f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chatbubbles.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_checkbox.xml b/app/src/main/res/drawable/type_icon_checkbox.xml new file mode 100644 index 0000000..7824c18 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_checkbox.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_checkmark_circle.xml b/app/src/main/res/drawable/type_icon_checkmark_circle.xml new file mode 100644 index 0000000..86a51c6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_checkmark_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_checkmark_done_circle.xml b/app/src/main/res/drawable/type_icon_checkmark_done_circle.xml new file mode 100644 index 0000000..b92e4b4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_checkmark_done_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chevron_back_circle.xml b/app/src/main/res/drawable/type_icon_chevron_back_circle.xml new file mode 100644 index 0000000..60c8b96 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chevron_back_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chevron_down_circle.xml b/app/src/main/res/drawable/type_icon_chevron_down_circle.xml new file mode 100644 index 0000000..a86e8f4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chevron_down_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chevron_forward_circle.xml b/app/src/main/res/drawable/type_icon_chevron_forward_circle.xml new file mode 100644 index 0000000..8ad5df3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chevron_forward_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_chevron_up_circle.xml b/app/src/main/res/drawable/type_icon_chevron_up_circle.xml new file mode 100644 index 0000000..15f746e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_chevron_up_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_clipboard.xml b/app/src/main/res/drawable/type_icon_clipboard.xml new file mode 100644 index 0000000..fa9862c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_clipboard.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_close_circle.xml b/app/src/main/res/drawable/type_icon_close_circle.xml new file mode 100644 index 0000000..3ac2f1d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_close_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud.xml b/app/src/main/res/drawable/type_icon_cloud.xml new file mode 100644 index 0000000..04aeb0e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud_circle.xml b/app/src/main/res/drawable/type_icon_cloud_circle.xml new file mode 100644 index 0000000..edb5735 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud_done.xml b/app/src/main/res/drawable/type_icon_cloud_done.xml new file mode 100644 index 0000000..0cc6eb4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud_done.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud_download.xml b/app/src/main/res/drawable/type_icon_cloud_download.xml new file mode 100644 index 0000000..a024d89 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud_offline.xml b/app/src/main/res/drawable/type_icon_cloud_offline.xml new file mode 100644 index 0000000..6a5f339 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud_offline.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloud_upload.xml b/app/src/main/res/drawable/type_icon_cloud_upload.xml new file mode 100644 index 0000000..a2cb26c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloud_upload.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloudy.xml b/app/src/main/res/drawable/type_icon_cloudy.xml new file mode 100644 index 0000000..49ec0ae --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloudy.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cloudy_night.xml b/app/src/main/res/drawable/type_icon_cloudy_night.xml new file mode 100644 index 0000000..0b39e28 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cloudy_night.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_code.xml b/app/src/main/res/drawable/type_icon_code.xml new file mode 100644 index 0000000..9e4ec52 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_code.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_code_slash.xml b/app/src/main/res/drawable/type_icon_code_slash.xml new file mode 100644 index 0000000..c35e146 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_code_slash.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cog.xml b/app/src/main/res/drawable/type_icon_cog.xml new file mode 100644 index 0000000..bf8d0af --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cog.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_color_fill.xml b/app/src/main/res/drawable/type_icon_color_fill.xml new file mode 100644 index 0000000..4973219 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_color_fill.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_color_filter.xml b/app/src/main/res/drawable/type_icon_color_filter.xml new file mode 100644 index 0000000..bb710ae --- /dev/null +++ b/app/src/main/res/drawable/type_icon_color_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_color_palette.xml b/app/src/main/res/drawable/type_icon_color_palette.xml new file mode 100644 index 0000000..2d8ce21 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_color_palette.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_color_wand.xml b/app/src/main/res/drawable/type_icon_color_wand.xml new file mode 100644 index 0000000..42d2d8b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_color_wand.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_compass.xml b/app/src/main/res/drawable/type_icon_compass.xml new file mode 100644 index 0000000..87ce186 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_compass.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_construct.xml b/app/src/main/res/drawable/type_icon_construct.xml new file mode 100644 index 0000000..3151309 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_construct.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_contract.xml b/app/src/main/res/drawable/type_icon_contract.xml new file mode 100644 index 0000000..30e5b95 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_contract.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_contrast.xml b/app/src/main/res/drawable/type_icon_contrast.xml new file mode 100644 index 0000000..0e8326b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_contrast.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_copy.xml b/app/src/main/res/drawable/type_icon_copy.xml new file mode 100644 index 0000000..6308fde --- /dev/null +++ b/app/src/main/res/drawable/type_icon_copy.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_create.xml b/app/src/main/res/drawable/type_icon_create.xml new file mode 100644 index 0000000..e27f696 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_create.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_crop.xml b/app/src/main/res/drawable/type_icon_crop.xml new file mode 100644 index 0000000..467f0ec --- /dev/null +++ b/app/src/main/res/drawable/type_icon_crop.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_cube.xml b/app/src/main/res/drawable/type_icon_cube.xml new file mode 100644 index 0000000..379b11d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cube.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_cut.xml b/app/src/main/res/drawable/type_icon_cut.xml new file mode 100644 index 0000000..66383c5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_cut.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_desktop.xml b/app/src/main/res/drawable/type_icon_desktop.xml new file mode 100644 index 0000000..778d9b0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_desktop.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_diamond.xml b/app/src/main/res/drawable/type_icon_diamond.xml new file mode 100644 index 0000000..906ab66 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_diamond.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_dice.xml b/app/src/main/res/drawable/type_icon_dice.xml new file mode 100644 index 0000000..6d5f724 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_dice.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_disc.xml b/app/src/main/res/drawable/type_icon_disc.xml new file mode 100644 index 0000000..0d16321 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_disc.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_document.xml b/app/src/main/res/drawable/type_icon_document.xml new file mode 100644 index 0000000..fb704ce --- /dev/null +++ b/app/src/main/res/drawable/type_icon_document.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_document_attach.xml b/app/src/main/res/drawable/type_icon_document_attach.xml new file mode 100644 index 0000000..7723562 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_document_attach.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_document_lock.xml b/app/src/main/res/drawable/type_icon_document_lock.xml new file mode 100644 index 0000000..ab768a0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_document_lock.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_document_text.xml b/app/src/main/res/drawable/type_icon_document_text.xml new file mode 100644 index 0000000..1ce5c43 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_document_text.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_documents.xml b/app/src/main/res/drawable/type_icon_documents.xml new file mode 100644 index 0000000..85580be --- /dev/null +++ b/app/src/main/res/drawable/type_icon_documents.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_download.xml b/app/src/main/res/drawable/type_icon_download.xml new file mode 100644 index 0000000..8d441d5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_duplicate.xml b/app/src/main/res/drawable/type_icon_duplicate.xml new file mode 100644 index 0000000..9e01571 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_duplicate.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_ear.xml b/app/src/main/res/drawable/type_icon_ear.xml new file mode 100644 index 0000000..bafe52a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ear.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_earth.xml b/app/src/main/res/drawable/type_icon_earth.xml new file mode 100644 index 0000000..e1dacbc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_earth.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_easel.xml b/app/src/main/res/drawable/type_icon_easel.xml new file mode 100644 index 0000000..5afafb4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_easel.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_egg.xml b/app/src/main/res/drawable/type_icon_egg.xml new file mode 100644 index 0000000..a985670 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_egg.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_ellipse.xml b/app/src/main/res/drawable/type_icon_ellipse.xml new file mode 100644 index 0000000..486e551 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ellipse.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_ellipsis_horizontal_circle.xml b/app/src/main/res/drawable/type_icon_ellipsis_horizontal_circle.xml new file mode 100644 index 0000000..683d7f0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ellipsis_horizontal_circle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_ellipsis_vertical_circle.xml b/app/src/main/res/drawable/type_icon_ellipsis_vertical_circle.xml new file mode 100644 index 0000000..8648a27 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ellipsis_vertical_circle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_enter.xml b/app/src/main/res/drawable/type_icon_enter.xml new file mode 100644 index 0000000..014d08c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_enter.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_exit.xml b/app/src/main/res/drawable/type_icon_exit.xml new file mode 100644 index 0000000..b09245f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_exit.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_expand.xml b/app/src/main/res/drawable/type_icon_expand.xml new file mode 100644 index 0000000..2aa1f0d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_expand.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_extension_puzzle.xml b/app/src/main/res/drawable/type_icon_extension_puzzle.xml new file mode 100644 index 0000000..2521b37 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_extension_puzzle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_eye.xml b/app/src/main/res/drawable/type_icon_eye.xml new file mode 100644 index 0000000..12736be --- /dev/null +++ b/app/src/main/res/drawable/type_icon_eye.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_eye_off.xml b/app/src/main/res/drawable/type_icon_eye_off.xml new file mode 100644 index 0000000..0dfe62f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_eye_off.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_eyedrop.xml b/app/src/main/res/drawable/type_icon_eyedrop.xml new file mode 100644 index 0000000..6e8d68f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_eyedrop.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_fast_food.xml b/app/src/main/res/drawable/type_icon_fast_food.xml new file mode 100644 index 0000000..cf0d23a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_fast_food.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_female.xml b/app/src/main/res/drawable/type_icon_female.xml new file mode 100644 index 0000000..60d20b8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_female.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_file_tray.xml b/app/src/main/res/drawable/type_icon_file_tray.xml new file mode 100644 index 0000000..b7dc46a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_file_tray.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_file_tray_full.xml b/app/src/main/res/drawable/type_icon_file_tray_full.xml new file mode 100644 index 0000000..38f9b4c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_file_tray_full.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_file_tray_stacked.xml b/app/src/main/res/drawable/type_icon_file_tray_stacked.xml new file mode 100644 index 0000000..ca59f9d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_file_tray_stacked.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_film.xml b/app/src/main/res/drawable/type_icon_film.xml new file mode 100644 index 0000000..809dbe2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_film.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_filter_circle.xml b/app/src/main/res/drawable/type_icon_filter_circle.xml new file mode 100644 index 0000000..9fcd71d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_filter_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_finger_print.xml b/app/src/main/res/drawable/type_icon_finger_print.xml new file mode 100644 index 0000000..d31cee1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_finger_print.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_fish.xml b/app/src/main/res/drawable/type_icon_fish.xml new file mode 100644 index 0000000..fd070a8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_fish.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_fitness.xml b/app/src/main/res/drawable/type_icon_fitness.xml new file mode 100644 index 0000000..b3d5881 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_fitness.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_flag.xml b/app/src/main/res/drawable/type_icon_flag.xml new file mode 100644 index 0000000..d451ce9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flag.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flame.xml b/app/src/main/res/drawable/type_icon_flame.xml new file mode 100644 index 0000000..c22df71 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flame.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flash.xml b/app/src/main/res/drawable/type_icon_flash.xml new file mode 100644 index 0000000..b24c642 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flash.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flash_off.xml b/app/src/main/res/drawable/type_icon_flash_off.xml new file mode 100644 index 0000000..86bbef4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flash_off.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flashlight.xml b/app/src/main/res/drawable/type_icon_flashlight.xml new file mode 100644 index 0000000..05dfaab --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flashlight.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flask.xml b/app/src/main/res/drawable/type_icon_flask.xml new file mode 100644 index 0000000..37d3627 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flask.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_flower.xml b/app/src/main/res/drawable/type_icon_flower.xml new file mode 100644 index 0000000..e012ecc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_flower.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_folder.xml b/app/src/main/res/drawable/type_icon_folder.xml new file mode 100644 index 0000000..036a1f4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_folder.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_folder_open.xml b/app/src/main/res/drawable/type_icon_folder_open.xml new file mode 100644 index 0000000..13e4f0f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_folder_open.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_football.xml b/app/src/main/res/drawable/type_icon_football.xml new file mode 100644 index 0000000..9a44958 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_football.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_footsteps.xml b/app/src/main/res/drawable/type_icon_footsteps.xml new file mode 100644 index 0000000..cc1bf82 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_footsteps.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_funnel.xml b/app/src/main/res/drawable/type_icon_funnel.xml new file mode 100644 index 0000000..2264bc1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_funnel.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_game_controller.xml b/app/src/main/res/drawable/type_icon_game_controller.xml new file mode 100644 index 0000000..b6ba6e2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_game_controller.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_gift.xml b/app/src/main/res/drawable/type_icon_gift.xml new file mode 100644 index 0000000..bdf8009 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_gift.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_git_branch.xml b/app/src/main/res/drawable/type_icon_git_branch.xml new file mode 100644 index 0000000..5cef3e8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_branch.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_git_commit.xml b/app/src/main/res/drawable/type_icon_git_commit.xml new file mode 100644 index 0000000..dd78bef --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_commit.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_git_compare.xml b/app/src/main/res/drawable/type_icon_git_compare.xml new file mode 100644 index 0000000..0141cf1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_compare.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_git_merge.xml b/app/src/main/res/drawable/type_icon_git_merge.xml new file mode 100644 index 0000000..11920d9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_merge.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_git_network.xml b/app/src/main/res/drawable/type_icon_git_network.xml new file mode 100644 index 0000000..f433b8b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_network.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_git_pull_request.xml b/app/src/main/res/drawable/type_icon_git_pull_request.xml new file mode 100644 index 0000000..03a3364 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_git_pull_request.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_glasses.xml b/app/src/main/res/drawable/type_icon_glasses.xml new file mode 100644 index 0000000..006c8ee --- /dev/null +++ b/app/src/main/res/drawable/type_icon_glasses.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_globe.xml b/app/src/main/res/drawable/type_icon_globe.xml new file mode 100644 index 0000000..2582e43 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_globe.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_golf.xml b/app/src/main/res/drawable/type_icon_golf.xml new file mode 100644 index 0000000..83d0eb7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_golf.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_grid.xml b/app/src/main/res/drawable/type_icon_grid.xml new file mode 100644 index 0000000..dc217a2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_grid.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_hammer.xml b/app/src/main/res/drawable/type_icon_hammer.xml new file mode 100644 index 0000000..4e10f55 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_hammer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_hand_left.xml b/app/src/main/res/drawable/type_icon_hand_left.xml new file mode 100644 index 0000000..073d07b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_hand_left.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_hand_right.xml b/app/src/main/res/drawable/type_icon_hand_right.xml new file mode 100644 index 0000000..0bd0f5a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_hand_right.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_happy.xml b/app/src/main/res/drawable/type_icon_happy.xml new file mode 100644 index 0000000..7bb8249 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_happy.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_hardware_chip.xml b/app/src/main/res/drawable/type_icon_hardware_chip.xml new file mode 100644 index 0000000..83c915d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_hardware_chip.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_headset.xml b/app/src/main/res/drawable/type_icon_headset.xml new file mode 100644 index 0000000..74900da --- /dev/null +++ b/app/src/main/res/drawable/type_icon_headset.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_heart.xml b/app/src/main/res/drawable/type_icon_heart.xml new file mode 100644 index 0000000..d245eac --- /dev/null +++ b/app/src/main/res/drawable/type_icon_heart.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_heart_circle.xml b/app/src/main/res/drawable/type_icon_heart_circle.xml new file mode 100644 index 0000000..b67b887 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_heart_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_heart_dislike.xml b/app/src/main/res/drawable/type_icon_heart_dislike.xml new file mode 100644 index 0000000..b6dcf40 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_heart_dislike.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_heart_dislike_circle.xml b/app/src/main/res/drawable/type_icon_heart_dislike_circle.xml new file mode 100644 index 0000000..3a741cb --- /dev/null +++ b/app/src/main/res/drawable/type_icon_heart_dislike_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_heart_half.xml b/app/src/main/res/drawable/type_icon_heart_half.xml new file mode 100644 index 0000000..77fae5b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_heart_half.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_help_buoy.xml b/app/src/main/res/drawable/type_icon_help_buoy.xml new file mode 100644 index 0000000..86a6a25 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_help_buoy.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_help_circle.xml b/app/src/main/res/drawable/type_icon_help_circle.xml new file mode 100644 index 0000000..1d8926b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_help_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_home.xml b/app/src/main/res/drawable/type_icon_home.xml new file mode 100644 index 0000000..4c976c7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_home.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_hourglass.xml b/app/src/main/res/drawable/type_icon_hourglass.xml new file mode 100644 index 0000000..a78be4a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_hourglass.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_ice_cream.xml b/app/src/main/res/drawable/type_icon_ice_cream.xml new file mode 100644 index 0000000..ee57014 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ice_cream.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_id_card.xml b/app/src/main/res/drawable/type_icon_id_card.xml new file mode 100644 index 0000000..736bc47 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_id_card.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_image.xml b/app/src/main/res/drawable/type_icon_image.xml new file mode 100644 index 0000000..752db18 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_image.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_images.xml b/app/src/main/res/drawable/type_icon_images.xml new file mode 100644 index 0000000..a4a114e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_images.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_infinite.xml b/app/src/main/res/drawable/type_icon_infinite.xml new file mode 100644 index 0000000..b42b1c8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_infinite.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_information_circle.xml b/app/src/main/res/drawable/type_icon_information_circle.xml new file mode 100644 index 0000000..a7d7af7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_information_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_invert_mode.xml b/app/src/main/res/drawable/type_icon_invert_mode.xml new file mode 100644 index 0000000..a0724cc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_invert_mode.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_journal.xml b/app/src/main/res/drawable/type_icon_journal.xml new file mode 100644 index 0000000..97b8843 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_journal.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_key.xml b/app/src/main/res/drawable/type_icon_key.xml new file mode 100644 index 0000000..b24ef08 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_key.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_keypad.xml b/app/src/main/res/drawable/type_icon_keypad.xml new file mode 100644 index 0000000..ad1d8ea --- /dev/null +++ b/app/src/main/res/drawable/type_icon_keypad.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_language.xml b/app/src/main/res/drawable/type_icon_language.xml new file mode 100644 index 0000000..f3acb52 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_language.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_laptop.xml b/app/src/main/res/drawable/type_icon_laptop.xml new file mode 100644 index 0000000..a55d21e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_laptop.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_layers.xml b/app/src/main/res/drawable/type_icon_layers.xml new file mode 100644 index 0000000..60505b0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_layers.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_leaf.xml b/app/src/main/res/drawable/type_icon_leaf.xml new file mode 100644 index 0000000..1b94a44 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_leaf.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_library.xml b/app/src/main/res/drawable/type_icon_library.xml new file mode 100644 index 0000000..72ca20b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_library.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_link.xml b/app/src/main/res/drawable/type_icon_link.xml new file mode 100644 index 0000000..56c10ac --- /dev/null +++ b/app/src/main/res/drawable/type_icon_link.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_list.xml b/app/src/main/res/drawable/type_icon_list.xml new file mode 100644 index 0000000..005989c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_list.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_list_circle.xml b/app/src/main/res/drawable/type_icon_list_circle.xml new file mode 100644 index 0000000..ae4e6a0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_list_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_locate.xml b/app/src/main/res/drawable/type_icon_locate.xml new file mode 100644 index 0000000..f447e67 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_locate.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_location.xml b/app/src/main/res/drawable/type_icon_location.xml new file mode 100644 index 0000000..b131aff --- /dev/null +++ b/app/src/main/res/drawable/type_icon_location.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_lock_closed.xml b/app/src/main/res/drawable/type_icon_lock_closed.xml new file mode 100644 index 0000000..7991066 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_lock_closed.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_lock_open.xml b/app/src/main/res/drawable/type_icon_lock_open.xml new file mode 100644 index 0000000..360964e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_lock_open.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_log_in.xml b/app/src/main/res/drawable/type_icon_log_in.xml new file mode 100644 index 0000000..fcef9ae --- /dev/null +++ b/app/src/main/res/drawable/type_icon_log_in.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_log_out.xml b/app/src/main/res/drawable/type_icon_log_out.xml new file mode 100644 index 0000000..43916cc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_log_out.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_logo_alipay.xml b/app/src/main/res/drawable/type_icon_logo_alipay.xml new file mode 100644 index 0000000..9bb102e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_logo_alipay.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_logo_amazon.xml b/app/src/main/res/drawable/type_icon_logo_amazon.xml new file mode 100644 index 0000000..a529c5d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_logo_amazon.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_logo_amplify.xml b/app/src/main/res/drawable/type_icon_logo_amplify.xml new file mode 100644 index 0000000..6333d2b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_logo_amplify.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_logo_android.xml b/app/src/main/res/drawable/type_icon_logo_android.xml new file mode 100644 index 0000000..927fa44 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_logo_android.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_magnet.xml b/app/src/main/res/drawable/type_icon_magnet.xml new file mode 100644 index 0000000..48b732b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_magnet.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_mail.xml b/app/src/main/res/drawable/type_icon_mail.xml new file mode 100644 index 0000000..01c6abd --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mail.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_mail_open.xml b/app/src/main/res/drawable/type_icon_mail_open.xml new file mode 100644 index 0000000..21f96dc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mail_open.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_mail_unread.xml b/app/src/main/res/drawable/type_icon_mail_unread.xml new file mode 100644 index 0000000..ffd6ddc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mail_unread.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_male.xml b/app/src/main/res/drawable/type_icon_male.xml new file mode 100644 index 0000000..847830b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_male.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_male_female.xml b/app/src/main/res/drawable/type_icon_male_female.xml new file mode 100644 index 0000000..44853ea --- /dev/null +++ b/app/src/main/res/drawable/type_icon_male_female.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_man.xml b/app/src/main/res/drawable/type_icon_man.xml new file mode 100644 index 0000000..bac53ad --- /dev/null +++ b/app/src/main/res/drawable/type_icon_man.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_map.xml b/app/src/main/res/drawable/type_icon_map.xml new file mode 100644 index 0000000..42ad4f5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_map.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_medal.xml b/app/src/main/res/drawable/type_icon_medal.xml new file mode 100644 index 0000000..e0390c1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_medal.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_medical.xml b/app/src/main/res/drawable/type_icon_medical.xml new file mode 100644 index 0000000..c1a279d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_medical.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_medkit.xml b/app/src/main/res/drawable/type_icon_medkit.xml new file mode 100644 index 0000000..801be1c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_medkit.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_megaphone.xml b/app/src/main/res/drawable/type_icon_megaphone.xml new file mode 100644 index 0000000..310f70a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_megaphone.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_menu.xml b/app/src/main/res/drawable/type_icon_menu.xml new file mode 100644 index 0000000..6c20501 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_menu.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_mic.xml b/app/src/main/res/drawable/type_icon_mic.xml new file mode 100644 index 0000000..3d564de --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mic.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_mic_circle.xml b/app/src/main/res/drawable/type_icon_mic_circle.xml new file mode 100644 index 0000000..497932a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mic_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_mic_off.xml b/app/src/main/res/drawable/type_icon_mic_off.xml new file mode 100644 index 0000000..7694693 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mic_off.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_mic_off_circle.xml b/app/src/main/res/drawable/type_icon_mic_off_circle.xml new file mode 100644 index 0000000..aee0e7f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_mic_off_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_moon.xml b/app/src/main/res/drawable/type_icon_moon.xml new file mode 100644 index 0000000..411e1ee --- /dev/null +++ b/app/src/main/res/drawable/type_icon_moon.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_move.xml b/app/src/main/res/drawable/type_icon_move.xml new file mode 100644 index 0000000..ce1de11 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_move.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_musical_note.xml b/app/src/main/res/drawable/type_icon_musical_note.xml new file mode 100644 index 0000000..6ca5a0b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_musical_note.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_musical_notes.xml b/app/src/main/res/drawable/type_icon_musical_notes.xml new file mode 100644 index 0000000..11519e5 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_musical_notes.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_navigate.xml b/app/src/main/res/drawable/type_icon_navigate.xml new file mode 100644 index 0000000..90401c9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_navigate.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_navigate_circle.xml b/app/src/main/res/drawable/type_icon_navigate_circle.xml new file mode 100644 index 0000000..3607537 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_navigate_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_newspaper.xml b/app/src/main/res/drawable/type_icon_newspaper.xml new file mode 100644 index 0000000..436d9be --- /dev/null +++ b/app/src/main/res/drawable/type_icon_newspaper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_notifications.xml b/app/src/main/res/drawable/type_icon_notifications.xml new file mode 100644 index 0000000..0fc9136 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_notifications.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_notifications_circle.xml b/app/src/main/res/drawable/type_icon_notifications_circle.xml new file mode 100644 index 0000000..ee4a23d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_notifications_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_notifications_off.xml b/app/src/main/res/drawable/type_icon_notifications_off.xml new file mode 100644 index 0000000..285e52d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_notifications_off.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_notifications_off_circle.xml b/app/src/main/res/drawable/type_icon_notifications_off_circle.xml new file mode 100644 index 0000000..1aced9e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_notifications_off_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_nuclear.xml b/app/src/main/res/drawable/type_icon_nuclear.xml new file mode 100644 index 0000000..c8fc371 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_nuclear.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_nutrition.xml b/app/src/main/res/drawable/type_icon_nutrition.xml new file mode 100644 index 0000000..c31a280 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_nutrition.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_options.xml b/app/src/main/res/drawable/type_icon_options.xml new file mode 100644 index 0000000..0ccf52b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_options.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_paper_plane.xml b/app/src/main/res/drawable/type_icon_paper_plane.xml new file mode 100644 index 0000000..ea6b66c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_paper_plane.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_partly_sunny.xml b/app/src/main/res/drawable/type_icon_partly_sunny.xml new file mode 100644 index 0000000..fac581b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_partly_sunny.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pause.xml b/app/src/main/res/drawable/type_icon_pause.xml new file mode 100644 index 0000000..79eb30b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pause.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pause_circle.xml b/app/src/main/res/drawable/type_icon_pause_circle.xml new file mode 100644 index 0000000..c162282 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pause_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_paw.xml b/app/src/main/res/drawable/type_icon_paw.xml new file mode 100644 index 0000000..0ab8c12 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_paw.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pencil.xml b/app/src/main/res/drawable/type_icon_pencil.xml new file mode 100644 index 0000000..d74bbc7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pencil.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_people.xml b/app/src/main/res/drawable/type_icon_people.xml new file mode 100644 index 0000000..660d898 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_people.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_people_circle.xml b/app/src/main/res/drawable/type_icon_people_circle.xml new file mode 100644 index 0000000..6593fe6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_people_circle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_person.xml b/app/src/main/res/drawable/type_icon_person.xml new file mode 100644 index 0000000..475bc26 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_person.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_person_add.xml b/app/src/main/res/drawable/type_icon_person_add.xml new file mode 100644 index 0000000..11e966d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_person_add.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_person_circle.xml b/app/src/main/res/drawable/type_icon_person_circle.xml new file mode 100644 index 0000000..ceee513 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_person_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_person_remove.xml b/app/src/main/res/drawable/type_icon_person_remove.xml new file mode 100644 index 0000000..dec1a85 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_person_remove.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_phone_landscape.xml b/app/src/main/res/drawable/type_icon_phone_landscape.xml new file mode 100644 index 0000000..2bd8528 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_phone_landscape.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_phone_portrait.xml b/app/src/main/res/drawable/type_icon_phone_portrait.xml new file mode 100644 index 0000000..e735401 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_phone_portrait.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_pie_chart.xml b/app/src/main/res/drawable/type_icon_pie_chart.xml new file mode 100644 index 0000000..1433450 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pie_chart.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_pin.xml b/app/src/main/res/drawable/type_icon_pin.xml new file mode 100644 index 0000000..bcecbcc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pin.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pint.xml b/app/src/main/res/drawable/type_icon_pint.xml new file mode 100644 index 0000000..ee409a1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pint.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pizza.xml b/app/src/main/res/drawable/type_icon_pizza.xml new file mode 100644 index 0000000..bc05f18 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pizza.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_planet.xml b/app/src/main/res/drawable/type_icon_planet.xml new file mode 100644 index 0000000..861147b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_planet.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play.xml b/app/src/main/res/drawable/type_icon_play.xml new file mode 100644 index 0000000..e6ca049 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_back.xml b/app/src/main/res/drawable/type_icon_play_back.xml new file mode 100644 index 0000000..c025a1c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_back.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_back_circle.xml b/app/src/main/res/drawable/type_icon_play_back_circle.xml new file mode 100644 index 0000000..2fe6894 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_back_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_circle.xml b/app/src/main/res/drawable/type_icon_play_circle.xml new file mode 100644 index 0000000..351ff14 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_forward.xml b/app/src/main/res/drawable/type_icon_play_forward.xml new file mode 100644 index 0000000..daa9b9e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_forward.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_forward_circle.xml b/app/src/main/res/drawable/type_icon_play_forward_circle.xml new file mode 100644 index 0000000..40e1d58 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_forward_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_skip_back.xml b/app/src/main/res/drawable/type_icon_play_skip_back.xml new file mode 100644 index 0000000..f65f7c9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_skip_back.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_skip_back_circle.xml b/app/src/main/res/drawable/type_icon_play_skip_back_circle.xml new file mode 100644 index 0000000..83e69ab --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_skip_back_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_skip_forward.xml b/app/src/main/res/drawable/type_icon_play_skip_forward.xml new file mode 100644 index 0000000..1956df7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_skip_forward.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_play_skip_forward_circle.xml b/app/src/main/res/drawable/type_icon_play_skip_forward_circle.xml new file mode 100644 index 0000000..f99574c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_play_skip_forward_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_podium.xml b/app/src/main/res/drawable/type_icon_podium.xml new file mode 100644 index 0000000..0fd1f22 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_podium.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_power.xml b/app/src/main/res/drawable/type_icon_power.xml new file mode 100644 index 0000000..0fdf0fa --- /dev/null +++ b/app/src/main/res/drawable/type_icon_power.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_pricetag.xml b/app/src/main/res/drawable/type_icon_pricetag.xml new file mode 100644 index 0000000..f6ede9e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pricetag.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pricetags.xml b/app/src/main/res/drawable/type_icon_pricetags.xml new file mode 100644 index 0000000..c3f7a9a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pricetags.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_print.xml b/app/src/main/res/drawable/type_icon_print.xml new file mode 100644 index 0000000..f4741f2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_print.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_prism.xml b/app/src/main/res/drawable/type_icon_prism.xml new file mode 100644 index 0000000..dd86ee2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_prism.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_pulse.xml b/app/src/main/res/drawable/type_icon_pulse.xml new file mode 100644 index 0000000..96f8676 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_pulse.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_push.xml b/app/src/main/res/drawable/type_icon_push.xml new file mode 100644 index 0000000..156102d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_push.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_qr_code.xml b/app/src/main/res/drawable/type_icon_qr_code.xml new file mode 100644 index 0000000..1193b10 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_qr_code.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/type_icon_radio.xml b/app/src/main/res/drawable/type_icon_radio.xml new file mode 100644 index 0000000..36273e6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_radio.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_radio_button_off.xml b/app/src/main/res/drawable/type_icon_radio_button_off.xml new file mode 100644 index 0000000..47474a1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_radio_button_off.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_radio_button_on.xml b/app/src/main/res/drawable/type_icon_radio_button_on.xml new file mode 100644 index 0000000..c0a3204 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_radio_button_on.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_rainy.xml b/app/src/main/res/drawable/type_icon_rainy.xml new file mode 100644 index 0000000..cb451e4 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_rainy.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_reader.xml b/app/src/main/res/drawable/type_icon_reader.xml new file mode 100644 index 0000000..7243d7a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_reader.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_receipt.xml b/app/src/main/res/drawable/type_icon_receipt.xml new file mode 100644 index 0000000..519747e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_receipt.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_recording.xml b/app/src/main/res/drawable/type_icon_recording.xml new file mode 100644 index 0000000..4406262 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_recording.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_refresh.xml b/app/src/main/res/drawable/type_icon_refresh.xml new file mode 100644 index 0000000..f0f9e7f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_refresh.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_refresh_circle.xml b/app/src/main/res/drawable/type_icon_refresh_circle.xml new file mode 100644 index 0000000..aaaf30a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_refresh_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_reload.xml b/app/src/main/res/drawable/type_icon_reload.xml new file mode 100644 index 0000000..1825cf6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_reload.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_reload_circle.xml b/app/src/main/res/drawable/type_icon_reload_circle.xml new file mode 100644 index 0000000..9c04cf9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_reload_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_remove_circle.xml b/app/src/main/res/drawable/type_icon_remove_circle.xml new file mode 100644 index 0000000..fb7812d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_remove_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_repeat.xml b/app/src/main/res/drawable/type_icon_repeat.xml new file mode 100644 index 0000000..c960390 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_repeat.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_resize.xml b/app/src/main/res/drawable/type_icon_resize.xml new file mode 100644 index 0000000..1e4ec7a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_resize.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_restaurant.xml b/app/src/main/res/drawable/type_icon_restaurant.xml new file mode 100644 index 0000000..a2117cc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_restaurant.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_ribbon.xml b/app/src/main/res/drawable/type_icon_ribbon.xml new file mode 100644 index 0000000..670166c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ribbon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_rocket.xml b/app/src/main/res/drawable/type_icon_rocket.xml new file mode 100644 index 0000000..124bcb9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_rocket.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_rose.xml b/app/src/main/res/drawable/type_icon_rose.xml new file mode 100644 index 0000000..36fb29c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_rose.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_sad.xml b/app/src/main/res/drawable/type_icon_sad.xml new file mode 100644 index 0000000..b1ed2b6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_sad.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_save.xml b/app/src/main/res/drawable/type_icon_save.xml new file mode 100644 index 0000000..9dc80c0 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_save.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_scale.xml b/app/src/main/res/drawable/type_icon_scale.xml new file mode 100644 index 0000000..9c1639a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_scale.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_scan.xml b/app/src/main/res/drawable/type_icon_scan.xml new file mode 100644 index 0000000..19355ad --- /dev/null +++ b/app/src/main/res/drawable/type_icon_scan.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_scan_circle.xml b/app/src/main/res/drawable/type_icon_scan_circle.xml new file mode 100644 index 0000000..111bf4c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_scan_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_school.xml b/app/src/main/res/drawable/type_icon_school.xml new file mode 100644 index 0000000..f3f26de --- /dev/null +++ b/app/src/main/res/drawable/type_icon_school.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_search.xml b/app/src/main/res/drawable/type_icon_search.xml new file mode 100644 index 0000000..703b6bc --- /dev/null +++ b/app/src/main/res/drawable/type_icon_search.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_search_circle.xml b/app/src/main/res/drawable/type_icon_search_circle.xml new file mode 100644 index 0000000..ab8f853 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_search_circle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_send.xml b/app/src/main/res/drawable/type_icon_send.xml new file mode 100644 index 0000000..7138d6b --- /dev/null +++ b/app/src/main/res/drawable/type_icon_send.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_server.xml b/app/src/main/res/drawable/type_icon_server.xml new file mode 100644 index 0000000..04ded39 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_server.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_settings.xml b/app/src/main/res/drawable/type_icon_settings.xml new file mode 100644 index 0000000..7feab94 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_settings.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_shapes.xml b/app/src/main/res/drawable/type_icon_shapes.xml new file mode 100644 index 0000000..ffc1c32 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shapes.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_share.xml b/app/src/main/res/drawable/type_icon_share.xml new file mode 100644 index 0000000..a941b5e --- /dev/null +++ b/app/src/main/res/drawable/type_icon_share.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_share_social.xml b/app/src/main/res/drawable/type_icon_share_social.xml new file mode 100644 index 0000000..ba36d51 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_share_social.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_shield.xml b/app/src/main/res/drawable/type_icon_shield.xml new file mode 100644 index 0000000..ea0bdb9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shield.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_shield_checkmark.xml b/app/src/main/res/drawable/type_icon_shield_checkmark.xml new file mode 100644 index 0000000..57cde0a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shield_checkmark.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_shield_half.xml b/app/src/main/res/drawable/type_icon_shield_half.xml new file mode 100644 index 0000000..776693f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shield_half.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_shirt.xml b/app/src/main/res/drawable/type_icon_shirt.xml new file mode 100644 index 0000000..1d9e552 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shirt.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_shuffle.xml b/app/src/main/res/drawable/type_icon_shuffle.xml new file mode 100644 index 0000000..1ee1f81 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_shuffle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_skull.xml b/app/src/main/res/drawable/type_icon_skull.xml new file mode 100644 index 0000000..b97a0b8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_skull.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_snow.xml b/app/src/main/res/drawable/type_icon_snow.xml new file mode 100644 index 0000000..0bad0e3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_snow.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_sparkles.xml b/app/src/main/res/drawable/type_icon_sparkles.xml new file mode 100644 index 0000000..0ee11a8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_sparkles.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_speedometer.xml b/app/src/main/res/drawable/type_icon_speedometer.xml new file mode 100644 index 0000000..346297f --- /dev/null +++ b/app/src/main/res/drawable/type_icon_speedometer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_square.xml b/app/src/main/res/drawable/type_icon_square.xml new file mode 100644 index 0000000..f54c894 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_square.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_star.xml b/app/src/main/res/drawable/type_icon_star.xml new file mode 100644 index 0000000..f647a1a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_star.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_star_half.xml b/app/src/main/res/drawable/type_icon_star_half.xml new file mode 100644 index 0000000..ae68214 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_star_half.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_stats_chart.xml b/app/src/main/res/drawable/type_icon_stats_chart.xml new file mode 100644 index 0000000..4f594ab --- /dev/null +++ b/app/src/main/res/drawable/type_icon_stats_chart.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_stop.xml b/app/src/main/res/drawable/type_icon_stop.xml new file mode 100644 index 0000000..5824ebd --- /dev/null +++ b/app/src/main/res/drawable/type_icon_stop.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_stop_circle.xml b/app/src/main/res/drawable/type_icon_stop_circle.xml new file mode 100644 index 0000000..8d6b185 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_stop_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_stopwatch.xml b/app/src/main/res/drawable/type_icon_stopwatch.xml new file mode 100644 index 0000000..a79a297 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_stopwatch.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_storefront.xml b/app/src/main/res/drawable/type_icon_storefront.xml new file mode 100644 index 0000000..80a4bbb --- /dev/null +++ b/app/src/main/res/drawable/type_icon_storefront.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_subway.xml b/app/src/main/res/drawable/type_icon_subway.xml new file mode 100644 index 0000000..1d80cbd --- /dev/null +++ b/app/src/main/res/drawable/type_icon_subway.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_sunny.xml b/app/src/main/res/drawable/type_icon_sunny.xml new file mode 100644 index 0000000..c3a0eb9 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_sunny.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_swap_horizontal.xml b/app/src/main/res/drawable/type_icon_swap_horizontal.xml new file mode 100644 index 0000000..7241a77 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_swap_horizontal.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_swap_vertical.xml b/app/src/main/res/drawable/type_icon_swap_vertical.xml new file mode 100644 index 0000000..acaa057 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_swap_vertical.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_sync.xml b/app/src/main/res/drawable/type_icon_sync.xml new file mode 100644 index 0000000..662901a --- /dev/null +++ b/app/src/main/res/drawable/type_icon_sync.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_sync_circle.xml b/app/src/main/res/drawable/type_icon_sync_circle.xml new file mode 100644 index 0000000..26f84b8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_sync_circle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_tablet_landscape.xml b/app/src/main/res/drawable/type_icon_tablet_landscape.xml new file mode 100644 index 0000000..26d09c8 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_tablet_landscape.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_tablet_portrait.xml b/app/src/main/res/drawable/type_icon_tablet_portrait.xml new file mode 100644 index 0000000..148bcb2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_tablet_portrait.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_telescope.xml b/app/src/main/res/drawable/type_icon_telescope.xml new file mode 100644 index 0000000..0393124 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_telescope.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_tennisball.xml b/app/src/main/res/drawable/type_icon_tennisball.xml new file mode 100644 index 0000000..f7182b1 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_tennisball.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_terminal.xml b/app/src/main/res/drawable/type_icon_terminal.xml new file mode 100644 index 0000000..1436aa2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_terminal.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_text.xml b/app/src/main/res/drawable/type_icon_text.xml new file mode 100644 index 0000000..24e330d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_text.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_thermometer.xml b/app/src/main/res/drawable/type_icon_thermometer.xml new file mode 100644 index 0000000..02d1cc6 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_thermometer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_thumbs_down.xml b/app/src/main/res/drawable/type_icon_thumbs_down.xml new file mode 100644 index 0000000..55fc3bf --- /dev/null +++ b/app/src/main/res/drawable/type_icon_thumbs_down.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_thumbs_up.xml b/app/src/main/res/drawable/type_icon_thumbs_up.xml new file mode 100644 index 0000000..f32aeba --- /dev/null +++ b/app/src/main/res/drawable/type_icon_thumbs_up.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_thunderstorm.xml b/app/src/main/res/drawable/type_icon_thunderstorm.xml new file mode 100644 index 0000000..3c91470 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_thunderstorm.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_ticket.xml b/app/src/main/res/drawable/type_icon_ticket.xml new file mode 100644 index 0000000..5a4daba --- /dev/null +++ b/app/src/main/res/drawable/type_icon_ticket.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_time.xml b/app/src/main/res/drawable/type_icon_time.xml new file mode 100644 index 0000000..2e39236 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_time.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_timer.xml b/app/src/main/res/drawable/type_icon_timer.xml new file mode 100644 index 0000000..22bec0c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_timer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_today.xml b/app/src/main/res/drawable/type_icon_today.xml new file mode 100644 index 0000000..7b8ec6c --- /dev/null +++ b/app/src/main/res/drawable/type_icon_today.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_toggle.xml b/app/src/main/res/drawable/type_icon_toggle.xml new file mode 100644 index 0000000..e914845 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_toggle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_trail_sign.xml b/app/src/main/res/drawable/type_icon_trail_sign.xml new file mode 100644 index 0000000..a6663ae --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trail_sign.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_train.xml b/app/src/main/res/drawable/type_icon_train.xml new file mode 100644 index 0000000..3810e28 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_train.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_transgender.xml b/app/src/main/res/drawable/type_icon_transgender.xml new file mode 100644 index 0000000..d6908c7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_transgender.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_trash.xml b/app/src/main/res/drawable/type_icon_trash.xml new file mode 100644 index 0000000..2e3cbbe --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trash.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_trash_bin.xml b/app/src/main/res/drawable/type_icon_trash_bin.xml new file mode 100644 index 0000000..edf8d59 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trash_bin.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_trending_down.xml b/app/src/main/res/drawable/type_icon_trending_down.xml new file mode 100644 index 0000000..6e838af --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trending_down.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_trending_up.xml b/app/src/main/res/drawable/type_icon_trending_up.xml new file mode 100644 index 0000000..37b4bd2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trending_up.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_triangle.xml b/app/src/main/res/drawable/type_icon_triangle.xml new file mode 100644 index 0000000..ed03148 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_triangle.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_trophy.xml b/app/src/main/res/drawable/type_icon_trophy.xml new file mode 100644 index 0000000..ca5c860 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_trophy.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_tv.xml b/app/src/main/res/drawable/type_icon_tv.xml new file mode 100644 index 0000000..c7285c3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_tv.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_umbrella.xml b/app/src/main/res/drawable/type_icon_umbrella.xml new file mode 100644 index 0000000..751a730 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_umbrella.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_unlink.xml b/app/src/main/res/drawable/type_icon_unlink.xml new file mode 100644 index 0000000..7622fc2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_unlink.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_videocam.xml b/app/src/main/res/drawable/type_icon_videocam.xml new file mode 100644 index 0000000..8b29239 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_videocam.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_videocam_off.xml b/app/src/main/res/drawable/type_icon_videocam_off.xml new file mode 100644 index 0000000..56a8974 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_videocam_off.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_volume_high.xml b/app/src/main/res/drawable/type_icon_volume_high.xml new file mode 100644 index 0000000..de137df --- /dev/null +++ b/app/src/main/res/drawable/type_icon_volume_high.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_volume_low.xml b/app/src/main/res/drawable/type_icon_volume_low.xml new file mode 100644 index 0000000..1fdef73 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_volume_low.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_volume_medium.xml b/app/src/main/res/drawable/type_icon_volume_medium.xml new file mode 100644 index 0000000..b5f4c24 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_volume_medium.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_volume_mute.xml b/app/src/main/res/drawable/type_icon_volume_mute.xml new file mode 100644 index 0000000..36a7600 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_volume_mute.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_volume_off.xml b/app/src/main/res/drawable/type_icon_volume_off.xml new file mode 100644 index 0000000..e14d3aa --- /dev/null +++ b/app/src/main/res/drawable/type_icon_volume_off.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_walk.xml b/app/src/main/res/drawable/type_icon_walk.xml new file mode 100644 index 0000000..482b01d --- /dev/null +++ b/app/src/main/res/drawable/type_icon_walk.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/type_icon_wallet.xml b/app/src/main/res/drawable/type_icon_wallet.xml new file mode 100644 index 0000000..6e212a7 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_wallet.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_warning.xml b/app/src/main/res/drawable/type_icon_warning.xml new file mode 100644 index 0000000..a232a57 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_warning.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_watch.xml b/app/src/main/res/drawable/type_icon_watch.xml new file mode 100644 index 0000000..eae8485 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_watch.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/type_icon_water.xml b/app/src/main/res/drawable/type_icon_water.xml new file mode 100644 index 0000000..78ddde2 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_water.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_wifi.xml b/app/src/main/res/drawable/type_icon_wifi.xml new file mode 100644 index 0000000..b90ec53 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_wifi.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/type_icon_wine.xml b/app/src/main/res/drawable/type_icon_wine.xml new file mode 100644 index 0000000..e234178 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_wine.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/type_icon_woman.xml b/app/src/main/res/drawable/type_icon_woman.xml new file mode 100644 index 0000000..d9244b3 --- /dev/null +++ b/app/src/main/res/drawable/type_icon_woman.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..79aca65 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index f8c6127..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/test/java/app/boxmanifest/NodeFiltersTest.kt b/app/src/test/java/app/boxmanifest/NodeFiltersTest.kt new file mode 100644 index 0000000..35011b8 --- /dev/null +++ b/app/src/test/java/app/boxmanifest/NodeFiltersTest.kt @@ -0,0 +1,59 @@ +package app.boxmanifest + +import app.boxmanifest.tree.NO_TYPE_FILTER +import app.boxmanifest.tree.FilterMode +import app.boxmanifest.tree.QuantityFilter +import app.boxmanifest.tree.SearchFilters +import app.boxmanifest.tree.matchingFilters +import app.boxmanifest.tree.selectableIds +import org.junit.Assert.assertEquals +import org.junit.Test + +class NodeFiltersTest { + private val nodes = listOf( + ApiNode("one", "root", "One", null, typeId = "box", tagIds = listOf("blue", "large")), + ApiNode("two", "root", "Two", null, typeId = "box", tagIds = listOf("blue")), + ApiNode("three", "root", "Three", null, typeId = null, tagIds = listOf("large")), + ApiNode("unsorted", "root", "Unsorted", null, typeId = "box", tagIds = listOf("blue", "large")), + ) + + @Test + fun selectAllUsesOnlyNodesMatchingEveryActiveFilter() { + val selected = nodes.matchingFilters("box", setOf("blue", "large")).selectableIds() + + assertEquals(setOf("one"), selected) + } + + @Test + fun noTypeFilterExcludesTypedNodes() { + assertEquals(listOf("three"), nodes.matchingFilters(NO_TYPE_FILTER, emptySet()).map(ApiNode::id)) + } + + @Test + fun noFiltersSelectsEverySelectableNode() { + assertEquals(setOf("one", "two", "three"), nodes.matchingFilters(null, emptySet()).selectableIds()) + } + + @Test + fun orModeMatchesAnyActiveFilter() { + val selected = nodes.matchingFilters("box", setOf("large"), FilterMode.OR).selectableIds() + + assertEquals(setOf("one", "two", "three"), selected) + } + + @Test + fun globalSearchFiltersUseTheSelectedMode() { + val filters = SearchFilters(typeId = "box", tagIds = setOf("large")) + + assertEquals(listOf("one", "unsorted"), nodes.filter { filters.matches(it, FilterMode.AND) }.map(ApiNode::id)) + assertEquals(listOf("one", "two", "three", "unsorted"), nodes.filter { filters.matches(it, FilterMode.OR) }.map(ApiNode::id)) + } + + @Test + fun globalSearchSupportsDescriptionAndQuantity() { + val node = ApiNode("four", "root", "Four", 1, description = "Small parts") + val filters = SearchFilters(description = "parts", quantity = QuantityFilter.ONE) + + assertEquals(true, filters.matches(node, FilterMode.AND)) + } +} diff --git a/app/src/test/java/app/boxmanifest/data/ServerSettingsTest.kt b/app/src/test/java/app/boxmanifest/data/ServerSettingsTest.kt new file mode 100644 index 0000000..81c2b8d --- /dev/null +++ b/app/src/test/java/app/boxmanifest/data/ServerSettingsTest.kt @@ -0,0 +1,20 @@ +package app.boxmanifest.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ServerSettingsTest { + @Test + fun normalizesServerUrls() { + assertEquals("http://example.test:8080", normalizeServerUrl("example.test:8080/")) + assertEquals("https://example.test", normalizeServerUrl(" https://example.test ")) + } + + @Test + fun rejectsUrlsWithPaths() { + assertThrows(IllegalArgumentException::class.java) { + normalizeServerUrl("https://example.test/inventory") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c842f56..85fa89b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,13 @@ activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" coroutines = "1.10.2" +datastore = "1.2.1" codeScanner = "16.1.0" zxing = "3.5.4" camerax = "1.6.2" mlkitBarcode = "17.3.0" +room = "2.8.4" +ksp = "2.3.11" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -27,6 +30,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } google-code-scanner = { group = "com.google.android.gms", name = "play-services-code-scanner", version.ref = "codeScanner" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } @@ -34,7 +38,11 @@ androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycl androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } androidx-camera-mlkit-vision = { group = "androidx.camera", name = "camera-mlkit-vision", version.ref = "camerax" } mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }