diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..0f4770d --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,74 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle + +name: Native Distributions + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop, main ] + +jobs: + build-ubuntu: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: JDK Ubuntu + uses: actions/setup-java@v2 + with: + java-package: jdk+fx + java-version: '17' + distribution: 'zulu' + - name: Gradle Ubuntu + uses: gradle/gradle-build-action@937999e9cc2425eddc7fd62d1053baf041147db7 + with: + arguments: -PcomposeBuildOs=ubuntu packageDeb + - name: Upload DEB + uses: actions/upload-artifact@v2.3.1 + with: + name: ubuntu + path: build/compose/binaries/main/deb + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: JDK Windows + uses: actions/setup-java@v2 + with: + java-package: jdk+fx + java-version: '17' + distribution: 'zulu' + - name: Gradle Windows + uses: gradle/gradle-build-action@937999e9cc2425eddc7fd62d1053baf041147db7 + with: + arguments: -PcomposeBuildOs=windows packageExe + - name: Upload EXE + uses: actions/upload-artifact@v2.3.1 + with: + name: windows + path: build/compose/binaries/main/exe + build-mac: + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + - name: JDK macOS + uses: actions/setup-java@v2 + with: + java-package: jdk+fx + java-version: '17' + distribution: 'zulu' + - name: Gradle Mac + uses: gradle/gradle-build-action@937999e9cc2425eddc7fd62d1053baf041147db7 + with: + arguments: -PcomposeBuildOs=mac packageDmg + - name: Upload DMG + uses: actions/upload-artifact@v2.3.1 + with: + name: macos-intel + path: build/compose/binaries/main/dmg + diff --git a/README.md b/README.md index 9d9daab..08e7593 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,43 @@ # TerminoDiff - Diff for 🔥 Terminology -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.5898497.svg)](https://doi.org/10.5281/zenodo.5898497) +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.5898497.svg)](https://doi.org/10.5281/zenodo.5898497) [![Native Distributions](https://github.com/itcr-uni-luebeck/TerminoDiff/actions/workflows/gradle.yml/badge.svg?branch=develop)](https://github.com/itcr-uni-luebeck/TerminoDiff/actions/workflows/gradle.yml) -TerminoDiff is a graphical application to quickly compare [HL7 FHIR CodeSystem resources](https://www.hl7.org/fhir/codesystem.html). +TerminoDiff is a graphical application to quickly +compare [HL7 FHIR CodeSystem resources](https://www.hl7.org/fhir/codesystem.html). ## Why this app? -Determining how HL7 FHIR CodeSystem resources differ proves to be a very difficult task without specialized tooling, since there are many aspects to consider in these resources. This makes maintenance of well-formed FHIR CodeSystem resources much more difficult than it should be. - -| Level | Aspect | Example(-s) | TerminoDiff's Approach | -|---:|---|---|---| -| 0 | Serialization format | JSON vs. XML | Reading using HAPI FHIR allows ignoring wire format,
since the formats are semantically identical | -| 1 | Metadata level | | Presentation as a table (lower half) in the GUI | -| 1.1 | Simple differences | `title`, `name`, `version` | String comparisons | -| 1.2 | Differences within lists | `language`, `version` | (keyed) difference lists, e.g. by using `language.code`
as the key | -| 2 | Concept level | | Presentation as a table (upper half) in the GUI | -| 2.1 | Simple differences | `display`, `designation` | String comparisons | -| 2.2 | Differences within lists | `property`, `designation` | (keyed) difference lists, e.g. by using `property.code`
as the key | -| 2.3 | Unilaterality of concepts | Deletions and additions of codes /
concepts across versions | Surfacing within the table with dedicated filter and highlight | -| 3 | Edge differences | Deletions and additions of
`parent`/`child` properties;
other properties linking concepts
also considered | Creation and display of a difference graph with multiple
color-coded types of edges. | +Determining how HL7 FHIR CodeSystem resources differ proves to be a very difficult task without specialized tooling, +since there are many aspects to consider in these resources. This makes maintenance of well-formed FHIR CodeSystem +resources much more difficult than it should be. + +| Level | Aspect | Example(-s) | TerminoDiff's Approach | +|------:|---------------------------|---------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------| +| 0 | Serialization format | JSON vs. XML | Reading using HAPI FHIR allows ignoring wire format,
since the formats are semantically identical | +| 1 | Metadata level | | Presentation as a table (lower half) in the GUI | +| 1.1 | Simple differences | `title`, `name`, `version` | String comparisons | +| 1.2 | Differences within lists | `language`, `version` | (keyed) difference lists, e.g. by using `language.code`
as the key | +| 2 | Concept level | | Presentation as a table (upper half) in the GUI | +| 2.1 | Simple differences | `display`, `designation` | String comparisons | +| 2.2 | Differences within lists | `property`, `designation` | (keyed) difference lists, e.g. by using `property.code`
as the key | +| 2.3 | Unilaterality of concepts | Deletions and additions of codes /
concepts across versions | Surfacing within the table with dedicated filter and highlight | +| 3 | Edge differences | Deletions and additions of
`parent`/`child` properties;
other properties linking concepts
also considered | Creation and display of a difference graph with multiple
color-coded types of edges. | ## What do I see? -When you start the application, you will have to load two HL7 FHIR CodeSystem resources. It does not matter whether the resources are stored as JSON or XML files, the HAPI FHIR library will take care of this. +When you start the application, you will have to load two HL7 FHIR CodeSystem resources. It does not matter whether the +resources are stored as JSON or XML files, the HAPI FHIR library will take care of this. + +You will be able to load data from the file system, but also from a FHIR Terminology Server: + +![Loading screen for files](images/load-file.png) + +![Loading screen for FHIR Servers](images/load-ts.png) + +If you do not have a FHIR server on your own, you can use the following URLs: + +- https://r4.ontoserver.csiro.au/fhir (a public test instance for Ontoserver, a commercial FHIR Terminology server) +- http://hapi.fhir.org/baseR4 (a public instance of HAPI FHIR JPA Server) Once loaded, you will be presented with the view of differences over the two loaded CodeSystems: @@ -30,42 +45,62 @@ Once loaded, you will be presented with the view of differences over the two loa The app is also translated into German; and a dark theme is implemented as well: -![TerminoDiff Main Screen for Oncotree, dark theme, German localization](images/main-oncotree-dark-de.png) +![TerminoDiff Main Screen for OncoTree, dark theme, German localization](images/main-oncotree-dark-de.png) + +Many columns are searchable (using a fuzzy search function, so that near matches can be found as well). This is +indicated by looking glass icons. Searches can be combined as well, by specifying multiple filters. + +The fuzzy search requires a partial match of at least 75 % similarity. ### Top section -In the top half of the app, you can inspect the concept differences in more detail. The filters at the top allow you to select different subsets of the concepts - you can view all concepts; those that are identical; those that are different (this is the default view); those where the concept is in both sides of the comparison, but different; and those that are only in one of the two CodeSystems. Differences are highlighted using colors. +In the top half of the app, you can inspect the concept differences in more detail. The filters at the top allow you to +select different subsets of the concepts - you can view all concepts; those that are identical; those that are +different (this is the default view); those where the concept is in both sides of the comparison, but different; and +those that are only in one of the two CodeSystems. Differences are highlighted using colors. The *Properties / Designations* buttons are clickable to reveal a more detailed comparison: ![Properties and designations for a concept in OncoTree](images/properties-oncotree.png) -If the concept is unilateral (only in left/only in right), the dialog is accessible regardless. +If the concept is unilateral (only in left / only in right), the dialog is accessible regardless. ### Bottom section -This section represents differences in the CodeSystem metadata. Attributes are represented as rows in the table, and the respective value is shown in the right-hand part. Every metadata comparison item is compared and represented using colored chips. +This section represents differences in the CodeSystem metadata. Attributes are represented as rows in the table, and the +respective value is shown in the right-hand part. Every metadata comparison item is compared and represented using +colored chips. -If the value is identical, the columns are merged, otherwise there will be a left and a right value. For properties that are lists of values in FHIR, such as `Identifier`, the colored chip will be a clickable button (as long as there is data in that property): +If the value is identical, the columns are merged, otherwise there will be a left and a right value. For properties that +are lists of values in FHIR, such as `Identifier`, the colored chip will be a clickable button (as long as there is data +in that property): ![Identifiers for a fictitious test CodeSystem](images/identifiers-testcs.png) ### Difference Graph -At the very top of the app, there are three buttons to view a graphical representation of the two CodeSystems, as well as the computed difference graph. To illustrate, consider these two CodeSystems: +At the very top of the app, there are three buttons to view a graphical representation of the two CodeSystems, as well +as the computed difference graph. To illustrate, consider these two CodeSystems: + +| Left CS | Right CS | +|-----------------------------------------------|-------------------------------------------------| +| ![Left CS for diff graph](images/left-cs.png) | ![Right CS for diff graph](images/right-cs.png) | -|Left CS|Right CS| -|--|--| -|![Left CS for diff graph](images/left-cs.png)|![Right CS for diff graph](images/right-cs.png)| -Going from "left" to "right", the concept `C` was removed, leading to changes in the edge going from `D` to `A`. Also, a new type of edge, `related-to` was introduced. +Going from "left" to "right", the concept `C` was removed, leading to changes in the edge going from `D` to `A`. Also, a +new type of edge, `related-to` was introduced. -The dashed edges `child` are special, since these are handled internally as `parent` edges. This is since FHIR specifies three ways of handling `parent-child` relationships: - 1. the `concept` property within `concept`, allowing arbitrarily deep nesting, - 2. the implicit `parent` property, - 3. and/or the implicit `child` property. +The dashed edges `child` are special, since these are handled internally as `parent` edges. This is since FHIR specifies +three ways of handling `parent-child` relationships: -Since these properties are to be [handled in the same fashion by implementing applications](https://www.hl7.org/fhir/codesystem.html#hierarchy), we reduce all of these three options to the "canonical" `parent` property (which allows a polyhierarchy, which `concept` does not). +1. the `concept` property within `concept`, allowing arbitrarily deep nesting, +2. the implicit `parent` property, +3. and/or the implicit `child` property. + +Since these properties are to +be [handled in the same fashion by implementing applications](https://www.hl7.org/fhir/codesystem.html#hierarchy), we +reduce all of these three options to the "canonical" `parent` property (which allows a poly-hierarchy, which `concept` +does not). The difference graph for these two fictitious CodeSystem resources could look like this: @@ -77,31 +112,41 @@ The difference graph implemented in the application looks very similar: ![Difference graph for example CodeSystems in the app](images/diff-app.png) -At the top of the dialog, you can choose from any of the available layout algorithms. Since CodeSystems generally have directed edges, and are often hierarchical, we find that the *Sugiyama* and *Eiglsperger* algorithms do a fine job at visualizing these graphs. +At the top of the dialog, you can choose from any of the available layout algorithms. Since CodeSystems generally have +directed edges, and are often hierarchical, we find that the *Sugiyama* and *Eiglsperger* algorithms do a fine job at +visualizing these graphs. ## How do I run this? -There are executable distributions [available under the Releases section](https://github.com/itcr-uni-luebeck/TerminoDiff/releases/latest). +There are executable +distributions [available under the Releases section](https://github.com/itcr-uni-luebeck/TerminoDiff/releases/latest). Executables are available for: + * Windows 64-bit * macOS Intel x64 (built on Monterey) * macOS aarch64 (built on Monterey) * Debian and derivatives, 64-bit (built on Ubuntu) * RPM-based distributions, 64-bit (built on Fedora) -These releases include a Java VM and the needed run-time components, and can be run as-is. The macOS binaries are code-signed, but not notarized by Apple, and may ask for permission to run. +These releases include a Java VM and the needed run-time components, and can be run as-is. The macOS binaries are +code-signed, but not notarized by Apple, and may ask for permission to run. -You can build the project yourself using Gradle, either from the console or via an IDE such as IntelliJ. If you want to build a native distribution yourself, you might need to edit the `build.gradle.kts` to only include the operating system and target format you require - cross-building is not yet supported by the Compose Desktop framework. +You can build the project yourself using Gradle, either from the console or via an IDE such as IntelliJ. If you want to +build a native distribution yourself, you might need to edit the `build.gradle.kts` to only include the operating system +and target format you require - cross-building is not yet supported by the Compose Desktop framework. -If you want to run from code, use at least JDK 11. If you want to build a native distribution, you need JDK 15 or later (17 recommended), since `jpackage` is not available in prior versions. +If you want to run from code, use at least JDK 11. If you want to build a native distribution, you need JDK 15 or +later (17 recommended), since `jpackage` is not available in prior versions. ## How is this built? -The application is written in Kotlin and utilizes [JetBrains' *Compose Multiplatform*](https://github.com/JetBrains/compose-jb) toolkit. This toolkit brings the declarative *Jetpack Compose* library from Android -over to the desktop, allowing a Kotlin-first approach to GUI development. +The application is written in Kotlin and utilizes [JetBrains' *Compose +Multiplatform*](https://github.com/JetBrains/compose-jb) toolkit. This toolkit brings the declarative *Jetpack Compose* +library from Android over to the desktop, allowing a Kotlin-first approach to GUI development. We utilize the following libraries alongside *Compose*: + - [HAPI FHIR](https://hapifhir.io) for processing FHIR resources - [slf4j](https://www.slf4j.org) for logging - [JGraphT](https://jgrapht.org) for representation of CodeSystem and diff graphs @@ -110,37 +155,63 @@ We utilize the following libraries alongside *Compose*: - [NativeJFileChooser](https://github.com/veluria/NativeJFileChooser) for the file selection dialog on Windows and Linux - [Apache Commons Lang](https://commons.apache.org/proper/commons-lang/) - [FlatLaf](https://www.formdev.com/flatlaf/) for dark window chrome on Windows +- [ktor](https://ktor.io) for coroutine-based HTTP +- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python ### Localization -The localization framework was built from scratch in the file [LocalizedStrings.kt](src\main\kotlin\terminodiff\i18n\LocalizedStrings.kt), since no suitable alternative for localization in Kotlin could be found. Currently, we support English (default) and German strings. Every component that displays strings receives an instance of `LocalizedStrings`, which declares a number of properties that are implemented in `EnglishStrings` and `GermanStrings`. Default arguments in `LocalizesStrings` represent strings that are identical in English and German - if you implement another language, you may need to make some default propertis explicit in the derived classes. +The localization framework was built from scratch in the +file [LocalizedStrings.kt](src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt), since no suitable alternative for +localization in Kotlin could be found. Currently, we support English (default) and German strings. Every component that +displays strings receives an instance of `LocalizedStrings`, which declares a number of properties that are implemented +in `EnglishStrings` and `GermanStrings`. Default arguments in `LocalizesStrings` represent strings that are identical in +English and German - if you implement another language, you may need to make some default properties explicit in the +derived classes. -Members with a trailing underscore represent formatting functions that are implemented as Lambda expressions. These are referenced from the GUI using `localizesStrings.member_.invoke(param1, param2)`. +Members with a trailing underscore represent formatting functions that are implemented as Lambda expressions. These are +referenced from the GUI using `localizesStrings.member_.invoke(param1, param2)`. ### Tables -Since `DataTables` are not (anymore) part of the Compose toolkit, we implemented our own table component. The generic implementation takes in a list of column specifications that render the provided type paramater using a composable body. The table supports merging adjacent columns (if a predicate returns `true`), tooltips (e.g. on the lower table, when English is not selected as the language, the default name of the FHIR attribute is shown in the tooltip of the left-hand column), and zebra striping. In the top table, the table data is also pre-filtered using a generic set of filter buttons. +Since `DataTables` are not (anymore) part of the Compose toolkit, we implemented our own table component. The generic +implementation takes in a list of column specifications that render the provided type parameter using a composable body. +The table supports merging adjacent columns (if a predicate returns `true`), tooltips (e.g. on the lower table, when +English is not selected as the language, the default name of the FHIR attribute is shown in the tooltip of the left-hand +column), and zebra striping. In the top table, the table data is also pre-filtered using a generic set of filter +buttons. ### Graph window -While interoperability between Kotlin and Java is generally very good, the `jungrapht-visualization` library is better called from Java than from Kotlin. Also, the performance of integrating these heavy components into the composable framemwork is not sufficient, so that these windows are implemented using Swing instead of Compose. +While interoperability between Kotlin and Java is generally very good, the `jungrapht-visualization` library is better +called from Java than from Kotlin. Also, the performance of integrating these heavy components into the composable +framework is not sufficient, so that these windows are implemented using Swing instead of Compose. -The Swing code makes liberal use of the sample implementations in the [jungrapht-visualization](https://github.com/tomnelson/jungrapht-visualization) libraries, e.g. for the rubber-banding satellite viewer. The graph viewer supports a range of mouse operations, [explained in more detail in the documentation](https://github.com/tomnelson/jungrapht-visualization/blob/master/MouseGestures.md). +The Swing code makes liberal use of the sample implementations in +the [jungrapht-visualization](https://github.com/tomnelson/jungrapht-visualization) libraries, e.g. for the +rubber-banding satellite viewer. The graph viewer supports a range of mouse +operations, [explained in more detail in the documentation](https://github.com/tomnelson/jungrapht-visualization/blob/master/MouseGestures.md) +. ## Metadata differences -We have implemented a very generic difference engine for the metadata table, so that new diff items can be added very easily. Most elements are basically string comparisons, which can be added using a single line of code in [MetadataDiffItems.kt](src\main\kotlin\terminodiff\engine\metadata\MetadataDiff.kt) (function `generateComparisonDefinitions`). Stuff like `identifiers` are a bit more involved, since these require the definition of a key and a string representation of the value, as well as column definitions for the key columns (to render the details dialog shown above), but are also not very challenging to implement. +We have implemented a very generic difference engine for the metadata table, so that new diff items can be added very +easily. Most elements are basically string comparisons, which can be added using a single line of code +in [MetadataDiffItems.kt](src/main/kotlin/terminodiff/engine/metadata/MetadataDiff.kt) ( +function `generateComparisonDefinitions`). Stuff like `identifiers` are a bit more involved, since these require the +definition of a key and a string representation of the value, as well as column definitions for the key columns (to +render the details dialog shown above), but are also not very challenging to implement. ## What's planned? We are looking at implementing the following features: -- a search for concepts in the CodeSystems by `code` and other characteristics -- filters for the metadata table, similar to those in the concept table -- a visualization of the neighborhood of any concept in the graph, to view the connections a concept has across the network of concepts - - integrating this feature into the difference graph, so that layers of context can be added iteratively -- query of resources from FHIR Terminology servers (by physical URL and canonical URL plus version) -- support for the `vread` mechanism to compare across instance versions on FHIR Terminology servers +- [x] a search for concepts in the CodeSystems by `code` and other characteristics +- [x] filters for the metadata table, similar to those in the concept table +- [x] query of resources from FHIR Terminology servers (by physical URL and canonical URL plus version) +- [x] support for the `vread` mechanism to compare across instance versions on FHIR Terminology servers +- [ ] a visualization of the neighborhood of any concept in the graph, to view the connections a concept has across the + network of concepts + - integrating this feature into the difference graph, so that layers of context can be added iteratively - support for other types of resources in FHIR, especially `ValueSet` and `ConceptMap`, likely with TS support. ## How do I cite this? @@ -155,6 +226,8 @@ Wiedekopf, Joshua, et al. (2022). TerminoDiff. Zenodo. https://doi.org/10.5281/z ## Can I help? -Absolutely 🔥! Please feel free to open an issue if you would like another feature added to the app. We are committed to improving on this app to provide a better experience for terminologists around the globe. +Absolutely 🔥! Please feel free to open an issue if you would like another feature added to the app. We are committed to +improving on this app to provide a better experience for terminologists around the globe. -If you improve on this app, we only ask that your changes remain freely accessible, and that you create a pull request on GitHub. Thanks! +If you improve on this app, we only ask that your changes remain freely accessible, and that you create a pull request +on GitHub. Thanks! diff --git a/build.gradle.kts b/build.gradle.kts index b449eb5..f9deaa7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,11 +5,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") version "1.5.31" id("org.jetbrains.compose") version "1.0.0" - id("org.openjfx.javafxplugin") version "0.0.10" + id("org.openjfx.javafxplugin") version "0.0.11" } - +val projectVersion: String by project group = "de.uzl.itcr" -version = "1.0.0" +version = projectVersion repositories { maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") @@ -18,17 +18,21 @@ repositories { } val hapiVersion = "5.6.2" -val slf4jVersion = "1.7.32" +val slf4jVersion = "1.7.35" val graphStreamVersion = "2.0" val jGraphTVersion = "1.5.1" val material3DesktopVersion = "1.0.0" val jungraphtVersion = "1.3" +val composeDesktopVersion = "1.0.1" +val ktorVersion = "2.0.0-beta-1" dependencies { testImplementation(kotlin("test")) implementation(compose.desktop.currentOs) - implementation("org.jetbrains.compose.components:components-splitpane:1.0.1") + implementation("org.jetbrains.compose.components:components-splitpane:$composeDesktopVersion") implementation("org.jetbrains.compose.material3:material3-desktop:$material3DesktopVersion") + implementation("org.jetbrains.compose.material:material-icons-core-desktop:$composeDesktopVersion") + implementation("org.jetbrains.compose.material:material-icons-extended-desktop:$composeDesktopVersion") implementation("ca.uhn.hapi.fhir:hapi-fhir-base:$hapiVersion") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:$hapiVersion") implementation("ca.uhn.hapi.fhir:hapi-fhir-validation:$hapiVersion") @@ -43,7 +47,10 @@ dependencies { implementation("li.flor:native-j-file-chooser:1.6.4") implementation("javax.xml.bind:jaxb-api:2.4.0-b180830.0359") // provides org.xml.sax implementation("org.apache.commons:commons-lang3:3.12.0") - implementation("com.formdev:flatlaf:2.0") + implementation("com.formdev:flatlaf:2.0.1") + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("me.xdrop:fuzzywuzzy:1.4.0") } tasks.test { @@ -66,50 +73,60 @@ tasks.withType { kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" } +val composeBuildVersion: String by project +val composeBuildOs: String? by project + compose.desktop { application { mainClass = "terminodiff.MainKt" - nativeDistributions { - val resourceDir = project.layout.projectDirectory.dir("resources") - appResourcesRootDir.set(resourceDir) - licenseFile.set(project.file("LICENSE")) - packageName = "TerminoDiff" - packageVersion = "1.0.0" - description = "Visually compare HL7 FHIR Terminology" - vendor = "IT Center for Clinical Reserach, University of Lübeck" - copyright = "Joshua Wiedekopf / IT Center for Clinical Research, 2022-" + if (composeBuildOs != null) { + nativeDistributions { + val resourceDir = project.layout.projectDirectory.dir("resources") + appResourcesRootDir.set(resourceDir) + licenseFile.set(project.file("LICENSE")) + packageName = "TerminoDiff" + packageVersion = composeBuildVersion + description = "Visually compare HL7 FHIR Terminology" + vendor = "IT Center for Clinical Reserach, University of Lübeck" + copyright = "Joshua Wiedekopf / IT Center for Clinical Research, 2022-" - /*linux { - iconFile.set(resourceDir.file("common/terminodiff.png")) - rpmLicenseType = "GPL-3.0" - debMaintainer = "j.wiedekopf@uni-luebeck.de" - appCategory = "Development" - targetFormats( - // TargetFormat.Deb, - TargetFormat.Rpm, - TargetFormat.AppImage, - ) - }*/ - /*macOS { - jvmArgs += listOf("-Dskiko.renderApi=SOFTWARE") - bundleID = "de.uzl.itcr.terminodiff" - signing { - sign.set(false) + when (composeBuildOs) { + "ubuntu", "redhat" -> linux { + iconFile.set(resourceDir.file("common/terminodiff.png")) + rpmLicenseType = "GPL-3.0" + debMaintainer = "j.wiedekopf@uni-luebeck.de" + appCategory = "Development" + when (composeBuildOs) { + "ubuntu" -> targetFormats( + TargetFormat.Deb, + ) + "redhat" -> targetFormats( + TargetFormat.Rpm + ) + } + } + "mac" -> macOS { + jvmArgs += listOf("-Dskiko.renderApi=SOFTWARE") + bundleID = "de.uzl.itcr.terminodiff" + signing { + sign.set(false) + } + iconFile.set(resourceDir.file("macos/terminodiff.icns")) + targetFormats( + TargetFormat.Dmg + ) + } + "windows" -> windows { + iconFile.set(resourceDir.file("windows/terminodiff.ico")) + perUserInstall = true + dirChooser = true + upgradeUuid = "ECFA19D9-D1F2-4AF5-9E5E-59A8F21C3A79" + menuGroup = "TerminoDiff" + targetFormats( + TargetFormat.Exe + ) + } } - iconFile.set(resourceDir.file("macos/terminodiff.icns")) - targetFormats( - TargetFormat.Dmg - ) - }*/ - windows { - iconFile.set(resourceDir.file("windows/terminodiff.ico")) - perUserInstall = true - dirChooser = true - upgradeUuid = "ECFA19D9-D1F2-4AF5-9E5E-59A8F21C3A79" - menuGroup = "TerminoDiff" - targetFormats( - TargetFormat.Exe - ) } } } diff --git a/gradle.properties b/gradle.properties index 29e08e8..223f09d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,4 @@ -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +projectVersion=1.1.0 +composeBuildVersion=1.1.0 +composeBuildOs=windows \ No newline at end of file diff --git a/images/main-oncotree-dark-de.png b/images/main-oncotree-dark-de.png index 7660b58..8663f55 100644 Binary files a/images/main-oncotree-dark-de.png and b/images/main-oncotree-dark-de.png differ diff --git a/images/main-oncotree.png b/images/main-oncotree.png index c8870bc..5cb9909 100644 Binary files a/images/main-oncotree.png and b/images/main-oncotree.png differ diff --git a/images/properties-oncotree.png b/images/properties-oncotree.png index 86ae113..bb45590 100644 Binary files a/images/properties-oncotree.png and b/images/properties-oncotree.png differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 74babb1..e909bca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,4 +7,3 @@ pluginManagement { } rootProject.name = "TerminoDiff" - diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 9c612d4..2ddc8a4 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -105,6 +105,7 @@ fun AppWindow( localizedStrings = localizedStrings, diffDataContainer = diffDataContainer, scrollState = scrollState, + fhirContext = fhirContext, useDarkTheme = useDarkTheme, onLocaleChange = { locale = when (locale) { @@ -116,7 +117,7 @@ fun AppWindow( diffDataContainer.localizedStrings = getStrings(locale) }, onChangeDarkTheme = onChangeDarkTheme, - splitPaneState = splitPaneState + splitPaneState = splitPaneState, ) } } diff --git a/src/main/kotlin/libraries/accompanist/pager/Pager.kt b/src/main/kotlin/libraries/accompanist/pager/Pager.kt new file mode 100644 index 0000000..a498828 --- /dev/null +++ b/src/main/kotlin/libraries/accompanist/pager/Pager.kt @@ -0,0 +1,377 @@ +package libraries.accompanist.pager + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import libraries.chrisbanes.snapper.* + + +@RequiresOptIn(message = "Accompanist Pager is experimental. The API may be changed in the future.") +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalPagerApi + +/** + * Contains the default values used by [HorizontalPager] and [VerticalPager]. + */ +@ExperimentalPagerApi +object PagerDefaults { + /** + * The default implementation for the `maximumFlingDistance` parameter of + * [flingBehavior] which limits the fling distance to a single page. + */ + @ExperimentalSnapperApi + @Suppress("MemberVisibilityCanBePrivate") + val singlePageFlingDistance: (SnapperLayoutInfo) -> Float = { layoutInfo -> + // We can scroll up to the scrollable size of the lazy layout + layoutInfo.endScrollOffset - layoutInfo.startScrollOffset.toFloat() + } + + /** + * Remember the default [FlingBehavior] that represents the scroll curve. + * + * Please remember to provide the correct [endContentPadding] if supplying your own + * [FlingBehavior] to [VerticalPager] or [HorizontalPager]. See those functions for how they + * calculate the value. + * + * @param state The [PagerState] to update. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param snapAnimationSpec The animation spec to use when snapping. + * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. + * @param endContentPadding The amount of content padding on the end edge of the lazy list + * in pixels (end/bottom depending on the scrolling direction). + */ + @Composable + @ExperimentalSnapperApi + fun flingBehavior( + state: PagerState, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + snapAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + maximumFlingDistance: (SnapperLayoutInfo) -> Float = singlePageFlingDistance, + endContentPadding: Dp = 0.dp, + ): FlingBehavior = rememberSnapperFlingBehavior( + lazyListState = state.lazyListState, + snapOffsetForItem = SnapOffsets.Start, // pages are full width, so we use the simplest + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = snapAnimationSpec, + maximumFlingDistance = maximumFlingDistance, + endContentPadding = endContentPadding, + ) +} + +/** + * A horizontally scrolling layout that allows users to flip between items to the left and right. + * + * @param count the number of pages. + * @param modifier the modifier to apply to this layout. + * @param state the state object to be used to control or observe the pager's state. + * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be + * composed of the end to the start and [PagerState.currentPage] == 0 will mean + * the first item is located at the end. + * @param itemSpacing horizontal spacing to add between items. + * @param flingBehavior logic describing fling behavior. + * @param key the scroll position will be maintained based on the key, which means if you + * add/remove items before the current visible item the item with the given key will be kept as the + * first visible one. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param content a block which describes the content. Inside this block you can reference + * [PagerScope.currentPage] and other properties in [PagerScope]. + */ +@OptIn(ExperimentalSnapperApi::class) +@ExperimentalPagerApi +@Composable +fun HorizontalPager( + count: Int, + modifier: Modifier = Modifier, + state: PagerState = rememberPagerState(), + reverseLayout: Boolean = false, + itemSpacing: Dp = 0.dp, + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + flingBehavior: FlingBehavior = PagerDefaults.flingBehavior( + state = state, + endContentPadding = contentPadding.calculateEndPadding(LayoutDirection.Ltr), + ), + key: ((page: Int) -> Any)? = null, + userScrollEnabled: Boolean = true, + content: @Composable PagerScope.(page: Int) -> Unit, +) { + Pager( + count = count, + modifier = modifier, + state = state, + reverseLayout = reverseLayout, + itemSpacing = itemSpacing, + isVertical = false, + flingBehavior = flingBehavior, + key = key, + contentPadding = contentPadding, + verticalAlignment = verticalAlignment, + content = content + ) +} + +/** + * A vertically scrolling layout that allows users to flip between items to the top and bottom. + * + * @param count the number of pages. + * @param modifier the modifier to apply to this layout. + * @param state the state object to be used to control or observe the pager's state. + * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be + * composed of the bottom to the top and [PagerState.currentPage] == 0 will mean + * the first item is located at the bottom. + * @param itemSpacing vertical spacing to add between items. + * @param flingBehavior logic describing fling behavior. + * @param key the scroll position will be maintained based on the key, which means if you + * add/remove items before the current visible item the item with the given key will be kept as the + * first visible one. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param content a block which describes the content. Inside this block you can reference + * [PagerScope.currentPage] and other properties in [PagerScope]. + */ +@OptIn(ExperimentalSnapperApi::class) +@ExperimentalPagerApi +@Composable +fun VerticalPager( + count: Int, + modifier: Modifier = Modifier, + state: PagerState = rememberPagerState(), + reverseLayout: Boolean = false, + itemSpacing: Dp = 0.dp, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + flingBehavior: FlingBehavior = PagerDefaults.flingBehavior( + state = state, + endContentPadding = contentPadding.calculateBottomPadding(), + ), + key: ((page: Int) -> Any)? = null, + userScrollEnabled: Boolean = true, + content: @Composable PagerScope.(page: Int) -> Unit, +) { + Pager( + count = count, + modifier = modifier, + state = state, + reverseLayout = reverseLayout, + itemSpacing = itemSpacing, + isVertical = true, + flingBehavior = flingBehavior, + key = key, + contentPadding = contentPadding, + horizontalAlignment = horizontalAlignment, + content = content + ) +} + +@ExperimentalPagerApi +@Composable +internal fun Pager( + count: Int, + modifier: Modifier, + state: PagerState, + reverseLayout: Boolean, + itemSpacing: Dp, + isVertical: Boolean, + flingBehavior: FlingBehavior, + key: ((page: Int) -> Any)?, + contentPadding: PaddingValues, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + content: @Composable (PagerScope.(page: Int) -> Unit), +) { + require(count >= 0) { "pageCount must be >= 0" } + + // Provide our libraries.accompanist.pager.PagerState.kt with access to the SnappingFlingBehavior animation target + // TODO: can this be done in a better way? + state.flingAnimationTarget = { + @OptIn(ExperimentalSnapperApi::class) + (flingBehavior as? SnapperFlingBehavior)?.animationTarget + } + + LaunchedEffect(count) { + state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0) + } + + // Once a fling (scroll) has finished, notify the state + LaunchedEffect(state) { + // When a 'scroll' has finished, notify the state + snapshotFlow { state.isScrollInProgress } + .filter { !it } + // initially isScrollInProgress is false as well, and we want to start receiving + // the events only after the real scroll happens. + .drop(1) + .collectLatest { state.onScrollFinished() } + //.collect { state.onScrollFinished() } + } + LaunchedEffect(state) { + snapshotFlow { state.currentLayoutPageInfo } + // we want to react on the currentLayoutPageInfo changes happened not because of the + // scroll. for example the current page could change because the items were reordered. + .filter { !state.isScrollInProgress } + .collectLatest { state.updateCurrentPageBasedOnLazyListState() } + //.collect { state.updateCurrentPageBasedOnLazyListState() } + } + + val pagerScope = remember(state) { PagerScopeImpl(state) } + + // We only consume nested flings in the main-axis, allowing cross-axis flings to propagate + // as normal + val consumeFlingNestedScrollConnection = ConsumeFlingNestedScrollConnection( + consumeHorizontal = !isVertical, + consumeVertical = isVertical, + ) + + if (isVertical) { + LazyColumn( + state = state.lazyListState, + verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment), + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + //userScrollEnabled = userScrollEnabled, + modifier = modifier, + ) { + items( + count = count, + key = key, + ) { page -> + Box( + Modifier + // We don't have any nested flings to continue in the pager, so we add a + // connection which consumes them. + // See: https://github.com/google/accompanist/issues/347 + .nestedScroll(connection = consumeFlingNestedScrollConnection) + // Constraint the content height to be <= than the height of the pager. + .fillParentMaxHeight() + .wrapContentSize() + ) { + pagerScope.content(page) + } + } + } + } else { + LazyRow( + state = state.lazyListState, + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.spacedBy(itemSpacing, horizontalAlignment), + flingBehavior = flingBehavior, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + //userScrollEnabled = userScrollEnabled, + modifier = modifier, + ) { + items( + count = count, + key = key, + ) { page -> + Box( + Modifier + // We don't have any nested flings to continue in the pager, so we add a + // connection which consumes them. + // See: https://github.com/google/accompanist/issues/347 + .nestedScroll(connection = consumeFlingNestedScrollConnection) + // Constraint the content width to be <= than the width of the pager. + .fillParentMaxWidth() + .wrapContentSize() + ) { + pagerScope.content(page) + } + } + } + } +} + +private class ConsumeFlingNestedScrollConnection( + private val consumeHorizontal: Boolean, + private val consumeVertical: Boolean, +) : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset = when (source) { + // We can consume all resting fling scrolls so that they don't propagate up to the + // Pager + NestedScrollSource.Fling -> available.consume(consumeHorizontal, consumeVertical) + else -> Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // We can consume all post fling velocity on the main-axis + // so that it doesn't propagate up to the Pager + return available.consume(consumeHorizontal, consumeVertical) + } +} + +private fun Offset.consume( + consumeHorizontal: Boolean, + consumeVertical: Boolean, +): Offset = Offset( + x = if (consumeHorizontal) this.x else 0f, + y = if (consumeVertical) this.y else 0f, +) + +private fun Velocity.consume( + consumeHorizontal: Boolean, + consumeVertical: Boolean, +): Velocity = Velocity( + x = if (consumeHorizontal) this.x else 0f, + y = if (consumeVertical) this.y else 0f, +) + +/** + * Scope for [HorizontalPager] content. + */ +@ExperimentalPagerApi +@Stable +interface PagerScope { + /** + * Returns the current selected page + */ + val currentPage: Int + + /** + * The current offset from the start of [currentPage], as a ratio of the page width. + */ + val currentPageOffset: Float +} + +@ExperimentalPagerApi +private class PagerScopeImpl( + private val state: PagerState, +) : PagerScope { + override val currentPage: Int get() = state.currentPage + override val currentPageOffset: Float get() = state.currentPageOffset +} + +/** + * Calculate the offset for the given [page] from the current scroll position. This is useful + * when using the scroll position to apply effects or animations to items. + * + * The returned offset can positive or negative, depending on whether which direction the [page] is + * compared to the current scroll position. + */ +@ExperimentalPagerApi +fun PagerScope.calculateCurrentOffsetForPage(page: Int): Float { + return (currentPage + currentPageOffset) - page +} \ No newline at end of file diff --git a/src/main/kotlin/libraries/accompanist/pager/PagerState.kt b/src/main/kotlin/libraries/accompanist/pager/PagerState.kt new file mode 100644 index 0000000..1f9bcb9 --- /dev/null +++ b/src/main/kotlin/libraries/accompanist/pager/PagerState.kt @@ -0,0 +1,321 @@ +package libraries.accompanist.pager + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +/** + * Creates a [PagerState] that is remembered across compositions. + * + * Changes to the provided values for [initialPage] will **not** result in the state being + * recreated or changed in any way if it has already + * been created. + * + * @param initialPage the initial value for [PagerState.currentPage] + */ +@ExperimentalPagerApi +@Composable +fun rememberPagerState( + initialPage: Int = 0, +): PagerState = rememberSaveable(saver = PagerState.Saver) { + PagerState( + currentPage = initialPage, + ) +} + +/** + * A state object that can be hoisted to control and observe scrolling for [HorizontalPager]. + * + * In most cases, this will be created via [rememberPagerState]. + * + * @param currentPage the initial value for [PagerState.currentPage] + */ +@ExperimentalPagerApi +@Stable +class PagerState( + currentPage: Int = 0, +) : ScrollableState { + // Should this be public? + internal val lazyListState = LazyListState(firstVisibleItemIndex = currentPage) + + private var _currentPage by mutableStateOf(currentPage) + + internal val currentLayoutPageInfo: LazyListItemInfo? + get() = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull { it.offset <= 0 } + + private val currentLayoutPageOffset: Float + get() = currentLayoutPageInfo?.let { current -> + // We coerce since itemSpacing can make the offset > 1f. + // We don't want to count spacing in the offset so cap it to 1f + (-current.offset / current.size.toFloat()).coerceIn(0f, 1f) + } ?: 0f + + /** + * [InteractionSource] that will be used to dispatch drag events when this + * list is being dragged. If you want to know whether the fling (or animated scroll) is in + * progress, use [isScrollInProgress]. + */ + val interactionSource: InteractionSource + get() = lazyListState.interactionSource + + /** + * The number of pages to display. + */ + val pageCount: Int by derivedStateOf { + lazyListState.layoutInfo.totalItemsCount + } + + /** + * The index of the currently selected page. This may not be the page which is + * currently displayed on screen. + * + * To update the scroll position, use [scrollToPage] or [animateScrollToPage]. + */ + var currentPage: Int + get() = _currentPage + internal set(value) { + if (value != _currentPage) { + _currentPage = value + } + } + + /** + * The current offset from the start of [currentPage], as a ratio of the page width. + * + * To update the scroll position, use [scrollToPage] or [animateScrollToPage]. + */ + val currentPageOffset: Float by derivedStateOf { + currentLayoutPageInfo?.let { + // The current page offset is the current layout page delta from `currentPage` + // (which is only updated after a scroll/animation). + // We calculate this by looking at the current layout page + it's offset, + // then subtracting the 'current page'. + it.index + currentLayoutPageOffset - _currentPage + } ?: 0f + } + + /** + * The target page for any ongoing animations. + */ + private var animationTargetPage: Int? by mutableStateOf(null) + + internal var flingAnimationTarget: (() -> Int?)? by mutableStateOf(null) + + /** + * The target page for any ongoing animations or scrolls by the user. + * Returns the current page if a scroll or animation is not currently in progress. + */ + val targetPage: Int + get() = animationTargetPage + ?: flingAnimationTarget?.invoke() + ?: when { + // If a scroll isn't in progress, return the current page + !isScrollInProgress -> currentPage + // If the offset is 0f (or very close), return the current page + currentPageOffset.absoluteValue < 0.001f -> currentPage + // If we're offset towards the start, guess the previous page + currentPageOffset < 0f -> (currentPage - 1).coerceAtLeast(0) + // If we're offset towards the end, guess the next page + else -> (currentPage + 1).coerceAtMost(pageCount - 1) + } + + @Deprecated( + "Replaced with animateScrollToPage(page, pageOffset)", + ReplaceWith("animateScrollToPage(page = page, pageOffset = pageOffset)") + ) + @Suppress("UNUSED_PARAMETER") + suspend fun animateScrollToPage( + page: Int, + pageOffset: Float = 0f, + animationSpec: AnimationSpec = spring(), + initialVelocity: Float = 0f, + skipPages: Boolean = true, + ) { + animateScrollToPage(page = page, pageOffset = pageOffset) + } + + /** + * Animate (smooth scroll) to the given page to the middle of the viewport. + * + * Cancels the currently running scroll, if any, and suspends until the cancellation is + * complete. + * + * @param page the page to animate to. Must be between 0 and [pageCount] (inclusive). + * @param pageOffset the percentage of the page width to offset, from the start of [page]. + * Must be in the range 0f..1f. + */ + suspend fun animateScrollToPage( + page: Int, + pageOffset: Float = 0f, + ) { + requireCurrentPage(page, "page") + requireCurrentPageOffset(pageOffset, "pageOffset") + try { + animationTargetPage = page + + // pre-jump to nearby item for long jumps as an optimization + // the same trick is done in ViewPager2 + val oldPage = lazyListState.firstVisibleItemIndex + if (abs(page - oldPage) > 3) { + lazyListState.scrollToItem(if (page > oldPage) page - 3 else page + 3) + } + + if (pageOffset <= 0.005f) { + // If the offset is (close to) zero, just call animateScrollToItem, and we're done + lazyListState.animateScrollToItem(index = page) + } else { + // Else we need to figure out what the offset is in pixels... + + var target = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == page } + + if (target != null) { + // If we have access to the target page layout, we can calculate the pixel + // offset from the size + lazyListState.animateScrollToItem( + index = page, + scrollOffset = (target.size * pageOffset).roundToInt() + ) + } else { + // If we don't, we use the current page size as a guide + val currentSize = currentLayoutPageInfo!!.size + lazyListState.animateScrollToItem( + index = page, + scrollOffset = (currentSize * pageOffset).roundToInt() + ) + + // The target should be visible now + target = lazyListState.layoutInfo.visibleItemsInfo.first { it.index == page } + + if (target.size != currentSize) { + // If the size we used for calculating the offset differs from the actual + // target page size, we need to scroll again. This doesn't look great, + // but there's not much else we can do. + lazyListState.animateScrollToItem( + index = page, + scrollOffset = (target.size * pageOffset).roundToInt() + ) + } + } + } + } finally { + // We need to manually call this, as the `animateScrollToItem` call above will happen + // in 1 frame, which is usually too fast for the LaunchedEffect in Pager to detect + // the change. This is especially true when running unit tests. + onScrollFinished() + } + } + + /** + * Instantly brings the item at [page] to the middle of the viewport. + * + * Cancels the currently running scroll, if any, and suspends until the cancellation is + * complete. + * + * @param page the page to snap to. Must be between 0 and [pageCount] (inclusive). + */ + suspend fun scrollToPage( + page: Int, + pageOffset: Float = 0f, + ) { + requireCurrentPage(page, "page") + requireCurrentPageOffset(pageOffset, "pageOffset") + try { + animationTargetPage = page + + // First scroll to the given page. It will now be laid out at offset 0 + lazyListState.scrollToItem(index = page) + + // If we have a start spacing, we need to offset (scroll) by that too + if (pageOffset > 0.0001f) { + scroll { + currentLayoutPageInfo?.let { + scrollBy(it.size * pageOffset) + } + } + } + } finally { + // We need to manually call this, as the `scroll` call above will happen in 1 frame, + // which is usually too fast for the LaunchedEffect in Pager to detect the change. + // This is especially true when running unit tests. + onScrollFinished() + } + } + + internal fun updateCurrentPageBasedOnLazyListState() { + // Then update the current page to our layout page + currentPage = currentLayoutPageInfo?.index ?: 0 + } + + internal fun onScrollFinished() { + updateCurrentPageBasedOnLazyListState() + // Clear the animation target page + animationTargetPage = null + } + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) = lazyListState.scroll(scrollPriority, block) + + override fun dispatchRawDelta(delta: Float): Float { + return lazyListState.dispatchRawDelta(delta) + } + + override val isScrollInProgress: Boolean + get() = lazyListState.isScrollInProgress + + override fun toString(): String = "libraries.accompanist.pager.PagerState(" + + "pageCount=$pageCount, " + + "currentPage=$currentPage, " + + "currentPageOffset=$currentPageOffset" + + ")" + + private fun requireCurrentPage(value: Int, name: String) { + if (pageCount == 0) { + require(value == 0) { "$name must be 0 when pageCount is 0" } + } else { + require(value in 0 until pageCount) { + "$name[$value] must be >= 0 and < pageCount" + } + } + } + + private fun requireCurrentPageOffset(value: Float, name: String) { + if (pageCount == 0) { + require(value == 0f) { "$name must be 0f when pageCount is 0" } + } else { + require(value in 0f..1f) { "$name must be >= 0 and <= 1" } + } + } + + companion object { + /** + * The default [Saver] implementation for [PagerState]. + */ + val Saver: Saver = listSaver( + save = { + listOf( + it.currentPage, + ) + }, + restore = { + PagerState( + currentPage = it[0] as Int, + ) + } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/libraries/accompanist/pager/README.md b/src/main/kotlin/libraries/accompanist/pager/README.md new file mode 100644 index 0000000..aea3c7d --- /dev/null +++ b/src/main/kotlin/libraries/accompanist/pager/README.md @@ -0,0 +1,15 @@ +Copied from https://github.com/google/accompanist/tree/main/pager/src/main/java/com/google/accompanist/pager + +Copyright 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/src/main/kotlin/libraries/chrisbanes/snapper/LazyList.kt b/src/main/kotlin/libraries/chrisbanes/snapper/LazyList.kt new file mode 100644 index 0000000..a665f8a --- /dev/null +++ b/src/main/kotlin/libraries/chrisbanes/snapper/LazyList.kt @@ -0,0 +1,232 @@ +package libraries.chrisbanes.snapper + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.* + +/** +* Create and remember a snapping FlingBehavior to be used with [LazyListState]. +* +* This is a convenience function for using [rememberLazyListSnapperLayoutInfo] and +* [rememberSnapperFlingBehavior]. If you require access to the layout info, you can safely use +* those APIs directly. +* +* @param lazyListState The [LazyListState] to update. +* @param snapOffsetForItem Block which returns which offset the given item should 'snap' to. +* See [SnapOffsets] for provided values. +* @param endContentPadding The amount of content padding on the end edge of the lazy list +* in dps (end/bottom depending on the scrolling direction). +* @param decayAnimationSpec The decay animation spec to use for decayed flings. +* @param springAnimationSpec The animation spec to use when snapping. +* @param maximumFlingDistance Block which returns the maximum fling distance in pixels. +* The returned value should be > 0. +*/ +@ExperimentalSnapperApi +@Composable +fun rememberSnapperFlingBehavior( + lazyListState: LazyListState, + snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center, + endContentPadding: Dp = 0.dp, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance, +): SnapperFlingBehavior = rememberSnapperFlingBehavior( + layoutInfo = rememberLazyListSnapperLayoutInfo( + lazyListState = lazyListState, + snapOffsetForItem = snapOffsetForItem, + endContentPadding = endContentPadding + ), + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + maximumFlingDistance = maximumFlingDistance +) + +/** + * Create and remember a [SnapperLayoutInfo] which works with [LazyListState]. + * + * @param lazyListState The [LazyListState] to update. + * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to. + * See [SnapOffsets] for provided values. + * @param endContentPadding The amount of content padding on the end edge of the lazy list + * in dps (end/bottom depending on the scrolling direction). + */ +@ExperimentalSnapperApi +@Composable +fun rememberLazyListSnapperLayoutInfo( + lazyListState: LazyListState, + snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center, + endContentPadding: Dp = 0.dp, +): LazyListSnapperLayoutInfo = remember(lazyListState, snapOffsetForItem) { + LazyListSnapperLayoutInfo( + lazyListState = lazyListState, + snapOffsetForItem = snapOffsetForItem, + ) +}.apply { + this.endContentPadding = with(LocalDensity.current) { endContentPadding.roundToPx() } +} + +/** + * A [SnapperLayoutInfo] which works with [LazyListState]. Typically, this would be remembered + * using [rememberLazyListSnapperLayoutInfo]. + * + * @param lazyListState The [LazyListState] to update. + * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to. + * See [SnapOffsets] for provided values. + * @param endContentPadding The amount of content padding on the end edge of the lazy list + * in pixels (end/bottom depending on the scrolling direction). + */ +@ExperimentalSnapperApi +class LazyListSnapperLayoutInfo( + private val lazyListState: LazyListState, + private val snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int, + endContentPadding: Int = 0, +) : SnapperLayoutInfo() { + override val startScrollOffset: Int = 0 + + internal var endContentPadding: Int by mutableStateOf(endContentPadding) + + override val endScrollOffset: Int + get() = lazyListState.layoutInfo.viewportEndOffset - endContentPadding + + private val itemCount: Int get() = lazyListState.layoutInfo.totalItemsCount + + override val currentItem: SnapperLayoutItemInfo? + get() = visibleItems.lastOrNull { it.offset <= snapOffsetForItem(this, it) } + + override val visibleItems: Sequence + get() = lazyListState.layoutInfo.visibleItemsInfo.asSequence() + .map(::LazyListSnapperLayoutItemInfo) + + override fun distanceToIndexSnap(index: Int): Int { + val itemInfo = visibleItems.firstOrNull { it.index == index } + if (itemInfo != null) { + // If we have the item visible, we can calculate using the offset. Woop. + return itemInfo.offset - snapOffsetForItem(this, itemInfo) + } + + // Otherwise, we need to guesstimate, using the current item snap point and + // multiplying distancePerItem by the index delta + val currentItem = currentItem ?: return 0 // TODO: throw? + return ((index - currentItem.index) * estimateDistancePerItem()).roundToInt() + + currentItem.offset - + snapOffsetForItem(this, currentItem) + } + + override fun canScrollTowardsStart(): Boolean { + return lazyListState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { + it.index > 0 || it.offset < startScrollOffset + } ?: false + } + + override fun canScrollTowardsEnd(): Boolean { + return lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { + it.index < itemCount - 1 || (it.offset + it.size) > endScrollOffset + } ?: false + } + + override fun determineTargetIndex( + velocity: Float, + decayAnimationSpec: DecayAnimationSpec, + maximumFlingDistance: Float, + ): Int { + val curr = currentItem ?: return -1 + + val distancePerItem = estimateDistancePerItem() + if (distancePerItem <= 0) { + // If we don't have a valid distance, return the current item + return curr.index + } + + val distanceToCurrent = distanceToIndexSnap(curr.index) + val distanceToNext = distanceToIndexSnap(curr.index + 1) + + if (abs(velocity) < 0.5f) { + // If we don't have a velocity, target whichever item is closer + return when { + distanceToCurrent.absoluteValue < distanceToNext.absoluteValue -> curr.index + else -> curr.index + 1 + }.coerceIn(0, itemCount - 1) + } + + // Otherwise, we calculate using the velocity + val flingDistance = decayAnimationSpec.calculateTargetValue(0f, velocity) + .coerceIn(-maximumFlingDistance, maximumFlingDistance) + .let { distance -> + // It's likely that the user has already scrolled an amount before the fling + // has been started. We compensate for that by removing the scrolled distance + // from the calculated fling distance. This is necessary so that we don't fling + // past the max fling distance. + if (velocity < 0) { + (distance + distanceToNext).coerceAtMost(0f) + } else { + (distance + distanceToCurrent).coerceAtLeast(0f) + } + } + + val flingIndexDelta = flingDistance / distancePerItem + val currentItemOffsetRatio = distanceToCurrent / distancePerItem + + // Our target index, using the fling distance from the current item. We round the value + // which results in flings rounding towards the (relative) infinity. + // The key use case for this is to support short + fast flings. These could result in a + // fling distance of ~70% of the item distance (example). The rounding ensures that + // we target the next page. + return (curr.index + flingIndexDelta - currentItemOffsetRatio) + .roundToInt() + .coerceIn(0, itemCount - 1) + } + + /** + * This attempts to calculate the item spacing for the layout, by looking at the distance + * between the visible items. If there's only 1 visible item available, it returns 0. + */ + private fun calculateItemSpacing(): Int = with(lazyListState.layoutInfo) { + if (visibleItemsInfo.size >= 2) { + val first = visibleItemsInfo[0] + val second = visibleItemsInfo[1] + second.offset - (first.size + first.offset) + } else 0 + } + + /** + * Computes an average pixel value to pass a single child. + * + * Returns a negative value if it cannot be calculated. + * + * @return A float value that is the average number of pixels needed to scroll by one view in + * the relevant direction. + */ + private fun estimateDistancePerItem(): Float = with(lazyListState.layoutInfo) { + if (visibleItemsInfo.isEmpty()) return -1f + + val minPosView = visibleItemsInfo.minByOrNull { it.offset } ?: return -1f + val maxPosView = visibleItemsInfo.maxByOrNull { it.offset + it.size } ?: return -1f + + val start = min(minPosView.offset, maxPosView.offset) + val end = max(minPosView.offset + minPosView.size, maxPosView.offset + maxPosView.size) + + // We add an extra `itemSpacing` onto the calculated total distance. This ensures that + // the calculated mean contains an item spacing for each visible item + // (not just spacing between items) + return when (val distance = end - start) { + 0 -> -1f // If we don't have a distance, return -1 + else -> (distance + calculateItemSpacing()) / visibleItemsInfo.size.toFloat() + } + } +} + +private class LazyListSnapperLayoutItemInfo( + private val lazyListItem: LazyListItemInfo, +) : SnapperLayoutItemInfo() { + override val index: Int get() = lazyListItem.index + override val offset: Int get() = lazyListItem.offset + override val size: Int get() = lazyListItem.size +} \ No newline at end of file diff --git a/src/main/kotlin/libraries/chrisbanes/snapper/README.md b/src/main/kotlin/libraries/chrisbanes/snapper/README.md new file mode 100644 index 0000000..37fb55e --- /dev/null +++ b/src/main/kotlin/libraries/chrisbanes/snapper/README.md @@ -0,0 +1,15 @@ +Copied from https://github.com/chrisbanes/snapper/tree/main/lib/src/main/kotlin/dev/chrisbanes/snapper + +Copyright 2021 Chris Banes + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/src/main/kotlin/libraries/chrisbanes/snapper/SnapperFlingBehavior.kt b/src/main/kotlin/libraries/chrisbanes/snapper/SnapperFlingBehavior.kt new file mode 100644 index 0000000..ae295fb --- /dev/null +++ b/src/main/kotlin/libraries/chrisbanes/snapper/SnapperFlingBehavior.kt @@ -0,0 +1,462 @@ +package libraries.chrisbanes.snapper + +import androidx.compose.animation.core.* +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.runtime.* +import kotlin.math.abs +import kotlin.math.absoluteValue + +@RequiresOptIn(message = "Snapper is experimental. The API may be changed in the future.") +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalSnapperApi + +/** + * Default values used for [SnapperFlingBehavior] & [rememberSnapperFlingBehavior]. + */ +@ExperimentalSnapperApi +object SnapperFlingBehaviorDefaults { + /** + * [AnimationSpec] used as the default value for the `snapAnimationSpec` parameter on + * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior]. + */ + val SpringAnimationSpec: AnimationSpec = spring(stiffness = 400f) + + /** + * The default implementation for the `maximumFlingDistance` parameter of + * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior], which does not limit + * the fling distance. + */ + val MaximumFlingDistance: (SnapperLayoutInfo) -> Float = { Float.MAX_VALUE } +} + +/** + * Create and remember a snapping [FlingBehavior] to be used with the given [layoutInfo]. + * + * @param layoutInfo The [SnapperLayoutInfo] to use. For lazy layouts, + * you can use [rememberLazyListSnapperLayoutInfo]. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. + * The returned value should be > 0. + */ +@ExperimentalSnapperApi +@Composable +fun rememberSnapperFlingBehavior( + layoutInfo: SnapperLayoutInfo, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance, +): SnapperFlingBehavior = remember( + layoutInfo, + decayAnimationSpec, + springAnimationSpec, + maximumFlingDistance, +) { + SnapperFlingBehavior( + layoutInfo = layoutInfo, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + maximumFlingDistance = maximumFlingDistance, + ) +} + +/** + * Contains the necessary information about the scrolling layout for [SnapperFlingBehavior] + * to determine how to fling. + */ +@ExperimentalSnapperApi +abstract class SnapperLayoutInfo { + /** + * The start offset of where items can be scrolled to. This value should only include + * scrollable regions. For example this should not include fixed content padding. + * For most layouts, this will be 0. + */ + abstract val startScrollOffset: Int + + /** + * The end offset of where items can be scrolled to. This value should only include + * scrollable regions. For example this should not include fixed content padding. + * For most layouts, this will the width of the container, minus content padding. + */ + abstract val endScrollOffset: Int + + /** + * A sequence containing the currently visible items in the layout. + */ + abstract val visibleItems: Sequence + + /** + * The current item which covers the desired snap point, or null if there is no item. + * The item returned may not yet currently be snapped into the final position. + */ + abstract val currentItem: SnapperLayoutItemInfo? + + /** + * Calculate the desired target which should be scrolled to for the given [velocity]. + * + * @param velocity Velocity of the fling. This can be 0. + * @param decayAnimationSpec The decay fling animation spec. + * @param maximumFlingDistance The maximum distance in pixels which should be scrolled. + */ + abstract fun determineTargetIndex( + velocity: Float, + decayAnimationSpec: DecayAnimationSpec, + maximumFlingDistance: Float, + ): Int + + /** + * Calculate the distance in pixels needed to scroll to the given [index]. The value returned + * signifies which direction to scroll in: + * + * - Positive values indicate to scroll towards the end. + * - Negative values indicate to scroll towards the start. + * + * If a precise calculation can not be found, a realistic estimate is acceptable. + */ + abstract fun distanceToIndexSnap(index: Int): Int + + /** + * Returns true if the layout has some scroll range remaining to scroll towards the start. + */ + abstract fun canScrollTowardsStart(): Boolean + + /** + * Returns true if the layout has some scroll range remaining to scroll towards the end. + */ + abstract fun canScrollTowardsEnd(): Boolean +} + +/** + * Contains information about a single item in a scrolling layout. + */ +abstract class SnapperLayoutItemInfo { + abstract val index: Int + abstract val offset: Int + abstract val size: Int + + override fun toString(): String { + return "SnapperLayoutItemInfo(index=$index, offset=$offset, size=$size)" + } +} + +/** + * Contains a number of values which can be used for the `snapOffsetForItem` parameter on + * [rememberLazyListSnapperLayoutInfo] and [LazyListSnapperLayoutInfo]. + */ +@ExperimentalSnapperApi +@Suppress("unused") // public vals which aren't used in the project +object SnapOffsets { + /** + * Snap offset which results in the start edge of the item, snapping to the start scrolling + * edge of the lazy list. + */ + val Start: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int = + { layout, _ -> layout.startScrollOffset } + + /** + * Snap offset which results in the item snapping in the center of the scrolling viewport + * of the lazy list. + */ + val Center: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int = { layout, item -> + layout.startScrollOffset + (layout.endScrollOffset - layout.startScrollOffset - item.size) / 2 + } + + /** + * Snap offset which results in the end edge of the item, snapping to the end scrolling + * edge of the lazy list. + */ + val End: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int = { layout, item -> + layout.endScrollOffset - item.size + } +} + +/** + * A snapping [FlingBehavior] for LazyListState. Typically, this would be created + * via [rememberSnapperFlingBehavior]. + * + * Note: the default parameter value for [decayAnimationSpec] is different to the value used in + * [rememberSnapperFlingBehavior], due to not being able to access composable functions. + * + * @param layoutInfo The [SnapperLayoutInfo] to use. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. + * The returned value should be > 0. + */ +@ExperimentalSnapperApi +class SnapperFlingBehavior( + private val layoutInfo: SnapperLayoutInfo, + private val maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance, + private val decayAnimationSpec: DecayAnimationSpec, + private val springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, +) : FlingBehavior { + /** + * The target item index for any ongoing animations. + */ + var animationTarget: Int? by mutableStateOf(null) + private set + + override suspend fun ScrollScope.performFling( + initialVelocity: Float + ): Float { + // If we're at the start/end of the scroll range, we don't snap and assume the user + // wanted to scroll here. + if (!layoutInfo.canScrollTowardsStart() || !layoutInfo.canScrollTowardsEnd()) { + return initialVelocity + } + + val maxFlingDistance = maximumFlingDistance(layoutInfo) + require(maxFlingDistance > 0) { + "Distance returned by maximumFlingDistance should be greater than 0" + } + + return flingToIndex( + index = layoutInfo.determineTargetIndex( + velocity = initialVelocity, + decayAnimationSpec = decayAnimationSpec, + maximumFlingDistance = maxFlingDistance, + ), + initialVelocity = initialVelocity, + ) + } + + private suspend fun ScrollScope.flingToIndex( + index: Int, + initialVelocity: Float, + ): Float { + val initialItem = layoutInfo.currentItem ?: return initialVelocity + + if (initialItem.index == index && layoutInfo.distanceToIndexSnap(initialItem.index) == 0) { + return consumeVelocityIfNotAtScrollEdge(initialVelocity) + } + + return if (decayAnimationSpec.canDecayBeyondCurrentItem(initialVelocity, initialItem)) { + // If the decay fling can scroll past the current item, fling with decay + performDecayFling( + initialItem = initialItem, + targetIndex = index, + initialVelocity = initialVelocity, + ) + } else { + // Otherwise, we 'spring' to current/next item + performSpringFling( + initialItem = initialItem, + targetIndex = index, + initialVelocity = initialVelocity, + ) + } + } + + /** + * Performs a decaying fling. + * + * If [flingThenSpring] is set to true, then a fling-then-spring animation might be used. + * If used, a decay fling will be run until we've scrolled to the preceding item of + * [targetIndex]. Once that happens, the decay animation is stopped and a spring animation + * is started to scroll the remainder of the distance. Visually this results in a much + * smoother finish to the animation, as it will slowly come to a stop at [targetIndex]. + * Even if [flingThenSpring] is set to true, fling-then-spring animations are only available + * when scrolling 2 items or more. + * + * When [flingThenSpring] is not used, the decay animation will be stopped immediately upon + * scrolling past [targetIndex], which can result in an abrupt stop. + */ + private suspend fun ScrollScope.performDecayFling( + initialItem: SnapperLayoutItemInfo, + targetIndex: Int, + initialVelocity: Float, + flingThenSpring: Boolean = true, + ): Float { + // If we're already at the target + snap offset, skip + if (initialItem.index == targetIndex && layoutInfo.distanceToIndexSnap(initialItem.index) == 0) { + return consumeVelocityIfNotAtScrollEdge(initialVelocity) + } + + var velocityLeft = initialVelocity + var lastValue = 0f + + // We can only fling-then-spring if we're flinging >= 2 items... + val canSpringThenFling = flingThenSpring && abs(targetIndex - initialItem.index) >= 2 + var needSpringAfter = false + + try { + // Update the animationTarget + animationTarget = targetIndex + + AnimationState( + initialValue = 0f, + initialVelocity = initialVelocity, + ).animateDecay(decayAnimationSpec) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + velocityLeft = velocity + + if (abs(delta - consumed) > 0.5f) { + // If some scroll was not consumed, cancel the animation now as we're + // likely at the end of the scroll range + cancelAnimation() + } + + val currentItem = layoutInfo.currentItem + if (currentItem == null) { + cancelAnimation() + return@animateDecay + } + + if (isRunning && canSpringThenFling) { + // If we're still running and fling-then-spring is enabled, check to see + // if we're at the 1 item width away (in the relevant direction). If we are, + // set the spring-after flag and cancel the current decay + if (velocity > 0 && currentItem.index == targetIndex - 1) { + needSpringAfter = true + cancelAnimation() + } else if (velocity < 0 && currentItem.index == targetIndex) { + needSpringAfter = true + cancelAnimation() + } + } + + if (isRunning && performSnapBackIfNeeded(currentItem, targetIndex, ::scrollBy)) { + // If we're still running, check to see if we need to snap-back + // (if we've scrolled past the target) + cancelAnimation() + } + } + } finally { + animationTarget = null + } + + if (needSpringAfter) { + // The needSpringAfter flag is enabled, so start a spring to the target using the + // remaining velocity + return performSpringFling(layoutInfo.currentItem!!, targetIndex, velocityLeft) + } + + return consumeVelocityIfNotAtScrollEdge(velocityLeft) + } + + private suspend fun ScrollScope.performSpringFling( + initialItem: SnapperLayoutItemInfo, + targetIndex: Int, + initialVelocity: Float = 0f, + ): Float { + var velocityLeft = when { + // Only use the initialVelocity if it is in the correct direction + targetIndex > initialItem.index && initialVelocity > 0 -> initialVelocity + targetIndex <= initialItem.index && initialVelocity < 0 -> initialVelocity + // Otherwise, start at 0 velocity + else -> 0f + } + var lastValue = 0f + + try { + // Update the animationTarget + animationTarget = targetIndex + + AnimationState( + initialValue = lastValue, + initialVelocity = velocityLeft, + ).animateTo( + targetValue = layoutInfo.distanceToIndexSnap(targetIndex).toFloat(), + animationSpec = springAnimationSpec, + ) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + velocityLeft = velocity + + val currentItem = layoutInfo.currentItem + if (currentItem == null) { + cancelAnimation() + return@animateTo + } + + if (performSnapBackIfNeeded(currentItem, targetIndex, ::scrollBy)) { + cancelAnimation() + } else if (abs(delta - consumed) > 0.5f) { + // If we're still running but some scroll was not consumed, + // cancel the animation now + cancelAnimation() + } + } + } finally { + animationTarget = null + } + return consumeVelocityIfNotAtScrollEdge(velocityLeft) + } + + /** + * Returns true if we needed to perform a snap back, and the animation should be cancelled. + */ + private fun AnimationScope.performSnapBackIfNeeded( + currentItem: SnapperLayoutItemInfo, + targetIndex: Int, + scrollBy: (pixels: Float) -> Float, + ): Boolean { + // Calculate the 'snap back'. If the returned value is 0, we don't need to do anything. + val snapBackAmount = calculateSnapBack(velocity, currentItem, targetIndex) + + if (snapBackAmount != 0) { + // If we've scrolled to/past the item, stop the animation. We may also need to + // 'snap back' to the item as we may have scrolled past it + scrollBy(snapBackAmount.toFloat()) + return true + } + + return false + } + + private fun DecayAnimationSpec.canDecayBeyondCurrentItem( + velocity: Float, + currentItem: SnapperLayoutItemInfo, + ): Boolean { + // If we don't have a velocity, return false + if (velocity.absoluteValue < 0.5f) return false + + val flingDistance = calculateTargetValue(0f, velocity) + + return if (velocity < 0) { + // backwards, towards 0 + flingDistance <= layoutInfo.distanceToIndexSnap(currentItem.index) + } else { + // forwards, toward index + 1 + flingDistance >= layoutInfo.distanceToIndexSnap(currentItem.index + 1) + } + } + + /** + * Returns the distance in pixels that is required to 'snap back' to the [targetIndex]. + * Returns 0 if a snap back is not needed. + */ + private fun calculateSnapBack( + initialVelocity: Float, + currentItem: SnapperLayoutItemInfo, + targetIndex: Int, + ): Int = when { + // forwards + initialVelocity > 0 && currentItem.index == targetIndex -> { + layoutInfo.distanceToIndexSnap(currentItem.index) + } + initialVelocity < 0 && currentItem.index == targetIndex - 1 -> { + layoutInfo.distanceToIndexSnap(currentItem.index + 1) + } + else -> 0 + } + + private fun consumeVelocityIfNotAtScrollEdge(velocity: Float): Float { + if (velocity < 0 && !layoutInfo.canScrollTowardsStart()) { + // If there is remaining velocity towards the start, and we're at the scroll start, + // we don't consume. This enables the overscroll effect where supported + return velocity + } else if (velocity > 0 && !layoutInfo.canScrollTowardsEnd()) { + // If there is remaining velocity towards the end, and we're at the scroll end, + // we don't consume. This enables the overscroll effect where supported + return velocity + } + // Else we return 0 to consume the remaining velocity + return 0f + } +} \ No newline at end of file diff --git a/src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt b/src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt new file mode 100644 index 0000000..e82875d --- /dev/null +++ b/src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt @@ -0,0 +1,162 @@ +package libraries.pager_indicators + +import libraries.accompanist.pager.PagerState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import libraries.accompanist.pager.ExperimentalPagerApi + +/** + * A horizontally laid out indicator for a HorizontalPager or VerticalPager, representing + * the currently active page and total pages drawn using a [Shape]. + * + * This element allows the setting of the [indicatorShape], which defines how the + * indicator is visually represented. + * + * @param pagerState the state object of your Pager to be used to observe the list's state. + * @param modifier the modifier to apply to this layout. + * @param activeColor the color of the active Page indicator + * @param inactiveColor the color of page indicators that are inactive. This defaults to + * [activeColor] with the alpha component set to the [ContentAlpha.disabled]. + * @param indicatorWidth the width of each indicator in [Dp]. + * @param indicatorHeight the height of each indicator in [Dp]. Defaults to [indicatorWidth]. + * @param spacing the spacing between each indicator in [Dp]. + * @param indicatorShape the shape representing each indicator. This defaults to [CircleShape]. + */ +@ExperimentalPagerApi +@Composable +fun HorizontalPagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, + activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + inactiveColor: Color = activeColor.copy(ContentAlpha.disabled), + indicatorWidth: Dp = 8.dp, + indicatorHeight: Dp = indicatorWidth, + spacing: Dp = indicatorWidth, + indicatorShape: Shape = CircleShape, +) { + + val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() } + val spacingPx = LocalDensity.current.run { spacing.roundToPx() } + + Box( + modifier = modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + ) { + val indicatorModifier = Modifier + .size(width = indicatorWidth, height = indicatorHeight) + .background(color = inactiveColor, shape = indicatorShape) + + repeat(pagerState.pageCount) { + Box(indicatorModifier) + } + } + + Box( + Modifier + .offset { + val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffset) + .coerceIn(0f, (pagerState.pageCount - 1).coerceAtLeast(0).toFloat()) + IntOffset( + x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), + y = 0 + ) + } + .size(width = indicatorWidth, height = indicatorHeight) + .background( + color = activeColor, + shape = indicatorShape, + ) + ) + } +} + +/** + * A vertically laid out indicator for a VerticalPager or HorizontalPager, representing + * the currently active page and total pages drawn using a [Shape]. + * + * This element allows the setting of the [indicatorShape], which defines how the + * indicator is visually represented. + * + * @param pagerState the state object of your Pager to be used to observe the list's state. + * @param modifier the modifier to apply to this layout. + * @param activeColor the color of the active Page indicator + * @param inactiveColor the color of page indicators that are inactive. This defaults to + * [activeColor] with the alpha component set to the [ContentAlpha.disabled]. + * @param indicatorHeight the height of each indicator in [Dp]. + * @param indicatorWidth the width of each indicator in [Dp]. Defaults to [indicatorHeight]. + * @param spacing the spacing between each indicator in [Dp]. + * @param indicatorShape the shape representing each indicator. This defaults to [CircleShape]. + */ +@ExperimentalPagerApi +@Composable +fun VerticalPagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, + activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + inactiveColor: Color = activeColor.copy(ContentAlpha.disabled), + indicatorHeight: Dp = 8.dp, + indicatorWidth: Dp = indicatorHeight, + spacing: Dp = indicatorHeight, + indicatorShape: Shape = CircleShape, +) { + + val indicatorHeightPx = LocalDensity.current.run { indicatorHeight.roundToPx() } + val spacingPx = LocalDensity.current.run { spacing.roundToPx() } + + Box( + modifier = modifier, + contentAlignment = Alignment.TopCenter + ) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val indicatorModifier = Modifier + .size(width = indicatorWidth, height = indicatorHeight) + .background(color = inactiveColor, shape = indicatorShape) + + repeat(pagerState.pageCount) { + Box(indicatorModifier) + } + } + + Box( + Modifier + .offset { + val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffset) + .coerceIn(0f, (pagerState.pageCount - 1).coerceAtLeast(0).toFloat()) + IntOffset( + x = 0, + y = ((spacingPx + indicatorHeightPx) * scrollPosition).toInt(), + ) + } + .size(width = indicatorWidth, height = indicatorHeight) + .background( + color = activeColor, + shape = indicatorShape, + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/libraries/pager_indicators/PagerTab.kt b/src/main/kotlin/libraries/pager_indicators/PagerTab.kt new file mode 100644 index 0000000..6f75043 --- /dev/null +++ b/src/main/kotlin/libraries/pager_indicators/PagerTab.kt @@ -0,0 +1,63 @@ +package libraries.pager_indicators + +import libraries.accompanist.pager.PagerState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.TabPosition +import androidx.compose.material.TabRow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import libraries.accompanist.pager.ExperimentalPagerApi +import kotlin.math.absoluteValue +import kotlin.math.max + +/** + * This indicator syncs up a [TabRow] or [ScrollableTabRow] tab indicator with a + * HorizontalPager or VerticalPager. See the sample for a full demonstration. + * + */ +@ExperimentalPagerApi +fun Modifier.pagerTabIndicatorOffset( + pagerState: PagerState, + tabPositions: List, +): Modifier = composed { + // If there are no pages, nothing to show + if (pagerState.pageCount == 0) return@composed this + + val targetIndicatorOffset: Dp + val indicatorWidth: Dp + + val currentTab = tabPositions[minOf(tabPositions.lastIndex, pagerState.currentPage)] + val targetPage = pagerState.targetPage + val targetTab = tabPositions.getOrNull(targetPage) + + if (targetTab != null) { + // The distance between the target and current page. If the pager is animating over many + // items this could be > 1 + val targetDistance = (targetPage - pagerState.currentPage).absoluteValue + // Our normalized fraction over the target distance + val fraction = (pagerState.currentPageOffset / max(targetDistance, 1)).absoluteValue + + targetIndicatorOffset = lerp(currentTab.left, targetTab.left, fraction) + indicatorWidth = lerp(currentTab.width, targetTab.width, fraction).absoluteValue + } else { + // Otherwise, we just use the current tab/page + targetIndicatorOffset = currentTab.left + indicatorWidth = currentTab.width + } + + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = targetIndicatorOffset) + .width(indicatorWidth) +} + +private inline val Dp.absoluteValue: Dp + get() = value.absoluteValue.dp \ No newline at end of file diff --git a/src/main/kotlin/libraries/pager_indicators/README.md b/src/main/kotlin/libraries/pager_indicators/README.md new file mode 100644 index 0000000..76a3bfa --- /dev/null +++ b/src/main/kotlin/libraries/pager_indicators/README.md @@ -0,0 +1,15 @@ +Copied from https://github.com/google/accompanist/tree/main/pager-indicators/src/main/java/com/google/accompanist/pager + +Copyright 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/engine/metadata/MetadataDiffItems.kt b/src/main/kotlin/terminodiff/engine/metadata/MetadataDiffItems.kt index b9ffe6e..b6474ed 100644 --- a/src/main/kotlin/terminodiff/engine/metadata/MetadataDiffItems.kt +++ b/src/main/kotlin/terminodiff/engine/metadata/MetadataDiffItems.kt @@ -169,7 +169,7 @@ class IdentifierListDiffItem(localizedStrings: LocalizedStrings) : class ContactComparisonItem( localizedStrings: LocalizedStrings, ) : MetadataKeyedListDiffItem({ contact }, false, localizedStrings, { it.contact }) { - override fun getKey(instance: ContactDetail): String = instance.name + override fun getKey(instance: ContactDetail): String = instance.name ?: "" override fun getStringValue(instance: ContactDetail): String = formatDisplay(instance) diff --git a/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt b/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt index 20b50d6..eacc4ee 100644 --- a/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt +++ b/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt @@ -13,7 +13,7 @@ import terminodiff.engine.concepts.ConceptDiffItem import terminodiff.engine.graph.CodeSystemDiffBuilder import terminodiff.engine.graph.CodeSystemGraphBuilder import terminodiff.i18n.LocalizedStrings -import java.io.File +import terminodiff.terminodiff.engine.resources.InputResource import java.util.* private val logger: Logger = LoggerFactory.getILoggerFactory().getLogger("DiffDataContainer") @@ -22,12 +22,15 @@ class DiffDataContainer(private val fhirContext: FhirContext, strings: Localized var localizedStrings by mutableStateOf(strings) var loadState: UUID by mutableStateOf(UUID.randomUUID()) - var leftFilename: File? by mutableStateOf(null) - var rightFilename: File? by mutableStateOf(null) + + //var leftFilename: File? by mutableStateOf(null) + //var rightFilename: File? by mutableStateOf(null) + var leftResource: InputResource? by mutableStateOf(null) + var rightResource: InputResource? by mutableStateOf(null) //all other properties are dependent and flow down from the filename changes - val leftCodeSystem: CodeSystem? by derivedStateOf { loadCodeSystemResource(leftFilename, Side.LEFT) } - val rightCodeSystem: CodeSystem? by derivedStateOf { loadCodeSystemResource(rightFilename, Side.RIGHT) } + val leftCodeSystem: CodeSystem? by derivedStateOf { loadCodeSystemResource(leftResource, Side.LEFT) } + val rightCodeSystem: CodeSystem? by derivedStateOf { loadCodeSystemResource(rightResource, Side.RIGHT) } val leftGraphBuilder: CodeSystemGraphBuilder? by derivedStateOf { buildCsGraph(leftCodeSystem) } val rightGraphBuilder: CodeSystemGraphBuilder? by derivedStateOf { buildCsGraph(rightCodeSystem) } @@ -45,9 +48,10 @@ class DiffDataContainer(private val fhirContext: FhirContext, strings: Localized LEFT, RIGHT } - private fun loadCodeSystemResource(file: File?, side: Side): CodeSystem? { - if (file == null) return null - logger.info("Loading $side resource from ${file.absolutePath}") + private fun loadCodeSystemResource(resource: InputResource?, side: Side): CodeSystem? { + if (resource?.localFile == null) return null + val file = resource.localFile!! + logger.info("Loading $side ${resource.kind} resource from ${file.absolutePath}") return try { when (file.extension.lowercase()) { "xml" -> fhirContext.newXmlParser().parseResource(CodeSystem::class.java, file.reader()) diff --git a/src/main/kotlin/terminodiff/engine/resources/InputResource.kt b/src/main/kotlin/terminodiff/engine/resources/InputResource.kt new file mode 100644 index 0000000..2a8b012 --- /dev/null +++ b/src/main/kotlin/terminodiff/engine/resources/InputResource.kt @@ -0,0 +1,47 @@ +package terminodiff.terminodiff.engine.resources + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import org.apache.http.HttpException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import terminodiff.ui.panes.loaddata.panes.fromserver.DownloadableCodeSystem +import java.io.File +import kotlin.io.path.bufferedWriter + +private val logger: Logger = LoggerFactory.getLogger("InputResource") + +data class InputResource( + val kind: Kind, + var localFile: File? = null, + val resourceUrl: String? = null, + val sourceFhirServerUrl: String? = null, + val downloadableCodeSystem: DownloadableCodeSystem? = null +) { + enum class Kind { + FILE, + FHIR_SERVER, + VREAD + } + + suspend fun downloadRemoteFile(ktorClient: HttpClient): InputResource = when { + kind == Kind.FILE -> this + (kind == Kind.FHIR_SERVER || kind == Kind.VREAD) && resourceUrl != null -> { + val tempFilePath = kotlin.io.path.createTempFile(prefix = "terminodiff", suffix = ".json") + val rx = ktorClient.get(resourceUrl) { + header("Accept", "application/json") + } + if (!rx.status.isSuccess()) throw HttpException("The resource $this could not be retrieved, error ${rx.status.value} ${rx.status.description}") + tempFilePath.bufferedWriter().use { + it.write(rx.bodyAsText()) + } + this.copy(localFile = tempFilePath.toFile()).also { + logger.info("Downloaded resource $it") + } + + } + else -> throw UnsupportedOperationException("The remote file can't be downloaded for input resource $this") + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt b/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt index a33d4cf..e0ad626 100644 --- a/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt +++ b/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt @@ -4,23 +4,29 @@ import terminodiff.engine.concepts.ConceptDiffItem import terminodiff.engine.concepts.KeyedListDiffResult import terminodiff.engine.concepts.KeyedListDiffResultKind import terminodiff.engine.graph.DiffGraphElementKind +import terminodiff.engine.resources.DiffDataContainer.* import terminodiff.terminodiff.engine.metadata.MetadataComparisonResult -import java.io.File +import terminodiff.terminodiff.engine.resources.InputResource /** * we pass around an instance of LocalizedStrings, since we want every composable * to recompose when the language changes. */ abstract class LocalizedStrings( + val anUnknownErrorOccurred: String, val boolean_: (Boolean?) -> String, val bothValuesAreNull: String, + val calculateDiff: String, val canonicalUrl: String, val caseSensitive: String = "Case-Sensitive?", val changeLanguage: String, + val clearSearch: String, val clickForDetails: String, - val code: String = "Code", + val closeAccept: String, + val closeReject: String, val comparison: String, val compositional: String, + val code: String = "Code", val conceptDiff: String, val conceptDiffResults_: (ConceptDiffItem.ConceptDiffResultEnum) -> String, val contact: String, @@ -37,21 +43,29 @@ abstract class LocalizedStrings( val display: String = "Display", val displayAndInWhich_: (String?, DiffGraphElementKind) -> String, val experimental: String, + val fhirTerminologyServer: String, + val fileFromPath_: (String) -> String, + val fileFromUrl_: (String) -> String, + val fileSystem: String, val hierarchyMeaning: String, val id: String = "ID", val identical: String, val identifiers: String, + val invalid: String, val jurisdiction: String, val keyedListResult_: (List>) -> String, - val loadLeftFile: String, - val loadRightFile: String, + val loadLeft: String, + val loadRight: String, + val loadFromFile: String, + val loadedResources: String, val leftValue: String, val language: String, val rightValue: String, val metadataDiff: String, val metadataDiffResults_: (MetadataComparisonResult) -> String, + val metaVersion: String, val name: String = "Name", - val noDataLoadedTitle: String, + val noDataLoaded: String, val numberItems_: (Int) -> String = { when (it) { 1 -> "1 item" @@ -63,6 +77,8 @@ abstract class LocalizedStrings( val onlyConceptDifferences: String, val onlyInRight: String, val overallComparison: String, + val openResources: String, + val pending: String, val publisher: String, val purpose: String, val property: String, @@ -73,6 +89,9 @@ abstract class LocalizedStrings( val propertyDesignationForCode_: (String) -> String, val propertyType: String, val reload: String, + val search: String, + val select: String, + val side_: (Side) -> String, val showAll: String, val showDifferent: String, val showIdentical: String, @@ -88,12 +107,15 @@ abstract class LocalizedStrings( val uniLuebeck: String, val use: String, val useContext: String, + val valid: String, val value: String, val valueSet: String = "ValueSet", val version: String = "Version", val versionNeeded: String, - val leftFileOpenFilename_: (File) -> String, - val rightFileOpenFilename_: (File) -> String, + val vRead: String = "VRead", + val vReadFor_: (InputResource) -> String, + val vReadExplanationEnabled_: (Boolean) -> String, + val vreadFromUrlAndMetaVersion_: (String, String) -> String, ) enum class SupportedLocale { @@ -104,15 +126,21 @@ enum class SupportedLocale { } } -class GermanStrings : LocalizedStrings(boolean_ = { - when (it) { - null -> "null" - true -> "WAHR" - false -> "FALSCH" - } -}, +class GermanStrings : LocalizedStrings( + anUnknownErrorOccurred = "Ein unbekannter Fehler ist aufgetrefen", + boolean_ = { + when (it) { + null -> "null" + true -> "WAHR" + false -> "FALSCH" + } + }, bothValuesAreNull = "Beide Werte sind null", + calculateDiff = "Diff berechnen", canonicalUrl = "Kanonische URL", + clearSearch = "Suche zurücksetzen", + closeAccept = "Akzeptieren", + closeReject = "Verwerfen", changeLanguage = "Sprache wechseln", clickForDetails = "Für Details klicken", comparison = "Vergleich", @@ -140,9 +168,14 @@ class GermanStrings : LocalizedStrings(boolean_ = { "'$display' ($where)" }, experimental = "Experimentell?", + fhirTerminologyServer = "FHIR-Terminologieserver", + fileFromPath_ = { "Datei von: $it" }, + fileFromUrl_ = { "FHIR-Server von: $it" }, + fileSystem = "Dateisystem", hierarchyMeaning = "Hierachie-Bedeutung", identical = "Identisch", identifiers = "IDs", + invalid = "Ungültig", jurisdiction = "Jurisdiktion", keyedListResult_ = { results -> results.map { it.result }.groupingBy { it }.eachCount().let { eachCount -> @@ -154,8 +187,10 @@ class GermanStrings : LocalizedStrings(boolean_ = { ) }.joinToString() }, - loadLeftFile = "Linke Datei laden", - loadRightFile = "Rechte Datei laden", + loadLeft = "Links laden", + loadRight = "Rechts laden", + loadFromFile = "Vom Dateisystem laden", + loadedResources = "Geladene Ressourcen", leftValue = "Linker Wert", language = "Sprache", rightValue = "Rechter Wert", @@ -166,12 +201,15 @@ class GermanStrings : LocalizedStrings(boolean_ = { MetadataComparisonResult.DIFFERENT -> "Unterschiedlich" } }, - noDataLoadedTitle = "Keine Daten geladen", + metaVersion = "Meta-Version", + noDataLoaded = "Keine Daten geladen", oneValueIsNull = "Ein Wert ist null", onlyInLeft = "Nur links", onlyConceptDifferences = "Konzeptunterschiede", onlyInRight = "Nur rechts", + openResources = "Ressourcen öffnen", overallComparison = "Gesamt", + pending = "Ausstehend...", publisher = "Herausgeber", purpose = "Zweck", property = "Eigenschaft", @@ -188,6 +226,14 @@ class GermanStrings : LocalizedStrings(boolean_ = { propertyDesignationForCode_ = { code -> "Eigenschaften und Designationen für Konzept '$code'" }, propertyType = "Typ", reload = "Neu laden", + search = "Suchen", + select = "Auswahl", + side_ = { + when (it) { + Side.RIGHT -> "Rechts" + Side.LEFT -> "Links" + } + }, showAll = "Alle", showDifferent = "Unterschiedliche", showIdentical = "Identische", @@ -199,12 +245,25 @@ class GermanStrings : LocalizedStrings(boolean_ = { uniLuebeck = "Universität zu Lübeck", use = "Zweck", useContext = "Nutzungskontext", + valid = "Gültig", value = "Wert", versionNeeded = "Version erforderlich?", - leftFileOpenFilename_ = { file -> "Linke Datei geöffnet: ${file.absolutePath}" }, - rightFileOpenFilename_ = { file -> "Rechte Datei geöffnet: ${file.absolutePath}" }) + vReadFor_ = { + "VRead für ${it.downloadableCodeSystem!!.canonicalUrl}" + }, + vReadExplanationEnabled_ = { + when (it) { + true -> "Vergleiche Versionen der Ressource mit der \$history-Operation." + else -> "Es gibt nur eine Ressourcen-Version der gewählten Ressource." + } + }, + vreadFromUrlAndMetaVersion_ = { url, meta -> + "VRead von $url (Meta-Version: $meta)" + } +) class EnglishStrings : LocalizedStrings( + anUnknownErrorOccurred = "An unknown error occured.", boolean_ = { when (it) { null -> "null" @@ -213,9 +272,13 @@ class EnglishStrings : LocalizedStrings( } }, bothValuesAreNull = "Both values are null", + calculateDiff = "Calculate diff", canonicalUrl = "Canonical URL", changeLanguage = "Change Language", + clearSearch = "Clear search", clickForDetails = "Click for details", + closeAccept = "Accept", + closeReject = "Reject", comparison = "Comparison", compositional = "Compositional?", conceptDiff = "Concept Diff", @@ -241,9 +304,14 @@ class EnglishStrings : LocalizedStrings( "'$display' ($where)" }, experimental = "Experimental?", + fhirTerminologyServer = "FHIR Terminology Server", + fileFromPath_ = { "File from: $it" }, + fileFromUrl_ = { "FHIR Server from: $it" }, + fileSystem = "Filesystem", hierarchyMeaning = "Hierarchy Meaning", identical = "Identical", identifiers = "Identifiers", + invalid = "Ungültig", jurisdiction = "Jurisdiction", keyedListResult_ = { results -> results.map { it.result }.groupingBy { it }.eachCount().let { eachCount -> @@ -255,8 +323,10 @@ class EnglishStrings : LocalizedStrings( ) }.joinToString() }, - loadLeftFile = "Load left file", - loadRightFile = "Load right file", + loadLeft = "Load left", + loadRight = "Load right", + loadFromFile = "Load from file", + loadedResources = "Loaded resources", leftValue = "Left value", language = "Language", rightValue = "Right value", @@ -267,12 +337,15 @@ class EnglishStrings : LocalizedStrings( MetadataComparisonResult.DIFFERENT -> "Different" } }, - noDataLoadedTitle = "No data loaded", + metaVersion = "Meta Version", + noDataLoaded = "No data loaded", oneValueIsNull = "One value is null", onlyInLeft = "Only left", onlyConceptDifferences = "Concept differences", onlyInRight = "Only right", + openResources = "Open Resources", overallComparison = "Overall", + pending = "Pending...", publisher = "Publisher", purpose = "Purpose", property = "Property", @@ -289,6 +362,14 @@ class EnglishStrings : LocalizedStrings( propertyDesignationForCode_ = { code -> "Properties and designations for concept '$code'" }, propertyType = "Type", reload = "Reload", + search = "Search", + select = "Select", + side_ = { + when (it) { + Side.RIGHT -> "Right" + Side.LEFT -> "Left" + } + }, showAll = "All", showDifferent = "Different", showIdentical = "Identical", @@ -300,11 +381,21 @@ class EnglishStrings : LocalizedStrings( uniLuebeck = "University of Luebeck", use = "Use", useContext = "Use context", + valid = "Valid", value = "Value", versionNeeded = "Version needed?", - leftFileOpenFilename_ = { file -> "Left file open: ${file.absolutePath}" }, - rightFileOpenFilename_ = { file -> "Right file open: ${file.absolutePath}" }, -) + vReadFor_ = { + "VRead for ${it.downloadableCodeSystem!!.canonicalUrl}" + }, + vReadExplanationEnabled_ = { + when (it) { + true -> "Compare versions of the resource using the \$history operation." + else -> "There is only one resource version of the selected resource." + } + }, + vreadFromUrlAndMetaVersion_ = { url, meta -> + "VRead from $url (Meta version: $meta)" + }) fun getStrings(locale: SupportedLocale = SupportedLocale.defaultLocale): LocalizedStrings = when (locale) { SupportedLocale.DE -> GermanStrings() diff --git a/src/main/kotlin/terminodiff/preferences/AppPreferences.kt b/src/main/kotlin/terminodiff/preferences/AppPreferences.kt index c165875..51601c8 100644 --- a/src/main/kotlin/terminodiff/preferences/AppPreferences.kt +++ b/src/main/kotlin/terminodiff/preferences/AppPreferences.kt @@ -25,6 +25,7 @@ object AppPreferences { var language: String by preference(userPref, "language_", SupportedLocale.defaultLocale.name) var darkModeEnabled: Boolean by preference(userPref, "dark_mode_enabled", false) var fileBrowserDirectory: String by preference(userPref, "file_browser_directory", System.getProperty("user.home")) + var terminologyServerUrl: String by preference(userPref, "terminology_server_url", "https://r4.ontoserver.csiro.au/fhir") } inline fun preference(preferences: Preferences, key: String, defaultValue: T) = diff --git a/src/main/kotlin/terminodiff/ui/AppContent.kt b/src/main/kotlin/terminodiff/ui/AppContent.kt index 4e23382..a4118d4 100644 --- a/src/main/kotlin/terminodiff/ui/AppContent.kt +++ b/src/main/kotlin/terminodiff/ui/AppContent.kt @@ -1,70 +1,54 @@ package terminodiff.terminodiff.ui import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import li.flor.nativejfilechooser.NativeJFileChooser -import org.apache.commons.lang3.SystemUtils +import ca.uhn.fhir.context.FhirContext import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.SplitPaneState -import org.jetbrains.compose.splitpane.VerticalSplitPane import terminodiff.engine.resources.DiffDataContainer import terminodiff.i18n.LocalizedStrings -import terminodiff.preferences.AppPreferences -import terminodiff.ui.AppIconResource -import terminodiff.ui.AppImageIcon +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.terminodiff.ui.panes.diff.DiffPaneContent +import terminodiff.terminodiff.ui.panes.loaddata.LoadDataPaneContent import terminodiff.ui.TerminoDiffTopAppBar -import terminodiff.ui.cursorForHorizontalResize -import terminodiff.ui.panes.conceptdiff.ConceptDiffPanel -import terminodiff.ui.panes.graph.ShowGraphsPanel -import terminodiff.ui.panes.metadatadiff.MetadataDiffPanel import terminodiff.ui.theme.TerminoDiffTheme -import java.io.File -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter @OptIn(ExperimentalSplitPaneApi::class) @Composable fun TerminodiffAppContent( localizedStrings: LocalizedStrings, diffDataContainer: DiffDataContainer, + fhirContext: FhirContext, scrollState: ScrollState, useDarkTheme: Boolean, onLocaleChange: () -> Unit, onChangeDarkTheme: () -> Unit, splitPaneState: SplitPaneState, ) { - val onLoadLeftFile: () -> Unit = { - showLoadFileDialog(localizedStrings.loadLeftFile)?.let { - diffDataContainer.leftFilename = it - } + var showDiff by remember { mutableStateOf(false) } + val onLoadLeftFile: (InputResource) -> Unit = { + diffDataContainer.leftResource = it } - val onLoadRightFile: () -> Unit = { - showLoadFileDialog(localizedStrings.loadRightFile)?.let { - diffDataContainer.rightFilename = it - } + val onLoadRightFile: (InputResource) -> Unit = { + diffDataContainer.rightResource = it } - TerminodiffContentWindow( - localizedStrings = localizedStrings, + TerminodiffContentWindow(localizedStrings = localizedStrings, scrollState = scrollState, useDarkTheme = useDarkTheme, onLocaleChange = onLocaleChange, onChangeDarkTheme = onChangeDarkTheme, - onLoadLeftFile = onLoadLeftFile, - onLoadRightFile = onLoadRightFile, + fhirContext = fhirContext, + onLoadLeft = onLoadLeftFile, + onLoadRight = onLoadRightFile, onReload = { diffDataContainer.reload() }, diffDataContainer = diffDataContainer, - splitPaneState = splitPaneState - ) + splitPaneState = splitPaneState, + showDiff = showDiff) { newValue -> showDiff = newValue } } @OptIn(ExperimentalSplitPaneApi::class) @@ -75,196 +59,47 @@ fun TerminodiffContentWindow( useDarkTheme: Boolean, onLocaleChange: () -> Unit, onChangeDarkTheme: () -> Unit, - onLoadLeftFile: () -> Unit, - onLoadRightFile: () -> Unit, + fhirContext: FhirContext, + onLoadLeft: (InputResource) -> Unit, + onLoadRight: (InputResource) -> Unit, onReload: () -> Unit, diffDataContainer: DiffDataContainer, splitPaneState: SplitPaneState, + showDiff: Boolean, + setShowDiff: (Boolean) -> Unit, ) { TerminoDiffTheme(useDarkTheme = useDarkTheme) { - Scaffold( - topBar = { - TerminoDiffTopAppBar( - localizedStrings = localizedStrings, - onLocaleChange = onLocaleChange, - onLoadLeftFile = onLoadLeftFile, - onLoadRightFile = onLoadRightFile, - onChangeDarkTheme = onChangeDarkTheme, - onReload = onReload - ) - }, - backgroundColor = MaterialTheme.colorScheme.background - ) { scaffoldPadding -> - when (diffDataContainer.leftCodeSystem != null && diffDataContainer.rightCodeSystem != null) { - true -> ContainerInitializedContent( - modifier = Modifier.padding(scaffoldPadding), + Scaffold(topBar = { + TerminoDiffTopAppBar( + localizedStrings = localizedStrings, + onLocaleChange = onLocaleChange, + onChangeDarkTheme = onChangeDarkTheme, + onReload = onReload, + onShowLoadScreen = { + setShowDiff.invoke(false) + } + ) + }, backgroundColor = MaterialTheme.colorScheme.background) { scaffoldPadding -> + when (diffDataContainer.leftCodeSystem != null && diffDataContainer.rightCodeSystem != null && showDiff) { + true -> DiffPaneContent(modifier = Modifier.padding(scaffoldPadding), scrollState = scrollState, strings = localizedStrings, useDarkTheme = useDarkTheme, diffDataContainer = diffDataContainer, - splitPaneState = splitPaneState - ) - false -> ContainerUninitializedContent( + splitPaneState = splitPaneState) + false -> LoadDataPaneContent( modifier = Modifier.padding(scaffoldPadding), scrollState = scrollState, localizedStrings = localizedStrings, - leftFile = diffDataContainer.leftFilename, - rightFile = diffDataContainer.rightFilename, - onLoadLeftFile = onLoadLeftFile, - onLoadRightFile = onLoadRightFile, - ) - } - } - } -} - -@Composable -private fun ContainerUninitializedContent( - modifier: Modifier = Modifier, - scrollState: ScrollState, - localizedStrings: LocalizedStrings, - onLoadLeftFile: () -> Unit, - onLoadRightFile: () -> Unit, - leftFile: File?, - rightFile: File?, -) { - Column(modifier.scrollable(scrollState, Orientation.Vertical)) { - Card( - modifier = Modifier.padding(8.dp).fillMaxWidth(), - elevation = 8.dp, - backgroundColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) { - val buttonColors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - Column(Modifier.padding(4.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Text( - localizedStrings.noDataLoadedTitle, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer + leftResource = diffDataContainer.leftResource, + rightResource = diffDataContainer.rightResource, + onLoadLeft = onLoadLeft, + onLoadRight = onLoadRight, + fhirContext = fhirContext, + onGoButtonClick = { setShowDiff.invoke(true) }, ) - Row { - Button( - modifier = Modifier.padding(4.dp), - colors = buttonColors, - onClick = onLoadLeftFile, - elevation = ButtonDefaults.elevation(defaultElevation = 8.dp) - ) { - AppImageIcon( - relativePath = AppIconResource.icLoadLeftFile, - label = localizedStrings.loadLeftFile, - tint = buttonColors.contentColor(true).value - ) - Text(localizedStrings.loadLeftFile) - } - Button( - modifier = Modifier.padding(4.dp), - onClick = onLoadRightFile, - colors = buttonColors, - ) { - AppImageIcon( - relativePath = AppIconResource.icLoadRightFile, - label = localizedStrings.loadRightFile, - tint = buttonColors.contentColor(true).value - ) - Text(localizedStrings.loadRightFile) - } - } - when { - leftFile != null -> localizedStrings.leftFileOpenFilename_.invoke(leftFile) - rightFile != null -> localizedStrings.rightFileOpenFilename_.invoke(rightFile) - else -> null - }?.let { openFilenameText -> - Text( - text = openFilenameText, - color = MaterialTheme.colorScheme.onSecondaryContainer, - style = MaterialTheme.typography.bodyMedium - ) - } } } } } -@OptIn(ExperimentalSplitPaneApi::class) -@Composable -private fun ContainerInitializedContent( - modifier: Modifier = Modifier, - scrollState: ScrollState, - strings: LocalizedStrings, - useDarkTheme: Boolean, - diffDataContainer: DiffDataContainer, - splitPaneState: SplitPaneState, -) { - Column( - modifier = modifier.scrollable(scrollState, Orientation.Vertical), - ) { - ShowGraphsPanel( - leftCs = diffDataContainer.leftCodeSystem!!, - rightCs = diffDataContainer.rightCodeSystem!!, - diffGraph = diffDataContainer.codeSystemDiff!!.differenceGraph, - localizedStrings = strings, - useDarkTheme = useDarkTheme, - ) - VerticalSplitPane(splitPaneState = splitPaneState) { - first(100.dp) { - ConceptDiffPanel( - diffDataContainer = diffDataContainer, - localizedStrings = strings, - useDarkTheme = useDarkTheme - ) - } - second(100.dp) { - MetadataDiffPanel( - diffDataContainer = diffDataContainer, - localizedStrings = strings, - useDarkTheme = useDarkTheme, - ) - } - splitter { - visiblePart { - Box(Modifier.height(3.dp).fillMaxWidth() - .background(MaterialTheme.colorScheme.primary)) - } - handle { - Box( - Modifier - .markAsHandle() - .cursorForHorizontalResize() - .background(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) - .height(9.dp) - .fillMaxWidth() - ) - } - } - } - } -} - -fun getFileChooser(title: String): JFileChooser { - return when (SystemUtils.IS_OS_MAC) { - // NativeJFileChooser hangs on Azul Zulu 11 + JavaFX on macOS 12.1 aarch64. - // With Azul Zulu w/o JFX, currently the file browser does not work at all on a M1 MBA. - // Hence, the non-native file chooser from Swing is used instead, which is not *nearly* as nice - // as the native dialog on Windows, but it seems to be much more stable. - true -> JFileChooser(AppPreferences.fileBrowserDirectory) - else -> NativeJFileChooser(AppPreferences.fileBrowserDirectory) - }.apply { - dialogTitle = title - isAcceptAllFileFilterUsed = false - addChoosableFileFilter(FileNameExtensionFilter("FHIR+JSON (*.json)", "json", "JSON")) - addChoosableFileFilter(FileNameExtensionFilter("FHIR+XML (*.xml)", "xml", "XML")) - } -} - -fun showLoadFileDialog(title: String): File? = getFileChooser(title).let { chooser -> - when (chooser.showOpenDialog(null)) { - JFileChooser.CANCEL_OPTION -> null - JFileChooser.APPROVE_OPTION -> { - return@let chooser.selectedFile?.absoluteFile ?: return null - } - else -> null - } -} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/ScaffoldComponents.kt b/src/main/kotlin/terminodiff/ui/ScaffoldComponents.kt index a4f4f91..856ce3e 100644 --- a/src/main/kotlin/terminodiff/ui/ScaffoldComponents.kt +++ b/src/main/kotlin/terminodiff/ui/ScaffoldComponents.kt @@ -8,7 +8,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -47,7 +50,8 @@ class AppIconResource { @Composable fun loadXmlImageVector(relativePath: ImageRelativePath): ImageVector = - loadFile(relativePath)?.let { loadXmlImageVector(it) } ?: throw IllegalArgumentException("the file $relativePath could not be loaded") + loadFile(relativePath)?.let { loadXmlImageVector(it) } + ?: throw IllegalArgumentException("the file $relativePath could not be loaded") } } @@ -55,27 +59,26 @@ class AppIconResource { fun TerminoDiffTopAppBar( localizedStrings: LocalizedStrings, onLocaleChange: () -> Unit, - onLoadLeftFile: () -> Unit, - onLoadRightFile: () -> Unit, onChangeDarkTheme: () -> Unit, onReload: () -> Unit, + onShowLoadScreen: () -> Unit, ) { TopAppBar(title = { Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.padding(end = 16.dp), text = localizedStrings.terminoDiff, - color = MaterialTheme.colorScheme.onPrimaryContainer) + color = colorScheme.onPrimaryContainer) AppImageIcon( relativePath = AppIconResource.icUniLuebeck, label = localizedStrings.uniLuebeck, - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = colorScheme.onPrimaryContainer, modifier = Modifier.fillMaxHeight(0.8f) ) } }, - backgroundColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + backgroundColor = colorScheme.primaryContainer, + contentColor = colorScheme.onPrimaryContainer, actions = { MouseOverPopup(localizedStrings.toggleDarkTheme) { IconActionButton(onClick = onChangeDarkTheme, @@ -87,15 +90,12 @@ fun TerminoDiffTopAppBar( imageRelativePath = AppIconResource.icChangeLanguage, label = localizedStrings.changeLanguage) } - MouseOverPopup(localizedStrings.loadLeftFile) { - IconActionButton(onClick = onLoadLeftFile, - imageRelativePath = AppIconResource.icLoadLeftFile, - label = localizedStrings.loadLeftFile) - } - MouseOverPopup(localizedStrings.loadRightFile) { - IconActionButton(onClick = onLoadRightFile, - imageRelativePath = AppIconResource.icLoadRightFile, - label = localizedStrings.loadRightFile) + MouseOverPopup(localizedStrings.openResources) { + IconActionButton( + onClick = onShowLoadScreen, + imageVector = Icons.Default.FolderOpen, + label = localizedStrings.reload + ) } MouseOverPopup(localizedStrings.reload) { IconActionButton(onClick = onReload, @@ -116,11 +116,26 @@ private fun IconActionButton( } } +@Composable +private fun IconActionButton( + onClick: () -> Unit, + imageVector: ImageVector, + label: String, +) { + IconButton(onClick = onClick) { + Icon( + imageVector = imageVector, + contentDescription = label, + tint = colorScheme.onPrimaryContainer, + ) + } +} + @Composable fun AppImageIcon( relativePath: ImageRelativePath, label: String, - tint: Color = MaterialTheme.colorScheme.onPrimaryContainer, + tint: Color = colorScheme.onPrimaryContainer, modifier: Modifier = Modifier, ) { AppIconResource.loadFile(relativePath)?.let { iconStream -> @@ -138,8 +153,8 @@ fun AppImageIcon( @Composable fun MouseOverPopup( text: String, - backgroundColor: Color = MaterialTheme.colorScheme.tertiaryContainer, - foregroundColor: Color = MaterialTheme.colorScheme.onTertiaryContainer, + backgroundColor: Color = colorScheme.tertiaryContainer, + foregroundColor: Color = colorScheme.onTertiaryContainer, content: @Composable () -> Unit, ) = TooltipArea(tooltip = { Surface(modifier = Modifier.shadow(4.dp), color = backgroundColor, shape = RoundedCornerShape(4.dp)) { diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffColumns.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffColumns.kt index 655ae02..6bf2c0f 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffColumns.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffColumns.kt @@ -1,68 +1,68 @@ package terminodiff.ui.panes.conceptdiff import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.OutlinedButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import terminodiff.engine.concepts.ConceptDiff import terminodiff.engine.concepts.ConceptDiffItem -import terminodiff.engine.concepts.ConceptDiffResult import terminodiff.engine.concepts.KeyedListDiffResultKind import terminodiff.engine.graph.FhirConceptDetails import terminodiff.i18n.LocalizedStrings import terminodiff.ui.AppIconResource import terminodiff.ui.theme.DiffColors -import terminodiff.ui.util.ColumnSpec -import terminodiff.ui.util.DiffChip -import terminodiff.ui.util.SelectableText -import terminodiff.ui.util.colorPairForConceptDiffResult +import terminodiff.ui.util.* fun conceptDiffColumnSpecs( localizedStrings: LocalizedStrings, diffColors: DiffColors, showPropertyDialog: (ConceptTableData) -> Unit, + showDisplayDetailsDialog: (ConceptTableData) -> Unit, + showDefinitionDetailsDialog: (ConceptTableData) -> Unit, ) = listOf(codeColumnSpec(localizedStrings), - displayColumnSpec(localizedStrings, diffColors), - definitionColumnSpec(localizedStrings, diffColors), + displayColumnSpec(localizedStrings, diffColors, showDisplayDetailsDialog), + definitionColumnSpec(localizedStrings, diffColors, showDefinitionDetailsDialog), propertyDesignationColumnSpec(localizedStrings, diffColors, showPropertyDialog), overallComparisonColumnSpec(localizedStrings, diffColors)) private fun codeColumnSpec(localizedStrings: LocalizedStrings) = - ColumnSpec(title = localizedStrings.code, weight = 0.1f, content = { - SelectableText(it.code, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) - }) + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.code, + weight = 0.1f, + instanceGetter = { code }) private fun displayColumnSpec( - localizedStrings: LocalizedStrings, diffColors: DiffColors, + localizedStrings: LocalizedStrings, diffColors: DiffColors, showDisplayDetailsDialog: (ConceptTableData) -> Unit, ) = columnSpecForProperty(localizedStrings = localizedStrings, title = localizedStrings.display, diffColors = diffColors, labelToFind = localizedStrings.display, weight = 0.25f, - stringValueResolver = FhirConceptDetails::display) + stringValueResolver = FhirConceptDetails::display, + onDetailClick = showDisplayDetailsDialog +) private fun definitionColumnSpec( localizedStrings: LocalizedStrings, diffColors: DiffColors, + showDefinitionDetailsDialog: (ConceptTableData) -> Unit, ) = columnSpecForProperty(localizedStrings = localizedStrings, title = localizedStrings.definition, diffColors = diffColors, labelToFind = localizedStrings.definition, weight = 0.25f, - stringValueResolver = FhirConceptDetails::definition) + stringValueResolver = FhirConceptDetails::definition, + onDetailClick = showDefinitionDetailsDialog) private fun propertyDesignationColumnSpec( localizedStrings: LocalizedStrings, diffColors: DiffColors, @@ -164,12 +164,14 @@ private fun columnSpecForProperty( labelToFind: String, @Suppress("SameParameterValue") weight: Float, stringValueResolver: (FhirConceptDetails) -> String?, -): ColumnSpec { + onDetailClick: ((ConceptTableData) -> Unit)? = null, +): ColumnSpec.StringSearchableColumnSpec { val tooltipTextFun: (ConceptTableData) -> String? = { data -> tooltipForConceptProperty(data.leftDetails, data.rightDetails, stringValueResolver) } - return ColumnSpec( + return ColumnSpec.StringSearchableColumnSpec( title = title, weight = weight, + instanceGetter = tooltipTextFun, tooltipText = tooltipTextFun, ) { data -> val singleConcept = when { @@ -178,13 +180,42 @@ private fun columnSpecForProperty( else -> null } when { - data.isInBoth() -> contentWithText(diff = data.diff!!, + data.isInBoth() -> contentWithText( + conceptData = data, localizedStrings = localizedStrings, diffColors = diffColors, labelToFind = labelToFind, - text = tooltipTextFun(data)) + text = tooltipTextFun(data), + onDetailClick = onDetailClick) singleConcept != null -> { // else - SelectableText(text = stringValueResolver.invoke(singleConcept)) + val text = stringValueResolver.invoke(singleConcept) + val textDisplay: @Composable (Color) -> Unit = { color -> + NullableText( + text = text, + color = color, + style = typography.labelMedium, + overflow = TextOverflow.Clip) + } + when (text != null) { + true -> Row(Modifier.padding(2.dp)) { + OutlinedButton( + modifier = Modifier.padding(2.dp), + onClick = { onDetailClick?.invoke(data) }, + elevation = ButtonDefaults.elevation(4.dp), + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onTertiaryContainer) + ) { + textDisplay.invoke(MaterialTheme.colorScheme.onTertiaryContainer) + } + } + else -> Row(modifier = Modifier.padding(2.dp).fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + textDisplay(LocalContentColor.current) + } + } + } } } @@ -206,31 +237,54 @@ private fun tooltipForConceptProperty( @Composable private fun contentWithText( - diff: ConceptDiff, localizedStrings: LocalizedStrings, diffColors: DiffColors, text: String?, labelToFind: String, + conceptData: ConceptTableData, + localizedStrings: LocalizedStrings, + diffColors: DiffColors, + text: String?, + labelToFind: String, + onDetailClick: ((ConceptTableData) -> Unit)? = null, ) { - Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - ChipForConceptDiffResult(modifier = Modifier.padding(end = 2.dp), - conceptComparison = diff.conceptComparison, + Row(horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically) { + ChipForConceptDiffResult( + modifier = Modifier.padding(end = 2.dp), + conceptData = conceptData, labelToFind = labelToFind, localizedStrings = localizedStrings, - diffColors = diffColors) - SelectableText(text = text) + diffColors = diffColors, + onDetailClick = onDetailClick) + SelectableText(text = text, style = typography.labelMedium) } } @Composable private fun ChipForConceptDiffResult( modifier: Modifier = Modifier, - conceptComparison: List, + conceptData: ConceptTableData, labelToFind: String, localizedStrings: LocalizedStrings, diffColors: DiffColors, + onDetailClick: ((ConceptTableData) -> Unit)?, ) { - val result = conceptComparison.find { it.diffItem.label.invoke(localizedStrings) == labelToFind } ?: return - val colorsForResult = colorPairForConceptDiffResult(result, diffColors) - DiffChip(modifier = modifier, - text = localizedStrings.conceptDiffResults_.invoke(result.result), - backgroundColor = colorsForResult.first, - textColor = colorsForResult.second, - icon = null) + val result = conceptData.diff!!.conceptComparison.find { it.diffItem.label.invoke(localizedStrings) == labelToFind } + ?: return + val (background, foreground) = colorPairForConceptDiffResult(result, diffColors) + when (onDetailClick) { + null -> DiffChip(modifier = modifier, + text = localizedStrings.conceptDiffResults_.invoke(result.result), + backgroundColor = background, + textColor = foreground, + icon = null) + else -> Button(onClick = { + onDetailClick(conceptData) + }, + elevation = ButtonDefaults.elevation(4.dp), + contentPadding = PaddingValues(1.dp), + colors = ButtonDefaults.buttonColors(background, foreground)) { + Text(text = localizedStrings.conceptDiffResults_.invoke(result.result), + style = typography.bodyMedium, + color = foreground) + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt index 283bc00..0f4dd45 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt @@ -23,6 +23,7 @@ import terminodiff.engine.graph.CodeSystemGraphBuilder import terminodiff.engine.graph.FhirConceptDetails import terminodiff.engine.resources.DiffDataContainer import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.ui.panes.conceptdiff.display.DisplayDetailsDialog import terminodiff.terminodiff.ui.panes.conceptdiff.propertydesignation.PropertyDesignationDialog import terminodiff.ui.theme.DiffColors import terminodiff.ui.theme.getDiffColors @@ -34,6 +35,10 @@ import java.util.* private val logger: Logger = LoggerFactory.getLogger("conceptdiffpanel") +private enum class DetailsDialogKind { + PROPERTY_DESIGNATION, DISPLAY, DEFINITION +} + @Composable fun ConceptDiffPanel( diffDataContainer: DiffDataContainer, @@ -57,12 +62,27 @@ fun ConceptDiffPanel( filterSpecs.associate { it.name to filterDiffItems(diffDataContainer, it.name).shownCodes.size } } - var propertyDialogData: ConceptTableData? by remember { mutableStateOf(null) } + var dialogData: Pair? by remember { mutableStateOf(null) } - propertyDialogData?.let { conceptTableData -> - PropertyDesignationDialog(conceptTableData, localizedStrings, useDarkTheme) { - propertyDialogData = null + dialogData?.let { (data, kind) -> + val onClose: () -> Unit = { dialogData = null } + when (kind) { + DetailsDialogKind.PROPERTY_DESIGNATION -> PropertyDesignationDialog(data, + localizedStrings, + useDarkTheme, + onClose) + DetailsDialogKind.DISPLAY -> DisplayDetailsDialog(data = data, + localizedStrings = localizedStrings, + label = localizedStrings.display, + useDarkTheme = useDarkTheme, + onClose = onClose) { it.display } + DetailsDialogKind.DEFINITION -> DisplayDetailsDialog(data = data, + localizedStrings = localizedStrings, + label = localizedStrings.definition, + useDarkTheme = useDarkTheme, + onClose = onClose) { it.definition } } + } Card( @@ -89,8 +109,13 @@ fun ConceptDiffPanel( diffColors = diffColors, lazyListState = lazyListState, showPropertyDialog = { - propertyDialogData = it - logger.info("showing details dialog for concept ${it.code}") + dialogData = it to DetailsDialogKind.PROPERTY_DESIGNATION + }, + showDisplayDetailsDialog = { + dialogData = it to DetailsDialogKind.DISPLAY + }, + showDefinitionDetailsDialog = { + dialogData = it to DetailsDialogKind.DEFINITION }) } } @@ -158,12 +183,23 @@ fun DiffDataTable( diffColors: DiffColors, lazyListState: LazyListState, showPropertyDialog: (ConceptTableData) -> Unit, + showDisplayDetailsDialog: (ConceptTableData) -> Unit, + showDefinitionDetailsDialog: (ConceptTableData) -> Unit, ) { if (diffDataContainer.codeSystemDiff == null) throw IllegalStateException("the diff data container is not initialized") - val columnSpecs = conceptDiffColumnSpecs(localizedStrings, diffColors, showPropertyDialog) + val columnSpecs = conceptDiffColumnSpecs(localizedStrings, + diffColors, + showPropertyDialog, + showDisplayDetailsDialog, + showDefinitionDetailsDialog) - TableScreen(tableData = tableData, lazyListState = lazyListState, columnSpecs = columnSpecs) + TableScreen( + tableData = tableData, + lazyListState = lazyListState, + columnSpecs = columnSpecs, + localizedStrings = localizedStrings, + ) } data class ConceptTableData( @@ -179,7 +215,10 @@ data class ConceptTableData( @Composable fun TableScreen( - tableData: TableData, lazyListState: LazyListState, columnSpecs: List>, + tableData: TableData, + lazyListState: LazyListState, + columnSpecs: List>, + localizedStrings: LocalizedStrings, ) { val containedData: List = tableData.shownCodes.map { code -> ConceptTableData(code = code, @@ -187,10 +226,13 @@ fun TableScreen( rightDetails = tableData.rightGraphBuilder.nodeTree[code], diff = tableData.conceptDiff[code]) } - LazyTable(columnSpecs = columnSpecs, + LazyTable( + columnSpecs = columnSpecs, + backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, lazyListState = lazyListState, + zebraStripingColor = MaterialTheme.colorScheme.primaryContainer, tableData = containedData, + localizedStrings = localizedStrings, keyFun = { it.code }, - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - zebraStripingColor = MaterialTheme.colorScheme.primaryContainer) + ) } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt new file mode 100644 index 0000000..ec68f71 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt @@ -0,0 +1,83 @@ +package terminodiff.terminodiff.ui.panes.conceptdiff.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Card +import androidx.compose.material.TextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberDialogState +import terminodiff.engine.graph.FhirConceptDetails +import terminodiff.i18n.LocalizedStrings +import terminodiff.ui.panes.conceptdiff.ConceptTableData +import terminodiff.ui.theme.getDiffColors +import terminodiff.ui.util.DiffChip +import terminodiff.ui.util.colorPairForConceptDiffResult + + +@Composable +fun DisplayDetailsDialog( + data: ConceptTableData, + localizedStrings: LocalizedStrings, + label: String, + useDarkTheme: Boolean, + onClose: () -> Unit, + dataGetter: (FhirConceptDetails) -> String?, +) { + val diffColors by derivedStateOf { getDiffColors(useDarkTheme = useDarkTheme) } + Dialog(onCloseRequest = onClose, + title = label, + state = rememberDialogState(WindowPosition(Alignment.Center), size = DpSize(512.dp, 400.dp))) { + Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize(), + verticalArrangement = Arrangement.Center) { + if (data.leftDetails != null) CardForDisplay(data.leftDetails, localizedStrings.leftValue, dataGetter) + if (data.isInBoth()) { + val result = + data.diff!!.conceptComparison.find { it.diffItem.label.invoke(localizedStrings) == label } + ?: return@Dialog + val (background, foreground) = colorPairForConceptDiffResult(result, diffColors) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + DiffChip( + Modifier.fillMaxWidth(0.5f).height(50.dp), + text = localizedStrings.conceptDiffResults_.invoke(result.result), + backgroundColor = background, + textColor = foreground) + } + } + if (data.rightDetails != null) CardForDisplay(data.rightDetails, localizedStrings.rightValue, dataGetter) + } + } +} + +@Composable +private fun CardForDisplay( + fhirConceptDetails: FhirConceptDetails, + title: String, + dataGetter: (FhirConceptDetails) -> String?, +) = + Card(modifier = Modifier.padding(4.dp).fillMaxWidth(), + backgroundColor = colorScheme.secondaryContainer, + contentColor = colorScheme.onSecondaryContainer) { + Column(modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSecondaryContainer) + val text = dataGetter.invoke(fhirConceptDetails) + TextField(modifier = Modifier.fillMaxWidth(0.9f), + value = text ?: "", + onValueChange = {}, + readOnly = true) + } + } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt index 65f0a47..5633425 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt @@ -68,35 +68,49 @@ fun PropertyDesignationDialog( Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize()) { VerticalSplitPane(splitPaneState = splitPaneState) { first { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 4.dp)) { + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 4.dp)) { Text(localizedStrings.properties, style = typography.titleMedium, color = colorScheme.onPrimaryContainer) when { - conceptData.isInBoth() -> DiffPropertyTable(conceptData.diff!!, - propertyDiffColumnSpecs, - propertyListState) - else -> SingleConceptPropertyTable(conceptData.leftDetails, - conceptData.rightDetails, - identicalPropertyColumnSpecs, - propertyListState) + conceptData.isInBoth() -> DiffPropertyTable( + conceptDiff = conceptData.diff!!, + diffColumnSpecs = propertyDiffColumnSpecs, + lazyListState = propertyListState, + localizedStrings = localizedStrings + ) + else -> SingleConceptPropertyTable( + leftDetails = conceptData.leftDetails, + rightDetails = conceptData.rightDetails, + identicalColumnSpecs = identicalPropertyColumnSpecs, + lazyListState = propertyListState, + localizedStrings = localizedStrings, + ) } } } second { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 4.dp)) { + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 4.dp)) { Text(localizedStrings.designations, style = typography.titleMedium, color = colorScheme.onPrimaryContainer) when { - conceptData.isInBoth() -> DiffDesignationTable(conceptData.diff!!, - designationDiffColumnSpecs, - designationListState) - else -> DesignationTable(conceptData.leftDetails, - conceptData.rightDetails, - identicalDesignationColumnSpecs, - designationListState) + conceptData.isInBoth() -> DiffDesignationTable( + diff = conceptData.diff!!, + columnSpecs = designationDiffColumnSpecs, + designationListState = designationListState, + localizedStrings = localizedStrings, + ) + else -> DesignationTable( + leftDetails = conceptData.leftDetails, + rightDetails = conceptData.rightDetails, + columnSpecs = identicalDesignationColumnSpecs, + designationListState = designationListState, + localizedStrings = localizedStrings + ) } } } @@ -128,6 +142,7 @@ fun DesignationTable( rightDetails: FhirConceptDetails?, columnSpecs: List>, designationListState: LazyListState, + localizedStrings: LocalizedStrings, ) = when (leftDetails) { null -> rightDetails else -> leftDetails @@ -135,13 +150,15 @@ fun DesignationTable( LazyTable( modifier = Modifier.padding(8.dp), columnSpecs = columnSpecs, - tableData = tableData, backgroundColor = colorScheme.primaryContainer, + lazyListState = designationListState, zebraStripingColor = colorScheme.tertiaryContainer, - lazyListState = designationListState - ) { - it.language ?: "null" - } + tableData = tableData, + localizedStrings = localizedStrings, + keyFun = { + it.language ?: "null" + }, + ) } @Composable @@ -149,12 +166,17 @@ fun DiffDesignationTable( diff: ConceptDiff, columnSpecs: List>>, designationListState: LazyListState, -) = LazyTable(modifier = Modifier.padding(8.dp), + localizedStrings: LocalizedStrings, +) = LazyTable( + modifier = Modifier.padding(8.dp), columnSpecs = columnSpecs, + backgroundColor = colorScheme.primaryContainer, lazyListState = designationListState, + zebraStripingColor = colorScheme.tertiaryContainer, tableData = diff.designationComparison, - backgroundColor = colorScheme.primaryContainer, - zebraStripingColor = colorScheme.tertiaryContainer) { it.key.toString() } + localizedStrings = localizedStrings, + keyFun = { it.key.toString() }, +) @Composable fun SingleConceptPropertyTable( @@ -162,16 +184,21 @@ fun SingleConceptPropertyTable( rightDetails: FhirConceptDetails?, identicalColumnSpecs: List>, lazyListState: LazyListState, + localizedStrings: LocalizedStrings, ) = when (leftDetails) { null -> rightDetails else -> leftDetails }?.property?.let { tableData -> - LazyTable(modifier = Modifier.padding(8.dp), + LazyTable( + modifier = Modifier.padding(8.dp), columnSpecs = identicalColumnSpecs, + backgroundColor = colorScheme.primaryContainer, lazyListState = lazyListState, + zebraStripingColor = colorScheme.tertiaryContainer, tableData = tableData, - backgroundColor = colorScheme.primaryContainer, - zebraStripingColor = colorScheme.tertiaryContainer) { it.propertyCode } + localizedStrings = localizedStrings, + keyFun = { it.propertyCode }, + ) } @Composable @@ -179,9 +206,14 @@ fun DiffPropertyTable( conceptDiff: ConceptDiff, diffColumnSpecs: List>, lazyListState: LazyListState, -) = LazyTable(modifier = Modifier.padding(8.dp), + localizedStrings: LocalizedStrings, +) = LazyTable( + modifier = Modifier.padding(8.dp), columnSpecs = diffColumnSpecs, + backgroundColor = colorScheme.primaryContainer, lazyListState = lazyListState, + zebraStripingColor = colorScheme.tertiaryContainer, tableData = conceptDiff.propertyComparison, - backgroundColor = colorScheme.primaryContainer, - zebraStripingColor = colorScheme.tertiaryContainer) { it.key } \ No newline at end of file + localizedStrings = localizedStrings, + keyFun = { it.key }, +) \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDiffColumns.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDiffColumns.kt index e6d17a3..74c7256 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDiffColumns.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDiffColumns.kt @@ -80,9 +80,14 @@ private fun rightDesignationValueColumnSpec(localizedStrings: LocalizedStrings) fun propertyCodeColumnSpec(localizedStrings: LocalizedStrings, codeGetter: (T) -> String) = - ColumnSpec(localizedStrings.code, 0.2f) { + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.code, weight = 0.2f, instanceGetter = { + codeGetter.invoke(this) + }, content = { textForValue(codeGetter.invoke(it)) - } + }) +/*ColumnSpec.StringSearchableColumnSpec(localizedStrings.code, 0.2f, instanceGetter = { codeGetter(this)}) { + textForValue(codeGetter.invoke(it)) +}*/ private fun propertyTypeColumnSpec(localizedStrings: LocalizedStrings, typeGetter: (T) -> CodeSystem.PropertyType) = ColumnSpec(localizedStrings.propertyType, weight = 0.2f) { diff --git a/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt b/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt new file mode 100644 index 0000000..2b8eff9 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt @@ -0,0 +1,78 @@ +package terminodiff.terminodiff.ui.panes.diff + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import org.jetbrains.compose.splitpane.SplitPaneState +import org.jetbrains.compose.splitpane.VerticalSplitPane +import terminodiff.engine.resources.DiffDataContainer +import terminodiff.i18n.LocalizedStrings +import terminodiff.ui.cursorForHorizontalResize +import terminodiff.ui.panes.conceptdiff.ConceptDiffPanel +import terminodiff.ui.panes.graph.ShowGraphsPanel +import terminodiff.ui.panes.metadatadiff.MetadataDiffPanel + +@OptIn(ExperimentalSplitPaneApi::class) +@Composable +fun DiffPaneContent( + modifier: Modifier = Modifier, + scrollState: ScrollState, + strings: LocalizedStrings, + useDarkTheme: Boolean, + diffDataContainer: DiffDataContainer, + splitPaneState: SplitPaneState, +) { + Column( + modifier = modifier.scrollable(scrollState, Orientation.Vertical), + ) { + ShowGraphsPanel( + leftCs = diffDataContainer.leftCodeSystem!!, + rightCs = diffDataContainer.rightCodeSystem!!, + diffGraph = diffDataContainer.codeSystemDiff!!.differenceGraph, + localizedStrings = strings, + useDarkTheme = useDarkTheme, + ) + VerticalSplitPane(splitPaneState = splitPaneState) { + first(100.dp) { + ConceptDiffPanel( + diffDataContainer = diffDataContainer, + localizedStrings = strings, + useDarkTheme = useDarkTheme + ) + } + second(100.dp) { + MetadataDiffPanel( + diffDataContainer = diffDataContainer, + localizedStrings = strings, + useDarkTheme = useDarkTheme, + ) + } + splitter { + visiblePart { + Box(Modifier.height(3.dp).fillMaxWidth() + .background(MaterialTheme.colorScheme.primary)) + } + handle { + Box( + Modifier + .markAsHandle() + .cursorForHorizontalResize() + .background(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + .height(9.dp) + .fillMaxWidth() + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/graph/GraphPane.kt b/src/main/kotlin/terminodiff/ui/panes/graph/GraphPane.kt index fced5ba..c7adf62 100644 --- a/src/main/kotlin/terminodiff/ui/panes/graph/GraphPane.kt +++ b/src/main/kotlin/terminodiff/ui/panes/graph/GraphPane.kt @@ -27,7 +27,7 @@ fun ShowGraphsPanel( useDarkTheme: Boolean, ) { Card( - Modifier.padding(8.dp).fillMaxWidth(), + Modifier.padding(top = 8.dp, bottom = 0.dp, start = 8.dp, end = 8.dp).fillMaxWidth(), elevation = 8.dp, backgroundColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer @@ -37,7 +37,7 @@ fun ShowGraphsPanel( contentColor = MaterialTheme.colorScheme.onPrimary ) Row( - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(2.dp), horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically ) { diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt new file mode 100644 index 0000000..5d631c0 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt @@ -0,0 +1,160 @@ +package terminodiff.terminodiff.ui.panes.loaddata + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import ca.uhn.fhir.context.FhirContext +import libraries.accompanist.pager.ExperimentalPagerApi +import libraries.accompanist.pager.rememberPagerState +import terminodiff.engine.resources.DiffDataContainer +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.terminodiff.engine.resources.InputResource.Kind +import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadFilesTabItem +import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadListener +import terminodiff.terminodiff.ui.panes.loaddata.panes.Tabs +import terminodiff.terminodiff.ui.panes.loaddata.panes.TabsContent + +@Composable +fun LoadDataPaneContent( + modifier: Modifier = Modifier, + scrollState: ScrollState, + localizedStrings: LocalizedStrings, + onLoadLeft: LoadListener, + onLoadRight: LoadListener, + leftResource: InputResource?, + rightResource: InputResource?, + fhirContext: FhirContext, + onGoButtonClick: () -> Unit, +) { + Column(modifier.scrollable(scrollState, Orientation.Vertical)) { + LoadedResourcesCard(localizedStrings, leftResource, rightResource, onGoButtonClick) + LoadResourcesCards(onLoadLeft, onLoadRight, localizedStrings, fhirContext) + } +} + +@Composable +fun ColumnScope.LoadedResourcesCard( + localizedStrings: LocalizedStrings, + leftResource: InputResource?, + rightResource: InputResource?, + onGoButtonClick: () -> Unit, +) = Card(modifier = Modifier.padding(8.dp).fillMaxWidth().weight(0.15f), + elevation = 8.dp, + backgroundColor = colorScheme.secondaryContainer, + contentColor = colorScheme.onSecondaryContainer) { + Column(Modifier.padding(4.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top) { + when { + leftResource != null && rightResource != null -> { + Button( + modifier = Modifier.weight(0.3f), + onClick = onGoButtonClick, + elevation = ButtonDefaults.elevation(8.dp), + colors = ButtonDefaults.buttonColors(colorScheme.primary, + colorScheme.onPrimary)) { + Text(localizedStrings.calculateDiff, color = colorScheme.onPrimary) + } + } + else -> { + Text(text = localizedStrings.loadedResources, + style = typography.titleLarge, + color = colorScheme.onSecondaryContainer) + } + } + + Row(modifier = Modifier.padding(4.dp).fillMaxWidth().weight(0.7f), + horizontalArrangement = Arrangement.SpaceAround) { + ResourceDescription(Modifier.weight(0.45f), localizedStrings, leftResource, DiffDataContainer.Side.LEFT) + Divider(color = colorScheme.onSecondaryContainer, modifier = Modifier.width(2.dp).fillMaxHeight()) + ResourceDescription(Modifier.weight(0.45f), localizedStrings, rightResource, DiffDataContainer.Side.RIGHT) + } + } +} + +@Composable +fun ResourceDescription( + modifier: Modifier = Modifier, + localizedStrings: LocalizedStrings, + resource: InputResource?, + side: DiffDataContainer.Side, +) = Column(modifier = modifier.wrapContentHeight(), horizontalAlignment = Alignment.CenterHorizontally) { + val text by derivedStateOf { formatText(resource, localizedStrings) } + Text(text = localizedStrings.side_(side), + style = typography.titleMedium, + textDecoration = TextDecoration.Underline, + color = colorScheme.onSecondaryContainer) + Row(modifier = Modifier.align(Alignment.CenterHorizontally).height(IntrinsicSize.Min)) { + Text( + text = text, + textAlign = TextAlign.Center, + color = colorScheme.onSecondaryContainer, + maxLines = 3, + softWrap = true, + ) + } +} + +private fun formatText(resource: InputResource?, localizedStrings: LocalizedStrings): AnnotatedString { + val stringDescription = when { + resource == null -> localizedStrings.noDataLoaded + resource.kind == Kind.FILE -> { + val path = resource.localFile!!.canonicalFile.invariantSeparatorsPath + localizedStrings.fileFromPath_.invoke(path) + } + resource.kind == Kind.FHIR_SERVER -> { + val url = resource.resourceUrl!! + localizedStrings.fileFromUrl_.invoke(url) + } + resource.kind == Kind.VREAD -> { + val url = resource.resourceUrl!! + val metaVersion = resource.downloadableCodeSystem!!.metaVersion + localizedStrings.vreadFromUrlAndMetaVersion_.invoke(url, metaVersion!!) + } + else -> "" + } + return AnnotatedString(stringDescription) +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun ColumnScope.LoadResourcesCards( + onLoadLeft: LoadListener, + onLoadRight: LoadListener, + localizedStrings: LocalizedStrings, + fhirContext : FhirContext, +) = Card(modifier = Modifier.padding(8.dp).fillMaxWidth().weight(0.66f, true), + elevation = 8.dp, + backgroundColor = colorScheme.tertiaryContainer, + contentColor = colorScheme.onTertiaryContainer) { + val tabs = listOf(LoadFilesTabItem.FromFile, LoadFilesTabItem.FromTerminologyServer) + val pagerState = rememberPagerState() + Column(modifier = Modifier.padding(4.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Tabs(tabs = tabs, pagerState = pagerState, localizedStrings = localizedStrings) + TabsContent(tabs = tabs, + pagerState = pagerState, + localizedStrings = localizedStrings, + onLoadLeft = onLoadLeft, + onLoadRight = onLoadRight, + fhirContext = fhirContext + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt new file mode 100644 index 0000000..bdbce1b --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt @@ -0,0 +1,129 @@ +package terminodiff.terminodiff.ui.panes.loaddata.panes + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Plagiarism +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import li.flor.nativejfilechooser.NativeJFileChooser +import org.apache.commons.lang3.SystemUtils +import terminodiff.i18n.LocalizedStrings +import terminodiff.preferences.AppPreferences +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.ui.AppIconResource +import terminodiff.ui.AppImageIcon +import java.io.File +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter +import kotlin.io.path.invariantSeparatorsPathString + + +@Composable +fun FromFileScreenWrapper( + localizedStrings: LocalizedStrings, + onLoadLeft: LoadListener, + onLoadRight: LoadListener, +) { + var selectedPath: String by remember { mutableStateOf("") } + val selectedFile: File by derivedStateOf { File(selectedPath) } + FromFileScreen(localizedStrings = localizedStrings, + selectedFile = selectedFile, + selectedPath = selectedPath, + onChangeFilePath = { + selectedPath = it ?: "" + }, + onLoadLeftFile = onLoadLeft, + onLoadRightFile = onLoadRight) +} + +@Composable +private fun FromFileScreen( + localizedStrings: LocalizedStrings, + selectedFile: File?, + onChangeFilePath: (String?) -> Unit, + onLoadLeftFile: (InputResource) -> Unit, + onLoadRightFile: (InputResource) -> Unit, + selectedPath: String, +) = Column(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center)) { + val buttonColors = + ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary) + val isValidPath by derivedStateOf { + when { + selectedFile == null -> false + selectedFile.exists() -> true + else -> false + } + } + + LabeledTextField( + value = selectedPath, + onValueChange = onChangeFilePath, + modifier = Modifier.fillMaxWidth().padding(12.dp), + labelText = localizedStrings.fileSystem, + trailingIconVector = Icons.Default.Plagiarism, + trailingIconDescription = localizedStrings.fileSystem + ) { + val newFile = showLoadFileDialog(localizedStrings.loadFromFile) + newFile?.let { + onChangeFilePath.invoke(it.absolutePath) + AppPreferences.fileBrowserDirectory = it.toPath().parent.invariantSeparatorsPathString + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Button(modifier = Modifier.padding(4.dp), + colors = buttonColors, + enabled = isValidPath, + onClick = { onLoadLeftFile(InputResource(InputResource.Kind.FILE, selectedFile)) }, + elevation = ButtonDefaults.elevation(defaultElevation = 8.dp)) { + AppImageIcon(relativePath = AppIconResource.icLoadLeftFile, + label = localizedStrings.loadLeft, + tint = buttonColors.contentColor(enabled = isValidPath).value) + Text(localizedStrings.loadLeft, color = buttonColors.contentColor(enabled = isValidPath).value) + } + Button(modifier = Modifier.padding(4.dp), + colors = buttonColors, + enabled = isValidPath, + onClick = { onLoadRightFile(InputResource(InputResource.Kind.FILE, selectedFile)) }, + elevation = ButtonDefaults.elevation(defaultElevation = 8.dp)) { + AppImageIcon(relativePath = AppIconResource.icLoadRightFile, + label = localizedStrings.loadRight, + tint = buttonColors.contentColor(enabled = isValidPath).value) + Text(localizedStrings.loadRight, color = buttonColors.contentColor(enabled = isValidPath).value) + } + } +} + +private fun getFileChooser(title: String): JFileChooser { + return when (SystemUtils.IS_OS_MAC) { + // NativeJFileChooser hangs on Azul Zulu 17 + JavaFX on macOS 12.1 aarch64. + // With Azul Zulu w/o JFX, currently the file browser does not work at all on a M1 MBA. + // The behaviour of NativeJFileChooser is different on Intel Macs, where it appears to work. + // Hence, the non-native file chooser from Swing is used instead, which is not *nearly* as nice + // as the native dialog on Windows, but it seems to be much more stable. + true -> JFileChooser(AppPreferences.fileBrowserDirectory) + else -> NativeJFileChooser(AppPreferences.fileBrowserDirectory) + }.apply { + dialogTitle = title + isAcceptAllFileFilterUsed = false + addChoosableFileFilter(FileNameExtensionFilter("FHIR+JSON (*.json)", "json", "JSON")) + addChoosableFileFilter(FileNameExtensionFilter("FHIR+XML (*.xml)", "xml", "XML")) + } +} + +fun showLoadFileDialog(title: String): File? = getFileChooser(title).let { chooser -> + when (chooser.showOpenDialog(null)) { + JFileChooser.CANCEL_OPTION -> null + JFileChooser.APPROVE_OPTION -> { + return@let chooser.selectedFile?.absoluteFile ?: return null + } + else -> null + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt new file mode 100644 index 0000000..0fde0db --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt @@ -0,0 +1,129 @@ +package terminodiff.terminodiff.ui.panes.loaddata.panes + +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fireplace +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import ca.uhn.fhir.context.FhirContext +import kotlinx.coroutines.launch +import libraries.accompanist.pager.ExperimentalPagerApi +import libraries.accompanist.pager.HorizontalPager +import libraries.accompanist.pager.PagerState +import libraries.pager_indicators.pagerTabIndicatorOffset +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.ui.MouseOverPopup +import terminodiff.ui.panes.loaddata.panes.fromserver.FromServerScreenWrapper + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun Tabs(tabs: List, pagerState: PagerState, localizedStrings: LocalizedStrings) { + val scope = rememberCoroutineScope() + TabRow(selectedTabIndex = pagerState.currentPage, + backgroundColor = colorScheme.tertiaryContainer, + contentColor = colorScheme.onTertiaryContainer, + indicator = { tabPositions -> + TabRowDefaults.Indicator(Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)) + }) { + tabs.forEachIndexed { index, tabItem -> + LeadingIconTab( + icon = { + Icon(tabItem.icon, contentDescription = null, tint = colorScheme.onTertiaryContainer) + }, + text = { + Text(tabItem.title.invoke(localizedStrings), color = colorScheme.onTertiaryContainer) + }, + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) + } + } +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun TabsContent( + tabs: List, + pagerState: PagerState, + localizedStrings: LocalizedStrings, + onLoadLeft: LoadListener, + onLoadRight: LoadListener, + fhirContext: FhirContext, +) { + HorizontalPager(state = pagerState, count = tabs.size) { page -> + tabs[page].screen(localizedStrings, onLoadLeft, onLoadRight, fhirContext) + } +} + +@Composable +fun LabeledTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + labelText: String, + singleLine: Boolean = true, + trailingIconVector: ImageVector? = null, + trailingIconDescription: String? = null, + onTrailingIconClick: (() -> Unit)? = null, +) = TextField(value = value, + onValueChange = onValueChange, + modifier = modifier, + singleLine = singleLine, + label = { + Text(text = labelText, color = colorScheme.onSecondaryContainer.copy(0.75f)) + }, + trailingIcon = { + trailingIconVector?.let { imageVector -> + if (trailingIconDescription == null) throw IllegalArgumentException("a content description has to be specified if a trailing icon is provided") + MouseOverPopup( + text = trailingIconDescription + ) { + when (onTrailingIconClick) { + null -> Icon(imageVector = imageVector, + contentDescription = trailingIconDescription, + tint = colorScheme.onSecondaryContainer) + else -> IconButton(onClick = onTrailingIconClick) { + Icon(imageVector = imageVector, + contentDescription = trailingIconDescription, + tint = colorScheme.onSecondaryContainer) + } + } + } + + } + }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = colorScheme.secondaryContainer, + textColor = colorScheme.onSecondaryContainer, + focusedIndicatorColor = colorScheme.onSecondaryContainer.copy(0.75f))) + + +typealias LoadListener = (InputResource) -> Unit + +sealed class LoadFilesTabItem( + val icon: ImageVector, + val title: LocalizedStrings.() -> String, + val screen: @Composable (LocalizedStrings, LoadListener, LoadListener, FhirContext) -> Unit, +) { + object FromFile : LoadFilesTabItem(icon = Icons.Default.Save, + title = { fileSystem }, + screen = { localizedStrings, onLoadLeft, onLoadRight, _ -> + FromFileScreenWrapper(localizedStrings, onLoadLeft, onLoadRight) + }) + + object FromTerminologyServer : LoadFilesTabItem(icon = Icons.Default.Fireplace, + title = { fhirTerminologyServer }, + screen = { localizedStrings, onLoadLeft, onLoadRight, fhirContext -> + FromServerScreenWrapper(localizedStrings, onLoadLeft, onLoadRight, fhirContext) + }) +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt new file mode 100644 index 0000000..83a6c55 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt @@ -0,0 +1,345 @@ +package terminodiff.ui.panes.loaddata.panes.fromserver + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Compare +import androidx.compose.material.icons.filled.Pending +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.parser.DataFormatException +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeSystem +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import terminodiff.i18n.LocalizedStrings +import terminodiff.preferences.AppPreferences +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.terminodiff.ui.panes.loaddata.panes.LabeledTextField +import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadListener +import terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver.VReadDialog +import terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver.fromServerPaneColumnSpecs +import terminodiff.ui.AppIconResource +import terminodiff.ui.ImageRelativePath +import terminodiff.ui.MouseOverPopup +import terminodiff.ui.util.ColumnSpec +import terminodiff.ui.util.LazyTable +import java.util.* + +private val logger: Logger = LoggerFactory.getLogger("FromServerScreen") + +@Composable +fun FromServerScreenWrapper( + localizedStrings: LocalizedStrings, + onLoadLeft: LoadListener, + onLoadRight: LoadListener, + fhirContext: FhirContext, +) { + var baseServerUrl: String by remember { mutableStateOf(AppPreferences.terminologyServerUrl) } + + val ktorClient = remember { + HttpClient(CIO) { + expectSuccess = false + followRedirects = true + } + } + + val coroutineScope = rememberCoroutineScope() + + /** + * https://developer.android.com/jetpack/compose/side-effects#producestate + */ + val resourceListPair by produceState?>>(true to null, baseServerUrl) { + value = true to null + val list = listCodeSystems(baseServerUrl, ktorClient, fhirContext) + value = false to list + } + val (isResourceListPending, resourceList) = resourceListPair + FromServerScreen( + localizedStrings = localizedStrings, + baseServerUrl = baseServerUrl, + onChangeBaseServerUrl = { newUrl -> + baseServerUrl = newUrl + AppPreferences.terminologyServerUrl = newUrl + }, + ktorClient = ktorClient, + coroutineScope = coroutineScope, + isResourceListPending = isResourceListPending, + resourceList = resourceList, + fhirContext = fhirContext, + onLoadLeftFile = onLoadLeft, + onLoadRightFile = onLoadRight, + ) +} + +fun urlBuilderWithProtocol(urlString: String) = urlString.trimEnd('/').let { trimUrl -> + URLBuilder(when { + trimUrl.startsWith("http") -> trimUrl + else -> "https://$trimUrl" // add HTTP prefix so that URLs of the fashion http://localhost/termserver.example.local/fhir are not constructed... + }) +} + +private suspend fun listCodeSystems( + urlString: String, + ktorClient: HttpClient, + fhirContext: FhirContext, +): List? = try { + val codeSystemUrl = urlBuilderWithProtocol(urlString).apply { + appendPathSegments("CodeSystem") + parameters.append("_elements", "url,id,version,name,title,link,content") + }.build() + logger.debug("Requesting resource bundle from $codeSystemUrl") + val list = retrieveBundleOfDownloadableResources(ktorClient, codeSystemUrl, fhirContext) + list?.sortedBy { it.canonicalUrl }?.sortedBy { it.version } +} catch (e: Exception) { + logger.info("Error requesting from FHIR Base $urlString: ${e.message}") + null +} + +suspend fun retrieveBundleOfDownloadableResources( + ktorClient: HttpClient, + initialUrl: Url, + fhirContext: FhirContext, +): List? { + var nextUrl: Url? = URLBuilder(initialUrl).apply { + parameters.append("_count", "256") + }.build() + val resources = mutableListOf() + while (nextUrl != null) { + val thisUrl = nextUrl + val bundleRx = ktorClient.get { + url(thisUrl) + headers { + append("Accept", "application/json") + append("Cache-Control", "max-age=30") + } + } + if (!bundleRx.status.isSuccess()) { + logger.debug("GET rx to $thisUrl not successful: ${bundleRx.status}") + return null + } else { + try { + val bundle = fhirContext.newJsonParser().parseResource(Bundle::class.java, bundleRx.bodyAsText()) + val entries: List = bundle.entry.mapNotNull { entry -> + val resource = entry?.resource + when (resource?.resourceType?.name) { + "CodeSystem" -> { + val cs = resource as CodeSystem + val id = when (entry.id) { + null -> cs.idElement.idPart + else -> entry.id + } + DownloadableCodeSystem(physicalUrl = entry.fullUrl, + canonicalUrl = cs.url, + id = id, //cs.idElement.idPart, + version = cs.version, + metaVersion = cs.meta.versionId, + lastChange = cs.meta.lastUpdated, + name = cs.name, + title = cs.title, + content = cs.content) + } + else -> null + } + } + resources.addAll(entries) + logger.debug("Read a page of ${entries.size}, now read ${resources.size}") + nextUrl = bundle.getLink("next")?.url?.let { Url(it) } + } catch (e: DataFormatException) { + return null + } + } + } + logger.info("Retrieved bundle with ${resources.count()} resources from $initialUrl") + return resources.sortedBy { it.canonicalUrl }.sortedBy { it.version } +} + +@Composable +fun FromServerScreen( + localizedStrings: LocalizedStrings, + baseServerUrl: String, + onChangeBaseServerUrl: (String) -> Unit, + coroutineScope: CoroutineScope, + fhirContext: FhirContext, + ktorClient: HttpClient, + isResourceListPending: Boolean, + resourceList: List?, + onLoadLeftFile: LoadListener, + onLoadRightFile: LoadListener, +) = Column(modifier = Modifier.fillMaxSize()) { + val trailingIconPair: Pair by derivedStateOf { + when { + isResourceListPending -> Icons.Default.Pending to localizedStrings.pending + resourceList == null -> Icons.Default.Cancel to localizedStrings.invalid + else -> Icons.Default.CheckCircle to localizedStrings.valid + } + } + val (trailingIcon, trailingIconDescription) = trailingIconPair + val lazyListState = rememberLazyListState() + var vReadResource: InputResource? by remember { mutableStateOf(null) } + vReadResource?.let { + VReadDialog(resource = it, + ktorClient = ktorClient, + fhirContext = fhirContext, + coroutineScope = coroutineScope, + localizedStrings = localizedStrings, + onCloseReject = { vReadResource = null }, + onSelectLeft = onLoadLeftFile, + onSelectRight = onLoadRightFile) + } + LabeledTextField(value = baseServerUrl, + onValueChange = onChangeBaseServerUrl, + modifier = Modifier.fillMaxWidth().padding(12.dp), + labelText = localizedStrings.fhirTerminologyServer, + trailingIconVector = trailingIcon, + trailingIconDescription = trailingIconDescription) + + when { + isResourceListPending -> Row(Modifier.fillMaxWidth().weight(0.5f), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator(Modifier.fillMaxHeight(0.75f).padding(16.dp), colorScheme.onPrimaryContainer) + } + resourceList != null -> { + logger.debug("resource list (${resourceList.size}): ${resourceList.joinToString(limit = 3)}") + ListOfResources(resourceList = resourceList, + lazyListState = lazyListState, + localizedStrings = localizedStrings, + baseServerUrl = baseServerUrl, + coroutineScope = coroutineScope, + ktorClient = ktorClient, + onLoadLeftFile = onLoadLeftFile, + onLoadRightFile = onLoadRightFile, + onShowVReadDialog = { vReadResource = it }) + } + } +} + +@Composable +fun ListOfResources( + resourceList: List, + lazyListState: LazyListState, + localizedStrings: LocalizedStrings, + baseServerUrl: String, + coroutineScope: CoroutineScope, + ktorClient: HttpClient, + onLoadLeftFile: LoadListener, + onLoadRightFile: LoadListener, + onShowVReadDialog: (InputResource) -> Unit, +) { + var selectedItem: DownloadableCodeSystem? by remember { mutableStateOf(null) } + var isDownloadingCurrently by remember { mutableStateOf(false) } + val columnSpecs: List> by derivedStateOf { + fromServerPaneColumnSpecs(localizedStrings, selectedItem, onCheckedChange = { + selectedItem = it + }) + } + + @Composable + fun leftRightButton(text: String, iconPath: ImageRelativePath, onLoadFile: (InputResource) -> Unit) = LoadButton( + text = text, + selectedItem = selectedItem, + baseServerUrl = baseServerUrl, + enabled = selectedItem != null && (!isDownloadingCurrently), + iconImageVector = AppIconResource.loadXmlImageVector(iconPath)) { + isDownloadingCurrently = true + coroutineScope.launch { + val downloaded = it.downloadRemoteFile(ktorClient) + onLoadFile.invoke(downloaded) + isDownloadingCurrently = false + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { + leftRightButton(text = localizedStrings.loadLeft, + iconPath = AppIconResource.icLoadLeftFile, + onLoadFile = onLoadLeftFile) + + val vReadDisabled = (selectedItem?.metaVersion?.equals("1")) ?: true + LoadButton(text = localizedStrings.vRead, + selectedItem = selectedItem, + baseServerUrl = baseServerUrl, + iconImageVector = Icons.Default.Compare, + enabled = !vReadDisabled, + tooltip = localizedStrings.vReadExplanationEnabled_.invoke(!vReadDisabled), + onClick = onShowVReadDialog) + + leftRightButton(text = localizedStrings.loadRight, + iconPath = AppIconResource.icLoadRightFile, + onLoadFile = onLoadRightFile) + } + LazyTable( + modifier = Modifier.padding(4.dp), + columnSpecs = columnSpecs, + backgroundColor = colorScheme.tertiaryContainer, + foregroundColor = colorScheme.onTertiaryContainer, + lazyListState = lazyListState, + zebraStripingColor = colorScheme.primaryContainer, + tableData = resourceList, + localizedStrings = localizedStrings, + keyFun = DownloadableCodeSystem::id, + ) +} + +@Composable +private fun LoadButton( + text: String, + selectedItem: DownloadableCodeSystem?, + baseServerUrl: String, + enabled: Boolean, + iconImageVector: ImageVector, + tooltip: String? = null, + onClick: (InputResource) -> Unit, +) { + val buttonColors = + ButtonDefaults.buttonColors(backgroundColor = colorScheme.primary, contentColor = colorScheme.onPrimary) + MouseOverPopup(text = tooltip ?: text) { + Button(modifier = Modifier.padding(4.dp), + elevation = ButtonDefaults.elevation(defaultElevation = 8.dp), + colors = buttonColors, + onClick = { + selectedItem?.let { item -> + val resource = InputResource(kind = InputResource.Kind.FHIR_SERVER, + resourceUrl = item.physicalUrl, + sourceFhirServerUrl = baseServerUrl, + downloadableCodeSystem = item) + onClick.invoke(resource) + } + }, + enabled = enabled) { + Icon(imageVector = iconImageVector, + contentDescription = text, + tint = buttonColors.contentColor(enabled).value) + Text(text, color = buttonColors.contentColor(enabled).value) + } + } +} + +data class DownloadableCodeSystem( + val id: String, + val physicalUrl: String, + val canonicalUrl: String?, + val version: String?, + val metaVersion: String?, + val name: String?, + val title: String?, + val content: CodeSystem.CodeSystemContentMode?, + val lastChange: Date?, +) \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServerColumns.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServerColumns.kt new file mode 100644 index 0000000..b4001c9 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServerColumns.kt @@ -0,0 +1,60 @@ +package terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver + +import androidx.compose.material.RadioButton +import terminodiff.i18n.LocalizedStrings +import terminodiff.ui.panes.loaddata.panes.fromserver.DownloadableCodeSystem +import terminodiff.ui.util.ColumnSpec +import terminodiff.ui.util.SelectableText + + +fun fromServerPaneColumnSpecs( + localizedStrings: LocalizedStrings, + selectedItem: DownloadableCodeSystem?, + onCheckedChange: (DownloadableCodeSystem) -> Unit, +) = listOf(radioButtonColumnSpec(localizedStrings, selectedItem, onCheckedChange), + canonicalColumnSpec(localizedStrings), + versionColumnSpec(localizedStrings), + nameColumnSpec(localizedStrings), + titleColumnSpec(localizedStrings), + metaVersionColumnSpec(localizedStrings)) + +private fun radioButtonColumnSpec( + localizedStrings: LocalizedStrings, + selectedItem: DownloadableCodeSystem?, + onCheckedChange: (DownloadableCodeSystem) -> Unit, +) = ColumnSpec( + title = localizedStrings.select, + weight = 0.05f, +) { thisCs -> + RadioButton(selected = when (selectedItem) { + null -> false + else -> selectedItem == thisCs + }, onClick = { onCheckedChange.invoke(thisCs) }) +} + +private fun canonicalColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.canonicalUrl, + weight = 0.1f, + instanceGetter = { canonicalUrl }) + +private fun nameColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.name, + weight = 0.1f, + instanceGetter = { name } + ) + +private fun titleColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.title, + weight = 0.1f, + instanceGetter = { title } + ) + +private fun versionColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec(title = localizedStrings.version, weight = 0.1f, content = { + SelectableText(text = it.version) + }, tooltipText = { it.version }) + +private fun metaVersionColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec(title = localizedStrings.metaVersion, weight = 0.05f, content = { + SelectableText(text = it.metaVersion) + }) \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt new file mode 100644 index 0000000..01e975e --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt @@ -0,0 +1,201 @@ +package terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.RadioButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberDialogState +import ca.uhn.fhir.context.FhirContext +import io.ktor.client.* +import io.ktor.http.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.ui.panes.loaddata.panes.fromserver.DownloadableCodeSystem +import terminodiff.ui.panes.loaddata.panes.fromserver.retrieveBundleOfDownloadableResources +import terminodiff.ui.panes.loaddata.panes.fromserver.urlBuilderWithProtocol +import terminodiff.ui.util.ColumnSpec +import terminodiff.ui.util.LazyTable +import terminodiff.ui.util.SelectableText + +private val logger: Logger = LoggerFactory.getLogger("VReadDialog") + +@Composable +fun VReadDialog( + resource: InputResource, + ktorClient: HttpClient, + coroutineScope: CoroutineScope, + fhirContext: FhirContext, + localizedStrings: LocalizedStrings, + onCloseReject: () -> Unit, + onSelectLeft: (InputResource) -> Unit, + onSelectRight: (InputResource) -> Unit, +) { + + val vReadVersions: List? by produceState?>(null, resource) { + withContext(Dispatchers.IO) { + Thread.sleep(1000) + } + val historyUrl = buildHistoryUrl(resource) + val bundle = retrieveBundleOfDownloadableResources(ktorClient, historyUrl, fhirContext) + bundle?.let { + logger.info("Retrieved bundle with ${bundle.size} versions from $historyUrl") + } ?: logger.info("Error retrieving bundle from $historyUrl") + value = bundle?.sortedByDescending { it.metaVersion } + } + + val lazyListState = rememberLazyListState() + var leftSelection: DownloadableCodeSystem? by remember { mutableStateOf(null) } + var rightSelection: DownloadableCodeSystem? by remember { mutableStateOf(null) } + val onCloseAccept: () -> Unit = { + leftSelection?.let { invokeLoadListener(onSelectLeft, it, resource, coroutineScope, ktorClient) } + rightSelection?.let { invokeLoadListener(onSelectRight, it, resource, coroutineScope, ktorClient) } + onCloseReject() + } + Dialog(onCloseRequest = onCloseReject, + rememberDialogState(position = WindowPosition(Alignment.Center), size = DpSize(512.dp, 512.dp)), + title = localizedStrings.vReadFor_.invoke(resource)) { + Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly) { + when { + vReadVersions == null -> { + CircularProgressIndicator(Modifier.fillMaxSize(0.75f).padding(16.dp), + colorScheme.onPrimaryContainer) + } + vReadVersions!!.isEmpty() -> Text(text = localizedStrings.anUnknownErrorOccurred, + color = colorScheme.onPrimaryContainer, + style = typography.titleMedium) + else -> { + VReadTable( + modifier = Modifier.weight(0.9f), + vReadVersions = vReadVersions!!, + lazyListState = lazyListState, + localizedStrings = localizedStrings, + leftSelection = leftSelection, + rightSelection = rightSelection, + onSelectLeft = { leftSelection = it }, + onSelectRight = { rightSelection = it }) + Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly) { + Button( + modifier = Modifier.wrapContentSize(), + onClick = onCloseReject, + colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { + Text(localizedStrings.closeReject, color = colorScheme.onTertiary) + } + Button( + modifier = Modifier.wrapContentSize(), + onClick = onCloseAccept, + colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary), + enabled = listOf(leftSelection, rightSelection).any { it != null }) { + Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) + } + } + + } + } + } + } +} + +fun invokeLoadListener( + onSelect: (InputResource) -> Unit, + downloadableCodeSystem: DownloadableCodeSystem, + resource: InputResource, + coroutineScope: CoroutineScope, + ktorClient: HttpClient, +) { + val physicalUrl = URLBuilder(buildHistoryUrl(resource)).apply { + appendPathSegments(downloadableCodeSystem.metaVersion!!) // ok if this crashes due to metaVersion == null, because that should never happen ;) + }.build() + val inputResource = InputResource( + kind = InputResource.Kind.VREAD, + resourceUrl = physicalUrl.toString(), + downloadableCodeSystem = downloadableCodeSystem, + sourceFhirServerUrl = resource.sourceFhirServerUrl, + ) + coroutineScope.launch { + val downloaded = inputResource.downloadRemoteFile(ktorClient) + onSelect.invoke(downloaded) + } +} + +@Composable +fun VReadTable( + modifier: Modifier, + vReadVersions: List, + lazyListState: LazyListState, + localizedStrings: LocalizedStrings, + leftSelection: DownloadableCodeSystem?, + rightSelection: DownloadableCodeSystem?, + onSelectLeft: (DownloadableCodeSystem) -> Unit, + onSelectRight: (DownloadableCodeSystem) -> Unit, +) { + LazyTable( + modifier = modifier.padding(8.dp), + columnSpecs = columnSpecs(localizedStrings, leftSelection, rightSelection, onSelectLeft, onSelectRight), + backgroundColor = colorScheme.primaryContainer, + lazyListState = lazyListState, + zebraStripingColor = colorScheme.tertiaryContainer, + tableData = vReadVersions, + localizedStrings = localizedStrings, + keyFun = DownloadableCodeSystem::metaVersion, + ) +} + +private fun buildHistoryUrl(resource: InputResource): Url = when { + resource.downloadableCodeSystem == null -> throw UnsupportedOperationException("Can not retrieve VRead for a resource without reference to CS") + resource.sourceFhirServerUrl == null -> throw UnsupportedOperationException("Can not retrieve VRead for a resource without FHIR base") + else -> urlBuilderWithProtocol(resource.sourceFhirServerUrl).apply { + appendPathSegments("CodeSystem", resource.downloadableCodeSystem.id, "_history") + }.build() +} + +private fun columnSpecs( + localizedStrings: LocalizedStrings, + leftSelection: DownloadableCodeSystem?, + rightSelection: DownloadableCodeSystem?, + onSelectLeft: (DownloadableCodeSystem) -> Unit, + onSelectRight: (DownloadableCodeSystem) -> Unit, +) = listOf(metaVersionColumn(localizedStrings), + lastUpdateVersionColumn(localizedStrings), + selectColumn(localizedStrings.loadLeft, leftSelection, onSelectLeft), + selectColumn(localizedStrings.loadRight, rightSelection, onSelectRight)) + +private fun metaVersionColumn(localizedStrings: LocalizedStrings) = + ColumnSpec(title = localizedStrings.metaVersion, weight = 0.1f) { + SelectableText(it.metaVersion) + } + +private fun lastUpdateVersionColumn(localizedStrings: LocalizedStrings) = + ColumnSpec(title = localizedStrings.metaVersion, weight = 0.25f) { + SelectableText(it.lastChange.toString()) + } + +private fun selectColumn( + title: String, + selection: DownloadableCodeSystem?, + onSelect: (DownloadableCodeSystem) -> Unit, +) = ColumnSpec(title = title, weight = 0.1f) { + RadioButton(selected = selection == it, onClick = { onSelect.invoke(it) }) +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffColumns.kt b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffColumns.kt index a136158..96718a2 100644 --- a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffColumns.kt +++ b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffColumns.kt @@ -33,16 +33,22 @@ fun metadataColumnSpecs( leftValueColumnSpec(localizedStrings, diffDataContainer.leftCodeSystem!!), rightValueColumnSpec(localizedStrings, diffDataContainer.rightCodeSystem!!)) -private fun propertyColumnSpec(localizedStrings: LocalizedStrings): ColumnSpec { +private fun propertyColumnSpec(localizedStrings: LocalizedStrings): ColumnSpec.StringSearchableColumnSpec { val defaultStrings = getStrings(SupportedLocale.defaultLocale) + val text: (MetadataComparison) -> String = { it.diffItem.label.invoke(localizedStrings) } val selectableContent: @Composable (MetadataComparison) -> Unit = { comparison -> - SelectableText(comparison.diffItem.label.invoke(localizedStrings), + SelectableText(text.invoke(comparison), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onTertiaryContainer, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) } - return ColumnSpec(title = localizedStrings.property, weight = 0.1f, content = { comparison -> + return ColumnSpec.StringSearchableColumnSpec( + title = localizedStrings.property, + weight = 0.1f, + instanceGetter = { text.invoke(this) }, + tooltipText = text, + ) { comparison: MetadataComparison -> // add the (english) default name to the property column as a tooltip, since FHIR spec is english, // and translations may not always be as clear. val localizedName = comparison.diffItem.label.invoke(localizedStrings) @@ -52,7 +58,7 @@ private fun propertyColumnSpec(localizedStrings: LocalizedStrings): ColumnSpec selectableContent.invoke(comparison) } - }) + } } private fun resultColumnSpec( @@ -136,9 +142,11 @@ private fun countText( private fun leftValueColumnSpec( localizedStrings: LocalizedStrings, leftCodeSystem: CodeSystem, -) = ColumnSpec(title = localizedStrings.leftValue, weight = 0.25f, mergeIf = { comparison -> - comparison.result == MetadataComparisonResult.IDENTICAL -}) { comparison -> +) = ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.leftValue, weight = 0.25f, + instanceGetter = { this.diffItem.getRenderDisplay(leftCodeSystem) }, + mergeIf = { comparison -> + comparison.result == MetadataComparisonResult.IDENTICAL + }) { comparison -> TextForLeftRightValue(comparison, leftCodeSystem, localizedStrings, @@ -148,7 +156,12 @@ private fun leftValueColumnSpec( private fun rightValueColumnSpec( localizedStrings: LocalizedStrings, rightCodeSystem: CodeSystem, -) = ColumnSpec(title = localizedStrings.rightValue, weight = 0.25f) { comparison -> +) = ColumnSpec.StringSearchableColumnSpec( + title = localizedStrings.rightValue, + weight = 0.25f, + mergeIf = null, + instanceGetter = { this.diffItem.getRenderDisplay(rightCodeSystem) } +) { comparison -> TextForLeftRightValue(comparison, rightCodeSystem, localizedStrings, diff --git a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt index 16f3df5..d915340 100644 --- a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt +++ b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt @@ -63,7 +63,7 @@ private fun DrawTable( ) { /** - * internal function to have less parameters in the when block below + * internal function to have fewer parameters in the when block below */ @Composable fun > internalDrawTable( @@ -73,11 +73,12 @@ private fun DrawTable( ) = LazyTable( modifier = Modifier.padding(8.dp), columnSpecs = columnSpecs, - tableData = comparisonResult, backgroundColor = colorScheme.primaryContainer, - zebraStripingColor = colorScheme.tertiaryContainer, lazyListState = listState, - keyFun = keyFun + zebraStripingColor = colorScheme.tertiaryContainer, + tableData = comparisonResult, + localizedStrings = localizedStrings, + keyFun = keyFun, ) when (comparison) { is IdentifierListComparison -> internalDrawTable( diff --git a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffPane.kt b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffPane.kt index 8a93e0c..92cc0c1 100644 --- a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffPane.kt +++ b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffPane.kt @@ -81,15 +81,18 @@ fun MetadataDiffTable( onShowDetailsClick: (MetadataComparison) -> Unit, ) = diffDataContainer.codeSystemDiff?.metadataDifferences?.comparisons?.let { comparisons -> - LazyTable(columnSpecs = metadataColumnSpecs(localizedStrings, - diffColors, - diffDataContainer, - onShowDetailsClick), - lazyListState = lazyListState, - tableData = comparisons, + LazyTable( + columnSpecs = metadataColumnSpecs(localizedStrings, + diffColors, + diffDataContainer, + onShowDetailsClick), backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, + lazyListState = lazyListState, zebraStripingColor = MaterialTheme.colorScheme.primaryContainer, - keyFun = { it.diffItem.label.invoke(localizedStrings) }) + tableData = comparisons, + localizedStrings = localizedStrings, + keyFun = { it.diffItem.label.invoke(localizedStrings) }, + ) } diff --git a/src/main/kotlin/terminodiff/ui/util/LazyTable.kt b/src/main/kotlin/terminodiff/ui/util/LazyTable.kt index ce41e84..6c3d303 100644 --- a/src/main/kotlin/terminodiff/ui/util/LazyTable.kt +++ b/src/main/kotlin/terminodiff/ui/util/LazyTable.kt @@ -6,22 +6,30 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider import androidx.compose.material.Text -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backspace +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import me.xdrop.fuzzywuzzy.FuzzySearch +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.ui.panes.loaddata.panes.LabeledTextField import terminodiff.ui.MouseOverPopup +import java.util.* @Composable fun LazyTable( @@ -34,18 +42,38 @@ fun LazyTable( lazyListState: LazyListState, zebraStripingColor: Color? = backgroundColor.copy(0.5f), tableData: List, + localizedStrings: LocalizedStrings, keyFun: (T) -> String?, -) = Column(modifier = modifier.fillMaxWidth()) { - // draw the header cells - Row(Modifier.fillMaxWidth()) { - columnSpecs.forEach { HeaderCell(it, cellBorderColor, foregroundColor) } +) = Column(modifier = modifier.fillMaxWidth().padding(4.dp)) { + val searchState by remember { mutableStateOf(SearchState(columnSpecs, tableData)) } + var showFilterDialogFor: String? by remember { mutableStateOf(null) } + + if (showFilterDialogFor != null) { + ShowFilterDialog(title = showFilterDialogFor!!, + searchState = searchState, + localizedStrings = localizedStrings) { + showFilterDialogFor = null + } + } + + // draw the header row + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + columnSpecs.forEach { columnSpec -> + HeaderCell(columnSpec = columnSpec, + cellBorderColor = cellBorderColor, + contentColor = foregroundColor, + localizedStrings = localizedStrings, + searchState = searchState, + onSearchClick = { showFilterDialogFor = it }, + onSearchClearClick = searchState::clearSearchFor) + } } Divider(color = cellBorderColor, thickness = 1.dp) - // the actual cells, contained by LazyColumn +// the actual cells, contained by LazyColumn Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { LazyColumn(state = lazyListState) { - itemsIndexed(items = tableData, key = { index, _ -> + itemsIndexed(items = searchState.filteredData, key = { index, _ -> "$keyFun-$index" }) { index, data -> val rowBackground = when (zebraStripingColor) { @@ -76,25 +104,62 @@ fun LazyTable( } } } + // the indicator for scrolling Carousel(state = lazyListState, colors = CarouselDefaults.colors(cellBorderColor), modifier = Modifier.padding(8.dp).width(8.dp).fillMaxHeight(0.9f)) } } + +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun RowScope.HeaderCell( - spec: ColumnSpec<*>, +fun RowScope.HeaderCell( + columnSpec: ColumnSpec, cellBorderColor: Color, contentColor: Color, + localizedStrings: LocalizedStrings, + searchState: SearchState, + onSearchClick: (String) -> Unit, + onSearchClearClick: (String) -> Unit, ) { - Box(Modifier.border(1.dp, cellBorderColor).weight(spec.weight).padding(2.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Text(text = spec.title, + Box(Modifier.border(1.dp, cellBorderColor).weight(columnSpec.weight).fillMaxHeight().padding(2.dp)) { + Row(modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + val columnName = columnSpec.title + Text(text = columnName, style = MaterialTheme.typography.bodyLarge, color = contentColor, fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Italic) + fontStyle = FontStyle.Italic, + textAlign = TextAlign.Center) + if (columnSpec.searchPredicate != null) { + CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) { + val searchMouseover = when (searchState.isFilteringFor(columnName)) { + true -> searchState.getSearchQueryFor(columnName) + else -> "\"${localizedStrings.search}\"" + } + MouseOverPopup(searchMouseover) { + IconButton(modifier = Modifier.size(32.dp).padding(4.dp), + onClick = { onSearchClick(columnName) }) { + Icon(Icons.Default.Search, + contentDescription = localizedStrings.search, + tint = contentColor) + } + } + if (searchState.isFilteringFor(columnName)) { + MouseOverPopup(text = localizedStrings.clearSearch) { + IconButton(modifier = Modifier.size(32.dp).padding(4.dp), + onClick = { onSearchClearClick(columnName) }) { + Icon(Icons.Default.Backspace, + contentDescription = localizedStrings.clearSearch, + tint = contentColor) + } + } + } + } + } } } } @@ -103,7 +168,7 @@ fun RowScope.HeaderCell( fun RowScope.TableCell( modifier: Modifier = Modifier, weight: Float, - tooltipText: String?,//(() -> String?)? = null, + tooltipText: String?, backgroundColor: Color, foregroundColor: Color, content: @Composable () -> Unit, @@ -122,12 +187,132 @@ fun RowScope.TableCell( } } -data class ColumnSpec( +open class ColumnSpec( val title: String, val weight: Float, + val searchPredicate: ((T, String) -> Boolean)? = null, val tooltipText: ((T) -> String?)? = null, val mergeIf: ((T) -> Boolean)? = null, val content: @Composable (T) -> Unit, ) { companion object + + class StringSearchableColumnSpec( + title: String, + weight: Float, + instanceGetter: T.() -> String?, + mergeIf: ((T) -> Boolean)? = null, + tooltipText: ((T) -> String?)? = null, + content: @Composable (T) -> Unit, + ) : ColumnSpec(title = title, weight = weight, searchPredicate = { value, search -> + when (val instanceValue = instanceGetter.invoke(value)?.lowercase(Locale.getDefault())) { + null -> false + else -> { + val fuzzyScore = FuzzySearch.partialRatio(instanceValue, search.lowercase(Locale.getDefault())) + fuzzyScore >= 75 + } + } + }, mergeIf = mergeIf, tooltipText = tooltipText, content = content) { + /** + * constructor overload that takes care of drawing the content by providing a tooltip and content as selectable text, with default styling + */ + constructor( + title: String, + weight: Float, + instanceGetter: T.() -> String?, + mergeIf: ((T) -> Boolean)? = null, + ) : this( + title = title, + weight = weight, + instanceGetter = instanceGetter, + mergeIf = mergeIf, + tooltipText = { it.instanceGetter() }, + content = { + SelectableText(text = it.instanceGetter()) + }, + ) + } +} + +class SearchState( + private val columnSpecs: List>, + private val tableData: List, +) { + private val searchableColumns: List> by derivedStateOf { + columnSpecs.filter { it.searchPredicate != null } + } + private val searchableColumnTitles: List by derivedStateOf { + searchableColumns.map { it.title } + } + + private val searchQueries = mutableStateMapOf().apply { + searchableColumnTitles.forEach { this[it] = null } + } + + private val predicates: Map Boolean> by derivedStateOf { + searchableColumns.associate { it.title to it.searchPredicate!! } + } + + fun isFilteringFor(columnTitle: String) = searchQueries.entries.firstOrNull() { it.key == columnTitle }?.let { + it.value != null + } ?: false + + private fun filterData(tableData: List) = when (searchQueries.any { it.value != null }) { + false -> tableData + else -> tableData.filter { data -> + val presentFilters = searchQueries.filterValues { it != null } + val presentPredicates = presentFilters.entries.associate { entry -> + val predicate = predicates[entry.key]!! + entry.key to { predicate.invoke(data, entry.value!!) } + } + presentPredicates.all { it.value() } + } + } + + val filteredData by derivedStateOf { filterData(tableData) } + + fun clearSearchFor(columnName: String) { + searchQueries[columnName] = null + } + + fun getSearchQueryFor(columnName: String) = searchQueries[columnName] ?: "" + + fun setSearchQueryFor(columnName: String, newValue: String) { + searchQueries[columnName] = newValue + } + +} + +@Composable +fun ShowFilterDialog( + title: String, + searchState: SearchState, + localizedStrings: LocalizedStrings, + onClose: () -> Unit, +) { + var inputText: String by remember { mutableStateOf(searchState.getSearchQueryFor(title)) } + Dialog(onCloseRequest = onClose, title = localizedStrings.search) { + Column(modifier = Modifier.fillMaxSize().background(colorScheme.primaryContainer), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally) { + LabeledTextField(value = inputText, + onValueChange = { inputText = it }, + labelText = title, + singleLine = true) + Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly) { + Button(modifier = Modifier.wrapContentSize(), + onClick = onClose, + colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { + Text(localizedStrings.closeReject, color = colorScheme.onTertiary) + } + Button(modifier = Modifier.wrapContentSize(), onClick = { + searchState.setSearchQueryFor(title, inputText) + onClose() + }, colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary)) { + Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) + } + } + } + } } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/util/SelectableText.kt b/src/main/kotlin/terminodiff/ui/util/SelectableText.kt index 47f6e2c..439e476 100644 --- a/src/main/kotlin/terminodiff/ui/util/SelectableText.kt +++ b/src/main/kotlin/terminodiff/ui/util/SelectableText.kt @@ -28,19 +28,31 @@ fun SelectableText( overflow: TextOverflow = TextOverflow.Clip, ) { SelectionContainer { - Text( - text = text ?: "null", - modifier = modifier, - fontStyle = fontStyle, - color = color, - fontWeight = fontWeight, - style = style, - textAlign = textAlign, - overflow = overflow, - ) + NullableText(text, fontStyle, fontWeight, modifier, color, style, textAlign, overflow) } } +@Composable +fun NullableText( + text: String?, + fontStyle: FontStyle? = if (text == null) FontStyle.Italic else FontStyle.Normal, + fontWeight: FontWeight? = null, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + style: TextStyle = LocalTextStyle.current, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, +) = Text( + text = text ?: "null", + modifier = modifier, + fontStyle = fontStyle, + color = color, + fontWeight = fontWeight, + style = style, + textAlign = textAlign, + overflow = overflow, +) + @Composable fun textForValue(