diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index e5f5472..48c2625 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -6,24 +6,36 @@ on: pull_request: branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - - name: Set up Gradle cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - name: Run unit tests - run: ./gradlew test --no-daemon + run: ./gradlew test + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: app/build/reports/tests/ + retention-days: 7 + - name: Build debug APK + run: ./gradlew assembleDebug + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + retention-days: 14 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4fe0ddd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +VCOPT (OpenPrintTag Writer) is an Android NFC application for reading, writing, and generating standardized data tags for 3D printing materials (filament spools). It implements the OpenPrintTag specification, storing material metadata (brand, type, temperatures, colors, certifications) on NFC tags using CBOR encoding and NDEF formatting. + +## Build Commands + +```bash +./gradlew assembleDebug # Build debug APK (outputs to app/build/outputs/apk/debug/) +./gradlew assembleRelease # Build release APK +./gradlew test # Run unit tests (in app/src/test/) +./gradlew installDebug # Install to connected device +./gradlew clean # Clean build directory +``` + +Note: Requires `ANDROID_HOME` environment variable set (e.g., `/Users//Library/Android/sdk`). + +## Architecture + +**Package:** `org.openprinttag.app` (namespace), `org.openprinttag` (code) + +### Core Components + +- **MainActivity** - Entry point handling NFC tag detection, reading, writing, and file import/export. Uses coroutines for async NFC operations with read/write mode toggle. Decodes and displays human-readable tag data. +- **GeneratorActivity** - Material data editor with complex UI for configuring material properties. Implements smart tag selection logic (implies/hints system) and returns generated binary data to MainActivity. +- **NfcHelper** - Abstraction layer for NFC operations supporting both NfcA and NfcV tag technologies. Handles page-based reading (pages 4-129). +- **OpenPrintTagModel** (`model/`) - Data model with nested regions (Meta, Main, Aux, URL). Uses Kotlinx.serialization with custom serializers and `@SerialName` for CBOR integer key mapping. `MainRegion` and `AuxRegion` are top-level classes, not nested. +- **Serializer** (`model/`) - Bidirectional CBOR serialization with NDEF record formatting. MIME type: `application/vnd.openprinttag`. Key methods: `serialize()`, `deserialize()`, `generateDualRecordBin()`. + +### Data Configuration + +YAML files in `app/src/main/assets/data/` define enum maps loaded at runtime: +- `material_class_enum.yaml` - Material classes (FFF, SLA, SLS) +- `material_type_enum.yaml` - Material types (PLA, PETG, ABS, TPU, etc.) +- `tags_enum.yaml` - Material property tags with implies/hints relationships +- `material_certifications_enum.yaml` - Certifications (FDA, REACH, etc.) +- `tag_categories_enum.yaml` - Tag categories with display names and emojis + +### Data Flow + +**Write flow:** +1. MainActivity -> GeneratorActivity (with optional cached tag data for pre-fill) +2. GeneratorActivity loads YAML configs -> builds dynamic UI +3. User fills form -> clicks Generate +4. Serializer encodes to CBOR -> returns binary to MainActivity +5. User taps NFC tag -> NfcHelper writes data + +**Read flow:** +1. User taps NFC tag -> NfcHelper reads raw bytes +2. Serializer.deserialize() parses CBOR -> OpenPrintTagModel +3. MainActivity displays human-readable format (brand, material, temps, etc.) + +## Coding Conventions + +- **Language**: Kotlin 2.2.0 with Java 17 toolchain +- **Classes**: PascalCase (e.g., `OpenPrintTagModel`) +- **Functions/Variables**: camelCase (e.g., `generateTag`, `nfcHelper`) +- **Layout Files**: snake_case (e.g., `activity_main.xml`) +- **View Binding**: Used throughout - avoid `findViewById` +- **Coroutines**: Dispatchers.IO for NFC/file operations, Dispatchers.Main for UI +- **Theming**: Use Material3 theme attributes (`?attr/colorOnSurface`) instead of hardcoded colors + +## Testing + +- Unit tests in `app/src/test/java/org/openprinttag/` +- `testOptions.returnDefaultValues = true` in build.gradle for Android mocks +- Focus on covering `model/` logic (Serializer, OpenPrintTagModel) +- Run with `./gradlew test` + +## Tech Stack + +- Kotlin 2.2.0 / JVM toolchain 17 +- Min SDK 25 / Target SDK 35 +- Kotlinx.serialization + Jackson CBOR +- SnakeYAML for config parsing +- Material Design 3 with explicit Light/Dark themes (`values/styles.xml`, `values-night/styles.xml`) diff --git a/app/build.gradle b/app/build.gradle index dc1cf91..72e8a73 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { compileSdk 35 compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 coreLibraryDesugaringEnabled true } @@ -34,22 +34,27 @@ android { applicationId "org.openprinttag.app" minSdk 25 targetSdk 35 + versionCode 1 + versionName "0.1.0" } // 2. Add this block to force Kotlin to match Java (outside the android block) kotlin { - jvmToolchain(21) + jvmToolchain(17) } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.debug } } buildFeatures { viewBinding true } + + testOptions { + unitTests.returnDefaultValues = true + } } @@ -72,25 +77,12 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.18.2' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2' -/* - implementation 'androidx.compose.runtime:runtime:1.10.0' - implementation 'com.google.android.material:material:1.13.0' - implementation 'com.android.identity:identity-jvm:202411.1' - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" // or latest - implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.0" - implementation 'androidx.appcompat:appcompat:1.7.1' - implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation("org.yaml:snakeyaml:2.5") - implementation("com.github.skydoves:colorpickerview:2.3.0") - implementation("com.github.skydoves:colorpicker-compose:1.1.3") + + // Test dependencies testImplementation 'junit:junit:4.13.2' + testImplementation "org.jetbrains.kotlin:kotlin-reflect:2.2.0" androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.20.1' - implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0" - implementation("org.jetbrains.kotlin:kotlin-reflect") -*/ } configurations.configureEach { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..50d3d08 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,16 @@ +# Kotlin Serialization +-keepattributes *Annotation*, InnerClasses +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keep,includedescriptorclasses class org.openprinttag.model.**$$serializer { *; } +-keepclassmembers class org.openprinttag.model.** { *** Companion; } + +# Jackson +-keep class com.fasterxml.jackson.databind.ObjectMapper { public ; } +-keepnames class com.fasterxml.jackson.** { *; } +-dontwarn com.fasterxml.jackson.databind.** + +# SnakeYAML +-keep class org.yaml.snakeyaml.** { *; } + +# Model classes +-keep class org.openprinttag.model.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a5f283..ec2a365 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,19 +1,30 @@ - + + android:theme="@style/Theme.OpenPrintTag"> + + + + + + + + + - + diff --git a/app/src/main/java/org/openprinttag/GeneratorActivity.kt b/app/src/main/java/org/openprinttag/GeneratorActivity.kt index 19168b3..a086a6d 100644 --- a/app/src/main/java/org/openprinttag/GeneratorActivity.kt +++ b/app/src/main/java/org/openprinttag/GeneratorActivity.kt @@ -3,7 +3,6 @@ package org.openprinttag import android.annotation.SuppressLint import android.app.Activity import android.content.Context - import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color @@ -11,35 +10,37 @@ import android.os.Bundle import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.Button -import android.widget.EditText +import android.widget.CheckBox import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import org.openprinttag.model.OpenPrintTagModel -import org.openprinttag.model.Serializer -import org.yaml.snakeyaml.Yaml -import org.openprinttag.app.R // use the actual namespace where R was generated -import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener -import com.skydoves.colorpickerview.ColorEnvelope -import java.io.InputStream -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.MaterialAutoCompleteTextView -import com.google.android.material.textfield.TextInputLayout +import com.skydoves.colorpickerview.ColorEnvelope import com.skydoves.colorpickerview.ColorPickerDialog +import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener +import org.openprinttag.app.R import org.openprinttag.app.databinding.ActivityGeneratorBinding -import kotlin.collections.emptyList - +import org.openprinttag.model.OpenPrintTagModel +import org.openprinttag.model.Serializer +import org.yaml.snakeyaml.Yaml +import java.io.InputStream +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter data class OptionEntry( var key: Int = 0, @@ -47,14 +48,18 @@ data class OptionEntry( var category: String = "", var display_name: String = "", var description: String = "", - var implies: List = emptyList(), - var hints: List = emptyList(), + var implies: List = emptyList(), + var hints: List = emptyList(), var deprecated: String = "" ) -data class RootConfig( - val options: List = emptyList() +data class CertEntry( + var key: Int = 0, + var name: String = "", + var display_name: String = "", + var description: String = "" ) + data class CategoryMetadata( val name: String, val display_name: String, @@ -66,73 +71,26 @@ class HintSpinnerAdapter( resource: Int, objects: List ) : ArrayAdapter(context, resource, objects) { - // Helper to get the theme's primary text color - private fun getThemeTextColor(context: Context): Int { - val typedValue = TypedValue() - context.theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true) - return ContextCompat.getColor(context, typedValue.resourceId) - } - - /* - override fun isEnabled(position: Int): Boolean { - // Disable the first item (the hint) so it's not clickable - return position != 0 - } */ - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = super.getView(position, convertView, parent) as TextView - // Closed state color - if (position == 0) { - view.alpha = 0.6f // Standard "hint" look (works in light and dark) - } else { - view.alpha = 1.0f - } + view.alpha = if (position == 0) 0.6f else 1.0f return view } - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getDropDownView(position, convertView, parent) - val tv = view as TextView + val view = super.getDropDownView(position, convertView, parent) as TextView if (position == 0) { - // Hint color: use primary text with transparency or secondary color view.alpha = 0.5f } else { - // Match the theme's primary text color - view.setTextColor(getThemeTextColor(context)) + val typedValue = TypedValue() + context.theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true) + view.setTextColor(ContextCompat.getColor(context, typedValue.resourceId)) view.alpha = 1.0f } return view } } -class TagsYamlLoader { - fun loadFromResources(fileName: String): List { - // Access the file from the resources folder - val inputStream: InputStream? = this::class.java.classLoader.getResourceAsStream(fileName) - - requireNotNull(inputStream) { "Could not find file: $fileName" } - - val yaml = Yaml() - val rawData: List> = yaml.load(inputStream) - - return rawData.map { map -> - OptionEntry( - key = map["key"] as? Int ?: 0, - name = map["name"] as? String ?: "", - category = map["category"] as? String ?: "", - display_name = map["display_name"] as? String ?: "", - description = map["description"] as? String ?: "", - implies = (map["implies"] as? List<*>)?.filterIsInstance() ?: emptyList(), - hints = (map["hints"] as? List<*>)?.filterIsInstance() ?: emptyList(), - deprecated = map["deprecated"] as? String ?: "" - ) - } - } -} - - - data class SelectionUpdate( val newSelectedKeys: Set, val suggestions: List @@ -144,11 +102,10 @@ class SelectionManager(private val allOptions: List) { fun onOptionSelected(selectedKey: Int, currentSelection: Set): SelectionUpdate { val selectedOption = keyLookup[selectedKey] ?: return SelectionUpdate(currentSelection, emptyList()) - + val newSelection = currentSelection.toMutableSet() val suggestions = mutableListOf() - // 1. Process "Implies" (Recursive Auto-selection) val stack = mutableListOf(selectedOption) while (stack.isNotEmpty()) { val current = stack.removeAt(0) @@ -159,7 +116,6 @@ class SelectionManager(private val allOptions: List) { } } - // 2. Process "Hints" (Non-automatic) selectedOption.hints.forEach { hintName -> nameLookup[hintName]?.let { hintOption -> if (hintOption.key !in newSelection) { @@ -172,256 +128,446 @@ class SelectionManager(private val allOptions: List) { } } - - class GeneratorActivity : AppCompatActivity() { - - - - @SuppressLint("ClickableViewAccessibility") + private lateinit var binding: ActivityGeneratorBinding private lateinit var selectionManager: SelectionManager - private var currentSelectedKeys = mutableSetOf() - private var allOptions: List = emptyList() - var color = 0x00 - var hex = "%06X".format(0xFFFFFF and color) - private var categoryMap = mapOf() - val yaml = org.yaml.snakeyaml.Yaml() + private var currentSelectedTagKeys = mutableSetOf() + private var currentSelectedCertKeys = mutableSetOf() + private var allTagOptions: List = emptyList() + private var allCertOptions: List = emptyList() + private var categoryMap = mapOf() private var classMap = mapOf() private var typeMap = mapOf() private var tagsMap = mapOf() private var certsMap = mapOf() - private lateinit var autoCompleteMaterialType: MaterialAutoCompleteTextView - private lateinit var autoCompleteMaterialClass: MaterialAutoCompleteTextView - private lateinit var layoutMaterialType: TextInputLayout - private lateinit var layoutMaterialClass: TextInputLayout - + // Date storage + private var manufacturedDate: LocalDate? = null + private var expirationDate: LocalDate? = null + // Color storage (hex without #) + private var primaryColorHex: String? = null + private var secondaryColor0Hex: String? = null + private var secondaryColor1Hex: String? = null + private var secondaryColor2Hex: String? = null + private var secondaryColor3Hex: String? = null + private var secondaryColor4Hex: String? = null + private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + sealed class TagDisplayItem { + data class Header(val title: String) : TagDisplayItem() + data class TagRow(val entry: OptionEntry) : TagDisplayItem() + } - class HintSpinnerAdapter( - context: Context, - resource: Int, - objects: List - ) : ArrayAdapter(context, resource, objects) { + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityGeneratorBinding.inflate(layoutInflater) + setContentView(binding.root) - /* - override fun isEnabled(position: Int): Boolean { - // Disable the first item (the hint) so it's not clickable - return position != 0 + // Handle system insets + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(insets.left, insets.top, insets.right, insets.bottom) + windowInsets } - */ - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getDropDownView(position, convertView, parent) - val tv = view as TextView - if (position == 0) { - // Set the hint text color to gray - tv.setTextColor(Color.GRAY) - } else { - // Set regular items to black (or your theme color) - tv.setTextColor(Color.BLACK) - } - return view + // Setup toolbar + binding.toolbar.setNavigationOnClickListener { finish() } + + // Load YAML maps + loadAllMaps() + + // Initialize selection manager + selectionManager = SelectionManager(allTagOptions) + + // Setup dropdowns + setupDropdowns() + + // Setup color pickers + setupColorPickers() + + // Setup date pickers + setupDatePickers() + + // Setup tag and certification buttons + setupTagsAndCertsButtons() + + // Setup generate button + setupGenerateButton() + + // Pre-fill UI if cached data exists + val cachedData = intent.getByteArrayExtra("CACHED_TAG_DATA") + if (cachedData != null) { + val serializer = Serializer(classMap, typeMap, tagsMap, certsMap) + val decodedModel = serializer.deserialize(cachedData) + decodedModel?.let { preFillUI(it) } } } - private fun loadMaterialTypesFromYaml(assetPath: String, fieldName: String): List { - val names = mutableListOf() + + private fun loadAllMaps() { + classMap = loadMapFromYaml("data/material_class_enum.yaml", "name") + typeMap = loadMapFromYaml("data/material_type_enum.yaml", "abbreviation") + tagsMap = loadMapFromYaml("data/tags_enum.yaml", "name") + certsMap = loadMapFromYaml("data/material_certifications_enum.yaml", "display_name") + + loadTagsFromAssets() + loadCertsFromAssets() + loadMetadata() + } + + private fun loadMapFromYaml(fileName: String, labelKey: String): Map { + val resultMap = mutableMapOf() try { - val inputStream = assets.open(assetPath) - val rawData: List? = org.yaml.snakeyaml.Yaml().load(inputStream) - - rawData?.forEach { entry -> - if (entry is Map<*, *>) { - val value = entry[fieldName] as? String - if (value != null) { - names.add(value) + assets.open(fileName).use { inputStream -> + val yaml = Yaml() + val rawData = yaml.load>>(inputStream) + rawData?.forEach { entry -> + val label = entry[labelKey]?.toString() + val value = entry["key"]?.toString()?.toIntOrNull() + if (label != null && value != null) { + resultMap[label] = value } } } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("Generator", "Error loading $fileName", e) } - return names.sorted() // Optional: Sort alphabetically for the UI + return resultMap } private fun loadMetadata() { try { - // Load Categories for Emojis - val catStream: InputStream = assets.open("data/tag_categories_enum.yaml") - val catData: List> = Yaml().load(catStream) - categoryMap = catData.associate { map -> - val name = map["name"] as String - name to CategoryMetadata( - name = name, - display_name = map["display_name"] as String, - emoji = map["emoji"] as? String ?: "📦" - ) + assets.open("data/tag_categories_enum.yaml").use { catStream -> + val catData: List> = Yaml().load(catStream) + categoryMap = catData.associate { map -> + val name = map["name"] as String + name to CategoryMetadata( + name = name, + display_name = map["display_name"] as String, + emoji = map["emoji"] as? String ?: "" + ) + } } } catch (e: Exception) { e.printStackTrace() } } - sealed class TagDisplayItem { - data class Header(val title: String) : TagDisplayItem() - data class TagRow(val entry: OptionEntry) : TagDisplayItem() + private fun loadTagsFromAssets() { + try { + assets.open("data/tags_enum.yaml").use { inputStream -> + val yaml = Yaml() + val loadedData = yaml.load>>(inputStream) + + allTagOptions = loadedData.filter { map -> + val isDeprecated = map["deprecated"] as? Boolean ?: false + !isDeprecated + }.map { map -> + @Suppress("UNCHECKED_CAST") + OptionEntry( + key = (map["key"] as? Int) ?: 0, + name = (map["name"] as? String) ?: "", + category = (map["category"] as? String) ?: "", + display_name = (map["display_name"] as? String) ?: "", + description = (map["description"] as? String) ?: "", + implies = (map["implies"] as? List) ?: emptyList(), + hints = (map["hints"] as? List) ?: emptyList(), + deprecated = (map["deprecated"] as? String) ?: "" + ) + } + selectionManager = SelectionManager(allTagOptions) + } + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(this, R.string.toast_failed_load_tags, Toast.LENGTH_SHORT).show() + } } - private fun loadMapFromYaml(fileName: String, labelKey: String): Map { - val resultMap = mutableMapOf() + private fun loadCertsFromAssets() { try { - val inputStream = assets.open(fileName) - val yaml = org.yaml.snakeyaml.Yaml() - val rawData = yaml.load>>(inputStream) - - rawData?.forEach { entry -> - val label = entry[labelKey]?.toString() - val value = entry["key"]?.toString()?.toIntOrNull() - - if (label != null && value != null) { - resultMap[label] = value + assets.open("data/material_certifications_enum.yaml").use { inputStream -> + val yaml = Yaml() + val loadedData = yaml.load>>(inputStream) + + allCertOptions = loadedData.map { map -> + CertEntry( + key = (map["key"] as? Int) ?: 0, + name = (map["name"] as? String) ?: "", + display_name = (map["display_name"] as? String) ?: "", + description = (map["description"] as? String) ?: "" + ) } } - inputStream.close() - android.util.Log.d("YAML", "Loaded ${resultMap.size} items from $fileName") } catch (e: Exception) { - android.util.Log.e("YAML", "Error loading $fileName", e) + e.printStackTrace() } - return resultMap } -/* - class OptionsAdapter( - private var options: List, - private val selectionManager: SelectionManager, - private val onSelectionChanged: (Set, List) -> Unit - ) : RecyclerView.Adapter() { - - private var selectedKeys = mutableSetOf() - private val TYPE_HEADER = 0 - private val TYPE_TAG = 1 + private fun setupDropdowns() { + // Material Class dropdown + val classAdapter = ArrayAdapter(this, R.layout.list_item, classMap.keys.toList()) + binding.autoCompleteMaterialClass.setAdapter(classAdapter) - override fun getItemViewType(position: Int): Int { - return when (options[position]) { - is TagDisplayItem.Header -> TYPE_HEADER - is TagDisplayItem.TagRow -> TYPE_TAG - } + // Listen for material class changes to update field visibility + binding.autoCompleteMaterialClass.setOnItemClickListener { _, _, _, _ -> + updateFieldVisibilityForMaterialClass() } - // 1. Fixed onCreateViewHolder: Ensure ViewGroup and Int are explicit - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - return if (viewType == TYPE_HEADER) { - // Reuse the TextView from your category_select.xml or create a small header layout - val view = inflater.inflate(R.layout.tag_category_header, parent, false) - HeaderViewHolder(view) + + // Material Type dropdown + val typeAdapter = ArrayAdapter(this, R.layout.list_item, typeMap.keys.toList().sorted()) + binding.autoCompleteMaterialType.setAdapter(typeAdapter) + + // Material Type validation + binding.autoCompleteMaterialType.addTextChangedListener { text -> + val input = text?.toString() ?: "" + if (input.isNotEmpty() && !typeMap.containsKey(input)) { + binding.layoutMaterialType.error = getString(R.string.error_unknown_material) } else { - val view = inflater.inflate(R.layout.tag_selectable_option, parent, false) - TagViewHolder(view) + binding.layoutMaterialType.error = null } } - // 2. Fixed onBindViewHolder: Matches the ViewHolder type exactly - override fun onBindViewHolder(holder: OptionViewHolder, position: Int) { - holder.bind(options[position]) - } + // Write Protection dropdown + val writeProtectionOptions = listOf("None", "Protected", "Locked") + val wpAdapter = ArrayAdapter(this, R.layout.list_item, writeProtectionOptions) + binding.autoCompleteWriteProtection.setAdapter(wpAdapter) - override fun getItemCount(): Int = options.size + // Set default visibility (FFF by default) + updateFieldVisibilityForMaterialClass() + } + private fun updateFieldVisibilityForMaterialClass() { + val materialClass = binding.autoCompleteMaterialClass.text?.toString() ?: "FFF" - inner class OptionViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val checkbox: CheckBox = view.findViewById(R.id.option_checkbox) - val title: TextView = view.findViewById(R.id.option_title) - val description: TextView = view.findViewById(R.id.option_description) + when (materialClass.uppercase()) { + "SLA" -> { + // SLA: Show SLA fields, hide FFF-specific fields + binding.groupFffPhysical.visibility = View.GONE + binding.groupFffTemperatures.visibility = View.GONE + binding.groupFffContainer.visibility = View.GONE + binding.groupSlaFields.visibility = View.VISIBLE + } + "SLS" -> { + // SLS: Show temperatures (chamber temp relevant), hide filament-specific and SLA + binding.groupFffPhysical.visibility = View.GONE + binding.groupFffTemperatures.visibility = View.VISIBLE + binding.groupFffContainer.visibility = View.GONE + binding.groupSlaFields.visibility = View.GONE + } + else -> { // FFF (default) + // FFF: Show FFF fields, hide SLA fields + binding.groupFffPhysical.visibility = View.VISIBLE + binding.groupFffTemperatures.visibility = View.VISIBLE + binding.groupFffContainer.visibility = View.VISIBLE + binding.groupSlaFields.visibility = View.GONE + } + } + } + + private fun setupColorPickers() { + // Primary color picker + binding.colorButton.setOnClickListener { + showColorPicker(binding.getColor, binding.colorButton) { hex -> + primaryColorHex = hex + } + } - fun bind(option: OptionEntry) { - title.text = option.display_name - description.text = option.description + binding.getColor.addTextChangedListener { text -> + updateColorButtonFromText(text?.toString(), binding.colorButton) + primaryColorHex = text?.toString()?.takeIf { it.length == 6 } + } - checkbox.setOnCheckedChangeListener(null) - checkbox.isChecked = selectedKeys.contains(option.key) + // Secondary color pickers + setupSecondaryColorPicker(binding.colorButton0, binding.getSecondaryColor0) { hex -> secondaryColor0Hex = hex } + setupSecondaryColorPicker(binding.colorButton1, binding.getSecondaryColor1) { hex -> secondaryColor1Hex = hex } + setupSecondaryColorPicker(binding.colorButton2, binding.getSecondaryColor2) { hex -> secondaryColor2Hex = hex } + setupSecondaryColorPicker(binding.colorButton3, binding.getSecondaryColor3) { hex -> secondaryColor3Hex = hex } + setupSecondaryColorPicker(binding.colorButton4, binding.getSecondaryColor4) { hex -> secondaryColor4Hex = hex } + } - checkbox.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - val update = selectionManager.onOptionSelected(option.key, selectedKeys) - selectedKeys.addAll(update.newSelectedKeys) - onSelectionChanged(selectedKeys, update.suggestions) - notifyDataSetChanged() - } else { - selectedKeys.remove(option.key) - onSelectionChanged(selectedKeys, emptyList()) - } + private fun setupSecondaryColorPicker( + button: Button, + editText: com.google.android.material.textfield.TextInputEditText, + onColorChanged: (String?) -> Unit + ) { + button.setOnClickListener { + showColorPicker(editText, button, onColorChanged) + } + editText.addTextChangedListener { text -> + updateColorButtonFromText(text?.toString(), button) + onColorChanged(text?.toString()?.takeIf { it.length == 6 }) + } + } + + private fun showColorPicker( + editText: com.google.android.material.textfield.TextInputEditText, + button: Button, + onColorChanged: (String?) -> Unit + ) { + ColorPickerDialog.Builder(this) + .setTitle(getString(R.string.dialog_choose_color)) + .setPreferenceName("ColorPickerSettings") + .setPositiveButton(getString(R.string.dialog_btn_confirm), object : ColorEnvelopeListener { + override fun onColorSelected(envelope: ColorEnvelope, fromUser: Boolean) { + val hex = "%06X".format(0xFFFFFF and envelope.color) + editText.setText(hex) + button.backgroundTintList = ColorStateList.valueOf(envelope.color) + onColorChanged(hex) } + }) + .attachBrightnessSlideBar(true) + .attachAlphaSlideBar(false) + .setNegativeButton(getString(R.string.dialog_btn_cancel)) { dialog, _ -> dialog.dismiss() } + .show() + } + + private fun updateColorButtonFromText(hexText: String?, button: Button) { + if (hexText != null && hexText.length == 6) { + try { + val color = Color.parseColor("#$hexText") + button.backgroundTintList = ColorStateList.valueOf(color) + } catch (_: Exception) { + // Invalid hex } } } - */ - - private fun preFillUI(model: OpenPrintTagModel) { - // Basic Fields - val main = model.main - // 1. Strings: Only set if not null and not blank - main.brand?.takeIf { it.isNotBlank() }?.let { - findViewById(R.id.getBrand).setText(it) + private fun setupDatePickers() { + binding.getManufacturedDate.setOnClickListener { + showDatePicker(manufacturedDate) { date -> + manufacturedDate = date + binding.getManufacturedDate.setText(date.format(dateFormatter)) + } } - main.materialName?.takeIf { it.isNotBlank() }?.let { - findViewById(R.id.getMaterialName).setText(it) + + binding.getExpirationDate.setOnClickListener { + showDatePicker(expirationDate) { date -> + expirationDate = date + binding.getExpirationDate.setText(date.format(dateFormatter)) + } } + } - main.primaryColor?.takeIf { it.isNotBlank() }?.let { colorHex -> - findViewById(R.id.getColor).setText(colorHex) - // If you have a color preview button, update it here too - try { - val colorInt = Color.parseColor("#$colorHex") - findViewById