diff --git a/._.DS_Store b/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/._.DS_Store differ diff --git a/._inou.db b/._inou.db new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._inou.db differ diff --git a/._start.sh b/._start.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._start.sh differ diff --git a/._status.sh b/._status.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._status.sh differ diff --git a/._stop.sh b/._stop.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._stop.sh differ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..29a3a50 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..d044da8 --- /dev/null +++ b/app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: android + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: ios + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: linux + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: macos + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: web + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: windows + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/FLUTTER_TASK.md b/app/FLUTTER_TASK.md new file mode 100644 index 0000000..1b92881 --- /dev/null +++ b/app/FLUTTER_TASK.md @@ -0,0 +1,105 @@ +# Flutter Landing Page + i18n Task + +## Goal +Match Go version landing page, add i18n (EN, NL, RU first) + +## Visual Changes Needed + +### Layout (match Go version) +- [ ] Wrap sections in `.landing-card` style (white bg, border, rounded, padding 48px) +- [ ] Hero: centered text, not left-aligned +- [ ] Hero text: "inou organizes and shares your health dossier with your AI — securely and privately." +- [ ] Hero tagline: "Your health, understood." +- [ ] CTA button: "Sign in" (or "Invite a friend" if logged in) + +### Content Sections (3 cards in Go) +1. **Hero card** - tagline + CTA +2. **"You need AI for your health"** - warm prose style +3. **"The challenge"** - Data/Reality pairs format: + - "Your MRI has 4,000 slices." / "It was read in 10 minutes." + - "Your genome has millions of variants." / "All you learned was your eye color..." + - etc. +4. **"Why we built this"** - prose paragraphs +5. **Trust section** - 4-column grid: + - Never used for training + - Never shared + - Military-grade encryption + - Delete anytime + +### CSS Values (from Go) +``` +--bg: #F8F7F6 +--bg-card: #FFFFFF +--border: #E5E2DE +--text: #1C1917 +--text-muted: #78716C +--accent: #B45309 +--accent-hover: #92400E + +.landing-card { + padding: 48px; + border-radius: 8px; + border: 1px solid var(--border); + margin-bottom: 24px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px; +} +``` + +## i18n Setup + +### Packages +```yaml +dependencies: + flutter_localizations: + sdk: flutter + intl: ^0.18.0 +``` + +### Structure +``` +lib/ +├── l10n/ +│ ├── app_en.arb # English +│ ├── app_nl.arb # Dutch +│ └── app_ru.arb # Russian +└── main.dart # Add localization delegates +``` + +### Key Strings to Port (landing only) +From `lang/en.yaml`: +- data_yours: "Your data stays yours" +- never_training: "Never used for training" +- never_training_desc: "Your images are never used to train AI models." +- never_shared: "Never shared" +- never_shared_desc: "We never share your data with anyone." +- encrypted: "Military-grade encryption" +- encrypted_desc: "At rest and in transit..." +- delete: "Delete anytime" +- delete_desc: "Your data, your control." +- get_started: "Get started" + +### Language Switcher +- Add to InouHeader +- Dropdown with: English, Nederlands, Русский +- Store preference (SharedPreferences) + +## Source Files +- Go templates: `~/dev/inou/templates/` +- Go CSS: `~/dev/inou/static/style.css` +- Go translations: `~/dev/inou/lang/*.yaml` + +## Translation Strategy +- Port EN strings first (manual) +- Use cheap model to translate EN → NL, RU +- Human review later + +## Priority +1. Match visual layout first +2. Add i18n scaffolding +3. Port EN strings +4. Generate NL, RU translations diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..3f559e1 --- /dev/null +++ b/app/README.md @@ -0,0 +1,16 @@ +# inou_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle new file mode 100644 index 0000000..33524aa --- /dev/null +++ b/app/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.inou.inou_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.inou.inou_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f44f6b8 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt new file mode 100644 index 0000000..1ef32de --- /dev/null +++ b/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.inou.inou_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle b/app/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/app/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7bb2df6 --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/app/android/settings.gradle b/app/android/settings.gradle new file mode 100644 index 0000000..b9e43bd --- /dev/null +++ b/app/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/app/fonts/Sora-Bold.ttf b/app/fonts/Sora-Bold.ttf new file mode 100644 index 0000000..ef4a017 Binary files /dev/null and b/app/fonts/Sora-Bold.ttf differ diff --git a/app/fonts/Sora-ExtraBold.ttf b/app/fonts/Sora-ExtraBold.ttf new file mode 100644 index 0000000..d9c724c Binary files /dev/null and b/app/fonts/Sora-ExtraBold.ttf differ diff --git a/app/fonts/Sora-ExtraLight.ttf b/app/fonts/Sora-ExtraLight.ttf new file mode 100644 index 0000000..cbc5ba7 Binary files /dev/null and b/app/fonts/Sora-ExtraLight.ttf differ diff --git a/app/fonts/Sora-Light.ttf b/app/fonts/Sora-Light.ttf new file mode 100644 index 0000000..c64d462 Binary files /dev/null and b/app/fonts/Sora-Light.ttf differ diff --git a/app/fonts/Sora-Regular.ttf b/app/fonts/Sora-Regular.ttf new file mode 100644 index 0000000..cc0103a Binary files /dev/null and b/app/fonts/Sora-Regular.ttf differ diff --git a/app/fonts/Sora-SemiBold.ttf b/app/fonts/Sora-SemiBold.ttf new file mode 100644 index 0000000..66820ae Binary files /dev/null and b/app/fonts/Sora-SemiBold.ttf differ diff --git a/app/fonts/Sora-Thin.ttf b/app/fonts/Sora-Thin.ttf new file mode 100644 index 0000000..1d59088 Binary files /dev/null and b/app/fonts/Sora-Thin.ttf differ diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..83a496f --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..ebeee69 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Inou App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + inou_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/l10n.yaml b/app/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/app/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/app/lib/core/locale_provider.dart b/app/lib/core/locale_provider.dart new file mode 100644 index 0000000..47394b4 --- /dev/null +++ b/app/lib/core/locale_provider.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Manages app locale with persistence +class LocaleProvider extends ChangeNotifier { + static const String _localeKey = 'app_locale'; + + Locale _locale = const Locale('en'); + + Locale get locale => _locale; + + /// Supported locales + static const List supportedLocales = [ + Locale('en'), // English + Locale('nl'), // Dutch + Locale('ru'), // Russian + ]; + + /// Locale display names + static const Map localeNames = { + 'en': 'English', + 'nl': 'Nederlands', + 'ru': 'Русский', + }; + + /// Short codes for display in header + static const Map localeCodes = { + 'en': 'EN', + 'nl': 'NL', + 'ru': 'RU', + }; + + LocaleProvider() { + _loadLocale(); + } + + /// Load saved locale from preferences + Future _loadLocale() async { + try { + final prefs = await SharedPreferences.getInstance(); + final localeCode = prefs.getString(_localeKey); + if (localeCode != null && supportedLocales.any((l) => l.languageCode == localeCode)) { + _locale = Locale(localeCode); + notifyListeners(); + } + } catch (e) { + // Fallback to default if loading fails + debugPrint('Failed to load locale: $e'); + } + } + + /// Set and persist locale + Future setLocale(Locale locale) async { + if (!supportedLocales.contains(locale)) return; + + _locale = locale; + notifyListeners(); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_localeKey, locale.languageCode); + } catch (e) { + debugPrint('Failed to save locale: $e'); + } + } + + /// Get current locale name for display + String get currentLocaleName => localeNames[_locale.languageCode] ?? 'English'; + + /// Get current locale code for header display + String get currentLocaleCode => localeCodes[_locale.languageCode] ?? 'EN'; +} diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart new file mode 100644 index 0000000..b167c7e --- /dev/null +++ b/app/lib/core/router.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +// Feature screens +import 'package:inou_app/features/static/landing_page.dart'; +import 'package:inou_app/features/static/security_page.dart'; +import 'package:inou_app/features/static/privacy_page.dart'; +import 'package:inou_app/features/static/faq_page.dart'; +import 'package:inou_app/features/static/dpa_page.dart'; +import 'package:inou_app/features/static/connect_page.dart'; +import 'package:inou_app/features/static/invite_page.dart'; +import 'package:inou_app/features/auth/login_page.dart'; +import 'package:inou_app/features/auth/signup_page.dart'; +import 'package:inou_app/features/dashboard/dashboard.dart'; +import 'package:inou_app/design/screens/styleguide_screen.dart'; + +/// App route definitions +class AppRoutes { + // Static pages + static const String landing = '/'; + static const String security = '/security'; + static const String privacy = '/privacy'; + static const String faq = '/faq'; + static const String dpa = '/dpa'; + static const String connect = '/connect'; + static const String invite = '/invite'; + + // Auth pages + static const String login = '/login'; + static const String signup = '/signup'; + static const String forgotPassword = '/forgot-password'; + + // Authenticated pages (deep pages) + static const String dashboard = '/dashboard'; + static const String dossier = '/dossier/:id'; + static const String profile = '/profile'; + + // Dev + static const String styleguide = '/styleguide'; +} + +/// GoRouter configuration +final GoRouter appRouter = GoRouter( + initialLocation: '/', + routes: [ + // Static pages + GoRoute( + path: '/', + builder: (context, state) => const LandingPage(), + ), + GoRoute( + path: '/security', + builder: (context, state) => const SecurityPage(), + ), + GoRoute( + path: '/privacy', + builder: (context, state) => const PrivacyPage(), + ), + GoRoute( + path: '/faq', + builder: (context, state) => const FaqPage(), + ), + GoRoute( + path: '/dpa', + builder: (context, state) => const DpaPage(), + ), + GoRoute( + path: '/connect', + builder: (context, state) => const ConnectPage(), + ), + GoRoute( + path: '/invite', + builder: (context, state) => const InvitePage(), + ), + + // Auth pages + GoRoute( + path: '/login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/signup', + builder: (context, state) => const SignupPage(), + ), + + // Authenticated pages + GoRoute( + path: '/dashboard', + builder: (context, state) => const DashboardPage(), + ), + GoRoute( + path: '/dossier/:id', + builder: (context, state) { + final dossierId = state.pathParameters['id']!; + return DossierPage(dossierId: dossierId); + }, + ), + + // Dev + GoRoute( + path: '/styleguide', + builder: (context, state) => const StyleguideScreen(), + ), + ], + errorBuilder: (context, state) => const LandingPage(), +); + +/// Legacy route generator for MaterialApp (deprecated, use GoRouter) +@Deprecated('Use appRouter with GoRouter instead') +Route? generateRoute(RouteSettings settings) { + // Keep for backwards compatibility if needed + return MaterialPageRoute( + builder: (_) => const LandingPage(), + settings: settings, + ); +} diff --git a/app/lib/design/inou_text.dart b/app/lib/design/inou_text.dart new file mode 100644 index 0000000..3ed6d6e --- /dev/null +++ b/app/lib/design/inou_text.dart @@ -0,0 +1,496 @@ +/// inou Text Styles & Typography Widgets +/// +/// RULES: +/// - Pages MUST use InouText.* styles or widgets +/// - NO raw TextStyle() in page code +/// - NO fontSize: or fontWeight: in page code +/// +/// Usage: +/// Text('Hello', style: InouText.pageTitle) +/// InouText.pageTitle('Hello') +/// InouText.body('Paragraph', color: InouTheme.textMuted) + +import 'package:flutter/material.dart'; +import 'inou_theme.dart'; + +/// Typography system for inou +/// +/// Base: 16px (1rem), Sora font, line-height 1.5 +class InouText { + InouText._(); + + // =========================================== + // FONT FAMILY + // =========================================== + static const String fontFamily = 'Sora'; + + // =========================================== + // TEXT STYLES + // =========================================== + + /// Page title: 2.5rem (40px), weight 800 (ExtraBold) + /// Use for: Main page headings like "Style Guide", "Privacy Policy" + static const TextStyle pageTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 40.0, // 2.5 * 16 + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.2, + color: InouTheme.text, + ); + + /// Hero title: 2.25rem (36px), weight 300 (Light) + /// Use for: Large hero text like "Your data. Your rules." + static const TextStyle heroTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 36.0, // 2.25 * 16 + fontWeight: FontWeight.w300, + height: 1.2, + letterSpacing: -1.08, // -0.03em + color: InouTheme.text, + ); + + /// Section title: 1.4rem (22.4px), weight 600 (SemiBold) + /// Use for: Section headings like "What we collect" + static const TextStyle sectionTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 22.4, // 1.4 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// Subsection title: 1.1rem (17.6px), weight 600 (SemiBold) + /// Use for: Subsection headings like "Account information" + static const TextStyle subsectionTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 17.6, // 1.1 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// H3: 1.125rem (18px), weight 500 (Medium) + /// Use for: Tertiary headings + static const TextStyle h3 = TextStyle( + fontFamily: fontFamily, + fontSize: 18.0, // 1.125 * 16 + fontWeight: FontWeight.w500, + color: InouTheme.text, + ); + + /// Intro text: 1.15rem (18.4px), weight 300 (Light) + /// Use for: Introduction paragraphs, larger body text + static const TextStyle intro = TextStyle( + fontFamily: fontFamily, + fontSize: 18.4, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); + + /// Body light: 1rem (16px), weight 300 (Light) + /// Use for: Long-form content, articles, descriptions + static const TextStyle bodyLight = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w300, + height: 1.5, + color: InouTheme.text, + ); + + /// Body regular: 1rem (16px), weight 400 (Regular) + /// Use for: UI labels, default text, buttons + static const TextStyle body = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + height: 1.5, + color: InouTheme.text, + ); + + /// Body small: 0.85rem (13.6px), weight 400 (Regular) + /// Use for: Secondary text, captions, helper text + static const TextStyle bodySmall = TextStyle( + fontFamily: fontFamily, + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Label: 1rem (16px), weight 500 (Medium) + /// Use for: Form labels, button text + static const TextStyle label = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: InouTheme.text, + ); + + /// Label caps: 0.75rem (12px), weight 600, uppercase + /// Use for: Category labels like "TEXT BLOCKS", "TYPOGRAPHY SCALE" + static const TextStyle labelCaps = TextStyle( + fontFamily: fontFamily, + fontSize: 12.0, // 0.75 * 16 + fontWeight: FontWeight.w600, + letterSpacing: 1.2, // 0.1em + color: InouTheme.textSubtle, + ); + + /// Logo: 1.75rem (28px), weight 700 (Bold) + /// Use for: "inou" in header + static const TextStyle logo = TextStyle( + fontFamily: fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w700, + letterSpacing: -0.56, // -0.02em + ); + + /// Logo light: 1.75rem (28px), weight 300 (Light) + /// Use for: "health" in header + static const TextStyle logoLight = TextStyle( + fontFamily: fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w300, + letterSpacing: -0.56, // -0.02em + ); + + /// Logo tagline: 0.95rem (15.2px), weight 300 (Light) + /// Use for: "ai answers for you" tagline + static const TextStyle logoTagline = TextStyle( + fontFamily: fontFamily, + fontSize: 15.2, // 0.95 * 16 + fontWeight: FontWeight.w300, + letterSpacing: 0.608, // 0.04em + color: InouTheme.textMuted, + ); + + /// Nav item: 1rem (16px), weight 400 (Regular) + /// Use for: Navigation menu items + static const TextStyle nav = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Nav item active: 1rem (16px), weight 600 (SemiBold) + /// Use for: Active navigation menu items + static const TextStyle navActive = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w600, + color: InouTheme.accent, + ); + + /// Mono: SF Mono, 0.85rem (13.6px) + /// Use for: Code, technical data, IDs + static const TextStyle mono = TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Profile name: 1.25rem (20px), weight 600 (SemiBold) + /// Use for: User names in profile cards + static const TextStyle profileName = TextStyle( + fontFamily: fontFamily, + fontSize: 20.0, // 1.25 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// Badge: 1rem (16px), weight 500 (Medium) + /// Use for: Badge/pill text + static const TextStyle badge = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + + /// Button: 1rem (16px), weight 500 (Medium) + /// Use for: Button text + static const TextStyle button = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + + /// Input: 1rem (16px), weight 400 (Regular) + /// Use for: Text input fields + static const TextStyle input = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Input placeholder: 1rem (16px), weight 400 (Regular) + /// Use for: Placeholder text in inputs + static const TextStyle inputPlaceholder = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.textSubtle, + ); + + /// Error text: 0.85rem (13.6px), weight 400 (Regular) + /// Use for: Form validation errors + static const TextStyle error = TextStyle( + fontFamily: fontFamily, + fontSize: 13.6, + fontWeight: FontWeight.w400, + color: InouTheme.danger, + ); + + /// Link: inherits size, weight 400, accent color + /// Use for: Inline links + static TextStyle link({double? fontSize}) => TextStyle( + fontFamily: fontFamily, + fontSize: fontSize ?? 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.accent, + decoration: TextDecoration.underline, + decorationColor: InouTheme.accent, + ); + + // =========================================== + // CONVENIENCE WIDGETS + // =========================================== + + /// Page title widget + static Widget pageTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: pageTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Hero title widget + static Widget heroTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: heroTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Section title widget + static Widget sectionTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: sectionTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Subsection title widget + static Widget subsectionTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: subsectionTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Body text widget + static Widget bodyText( + String text, { + Color? color, + TextAlign? textAlign, + int? maxLines, + TextOverflow? overflow, + }) { + return Text( + text, + style: body.copyWith(color: color), + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + ); + } + + /// Body light text widget (for long-form) + static Widget bodyLightText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: bodyLight.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Intro text widget + static Widget introText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: intro.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Label caps widget (auto-uppercases) + static Widget labelCapsText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text.toUpperCase(), + style: labelCaps.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Mono text widget + static Widget monoText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: mono.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Small text widget + static Widget smallText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: bodySmall.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Error text widget + static Widget errorText( + String text, { + TextAlign? textAlign, + }) { + return Text( + text, + style: error, + textAlign: textAlign, + ); + } + + // =========================================== + // RICH TEXT HELPERS + // =========================================== + + /// Build rich text with multiple styled spans + static Widget rich( + List children, { + TextAlign? textAlign, + }) { + return Text.rich( + TextSpan(children: children), + textAlign: textAlign, + ); + } +} + +/// Styled text spans for use with InouText.rich() +class InouSpan { + InouSpan._(); + + /// Accent colored text + static TextSpan accent(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.accent), + ); + } + + /// Muted colored text + static TextSpan muted(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.textMuted), + ); + } + + /// Subtle colored text + static TextSpan subtle(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.textSubtle), + ); + } + + /// Bold text + static TextSpan bold(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w700), + ); + } + + /// SemiBold text + static TextSpan semiBold(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w600), + ); + } + + /// Light text + static TextSpan light(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w300), + ); + } + + /// Plain text (default style) + static TextSpan plain(String text, {TextStyle? style}) { + return TextSpan(text: text, style: style); + } + + /// Link text + static TextSpan link( + String text, { + VoidCallback? onTap, + TextStyle? baseStyle, + }) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith( + color: InouTheme.accent, + decoration: TextDecoration.underline, + decorationColor: InouTheme.accent, + ), + // Note: For tap handling, wrap in GestureDetector or use url_launcher + ); + } +} diff --git a/app/lib/design/inou_theme.dart b/app/lib/design/inou_theme.dart new file mode 100644 index 0000000..04a5d71 --- /dev/null +++ b/app/lib/design/inou_theme.dart @@ -0,0 +1,317 @@ +/// inou Design System +/// Source: https://inou.com/static/style.css +/// Base: 16px, Sora font, line-height 1.5 +/// +/// Using LOCAL Sora font (fonts/Sora-*.ttf), not google_fonts package. + +import 'package:flutter/material.dart'; + +class InouTheme { + InouTheme._(); + + // Font family - local asset + static const String _fontFamily = 'Sora'; + + // =========================================== + // COLORS (from :root CSS variables) + // =========================================== + static const Color bg = Color(0xFFF8F7F6); // --bg + static const Color bgCard = Color(0xFFFFFFFF); // --bg-card + static const Color border = Color(0xFFE5E2DE); // --border + static const Color borderHover = Color(0xFFC4BFB8); // --border-hover + static const Color text = Color(0xFF1C1917); // --text + static const Color textMuted = Color(0xFF78716C); // --text-muted + static const Color textSubtle = Color(0xFFA8A29E); // --text-subtle + static const Color accent = Color(0xFFB45309); // --accent + static const Color accentHover = Color(0xFF92400E); // --accent-hover + static const Color accentLight = Color(0xFFFEF3C7); // --accent-light + static const Color danger = Color(0xFFDC2626); // --danger + static const Color dangerLight = Color(0xFFFEF2F2); // --danger-light + static const Color success = Color(0xFF059669); // --success + static const Color successLight = Color(0xFFECFDF5); // --success-light + + // Indicator colors (data sections) + static const Color indicatorImaging = Color(0xFFB45309); + static const Color indicatorLabs = Color(0xFF059669); + static const Color indicatorUploads = Color(0xFF6366F1); + static const Color indicatorVitals = Color(0xFFEC4899); + static const Color indicatorMedications = Color(0xFF8B5CF6); + static const Color indicatorRecords = Color(0xFF06B6D4); + static const Color indicatorJournal = Color(0xFFF59E0B); + static const Color indicatorPrivacy = Color(0xFF64748B); + static const Color indicatorGenetics = Color(0xFF10B981); + + // Message border colors + static const Color errorBorder = Color(0xFFFECACA); // #FECACA + static const Color infoBorder = Color(0xFFFDE68A); // #FDE68A + static const Color successBorder = Color(0xFFA7F3D0); // #A7F3D0 + + // =========================================== + // SPACING + // =========================================== + static const double spaceXs = 4.0; + static const double spaceSm = 8.0; + static const double spaceMd = 12.0; + static const double spaceLg = 16.0; + static const double spaceXl = 24.0; + static const double spaceXxl = 32.0; + static const double spaceXxxl = 48.0; + + // =========================================== + // BORDER RADIUS + // =========================================== + static const double radiusSm = 4.0; + static const double radiusMd = 6.0; + static const double radiusLg = 8.0; + static const double radiusXl = 12.0; + static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm); + static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd); + static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg); + + // =========================================== + // LAYOUT + // =========================================== + static const double maxWidth = 1200.0; + static const double maxWidthNarrow = 800.0; + static const double maxWidthForm = 360.0; + + // =========================================== + // TYPOGRAPHY + // CSS base: body { font-size: 16px; line-height: 1.5; font-weight: 400; } + // All rem values calculated as: rem * 16 + // =========================================== + + // h1: 2.25rem (36px), weight 300, line-height 1.2, letter-spacing -0.03em + static TextStyle get h1 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 36.0, // 2.25 * 16 + fontWeight: FontWeight.w300, + height: 1.2, + letterSpacing: -0.03 * 36.0, // -0.03em + color: text, + ); + + // Page title (styleguide): 2.5rem (40px), weight 700 + static TextStyle get pageTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 40.0, // 2.5 * 16 + fontWeight: FontWeight.w800, // ExtraBold + letterSpacing: -0.5, // Tighter tracking to match CSS + height: 1.2, // Line height to match CSS defaults + color: text, + ); + + // h2: 1.5rem (24px), weight 300, letter-spacing -0.02em + static TextStyle get h2 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 24.0, // 1.5 * 16 + fontWeight: FontWeight.w300, + letterSpacing: -0.02 * 24.0, // -0.02em + color: text, + ); + + // Section title (styleguide): 1.4rem (22.4px), weight 600 + static TextStyle get sectionTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 22.4, // 1.4 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // h3: 1.125rem (18px), weight 500 + static TextStyle get h3 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 18.0, // 1.125 * 16 + fontWeight: FontWeight.w500, + color: text, + ); + + // Subsection title (styleguide): 1.1rem (17.6px), weight 600 + static TextStyle get subsectionTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 17.6, // 1.1 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // Intro text: 1.15rem (18.4px), weight 300 + static TextStyle get intro => const TextStyle( + fontFamily: _fontFamily, + fontSize: 18.4, // 1.15 * 16 + fontWeight: FontWeight.w300, + height: 1.8, + color: textMuted, + ); + + // Body light (long-form): 1rem (16px), weight 300 + static TextStyle get bodyLight => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w300, + height: 1.5, + color: text, + ); + + // Body regular (UI labels): 1rem (16px), weight 400 + static TextStyle get body => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + height: 1.5, + color: text, + ); + + // Small text: 0.85rem (13.6px), weight 400 + static TextStyle get bodySmall => const TextStyle( + fontFamily: _fontFamily, + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: text, + ); + + // Label/Category: 0.75rem (12px), weight 600, uppercase, letter-spacing 0.1em + static TextStyle get labelCaps => const TextStyle( + fontFamily: _fontFamily, + fontSize: 12.0, // 0.75 * 16 + fontWeight: FontWeight.w600, + letterSpacing: 0.1 * 12.0, // 0.1em + color: textSubtle, + ); + + // Button/label: 1rem (16px), weight 500 + static TextStyle get label => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: text, + ); + static TextStyle get labelLarge => label; // alias + + // Badge text: 1rem (16px), weight 500 + static TextStyle get badge => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + static TextStyle get badgeText => badge; // alias + + // Logo: 1.75rem (28px), weight 700, letter-spacing -0.02em + static TextStyle get logo => const TextStyle( + fontFamily: _fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w700, + letterSpacing: -0.02 * 28.0, // -0.02em + ); + + // Logo tagline: 0.95rem (15.2px), weight 300, letter-spacing 0.04em + static TextStyle get logoTagline => const TextStyle( + fontFamily: _fontFamily, + fontSize: 15.2, // 0.95 * 16 + fontWeight: FontWeight.w300, + letterSpacing: 0.04 * 15.2, // 0.04em + color: textMuted, + ); + + // Mono: SF Mono, 0.85rem (13.6px) + static TextStyle get mono => const TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: text, + ); + + // Profile card h3: 1.25rem (20px) + static TextStyle get profileName => const TextStyle( + fontFamily: _fontFamily, + fontSize: 20.0, // 1.25 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // =========================================== + // THEME DATA + // =========================================== + static ThemeData get light => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + fontFamily: _fontFamily, + scaffoldBackgroundColor: bg, + colorScheme: ColorScheme.light( + primary: accent, + onPrimary: Colors.white, + secondary: accentLight, + onSecondary: accent, + surface: bgCard, + onSurface: text, + error: danger, + onError: Colors.white, + outline: border, + ), + textTheme: TextTheme( + displayLarge: pageTitle, + displayMedium: h1, + headlineMedium: sectionTitle, + headlineSmall: subsectionTitle, + bodyLarge: body, + bodyMedium: body, + bodySmall: bodySmall, + labelLarge: label, + labelSmall: labelCaps, + ), + appBarTheme: AppBarTheme( + backgroundColor: bg, + foregroundColor: text, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardTheme( + color: bgCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadiusLg, + side: BorderSide(color: border), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: label, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + side: BorderSide(color: border), + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: label, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: bgCard, + border: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 1), + ), + contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd), + ), + dividerTheme: DividerThemeData( + color: border, + thickness: 1, + ), + ); +} diff --git a/app/lib/design/screens/styleguide_screen.dart b/app/lib/design/screens/styleguide_screen.dart new file mode 100644 index 0000000..a7fc424 --- /dev/null +++ b/app/lib/design/screens/styleguide_screen.dart @@ -0,0 +1,1189 @@ +// Flutter Styleguide — matches inou.com/styleguide +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +class StyleguideScreen extends StatefulWidget { + const StyleguideScreen({super.key}); + + @override + State createState() => _StyleguideScreenState(); +} + +class _StyleguideScreenState extends State { + String? _selectedOption = 'Option 1'; + String _selectedSex = 'male'; + bool _checkboxValue = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: Column( + children: [ + // Header component + const InouHeader(), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Page title + Text('Style Guide', style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + 'Design system components for inou', + style: InouText.bodyLight.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 32), + + // Header Component Preview + _buildHeaderSection(), + + // Text Blocks + _buildTextBlocksSection(), + + // Typography + _buildTypographySection(), + + // Colors + _buildColorsSection(), + + // Buttons + _buildButtonsSection(), + + // Badges + _buildBadgesSection(), + + // Messages + _buildMessagesSection(), + + // Form Elements + _buildFormsSection(), + + // Profile Cards + _buildProfileCardsSection(), + + // Data Cards (Imaging, Labs) + _buildDataCardsSection(), + + // Settings + _buildSettingsSection(), + + // Genetics + _buildGeneticsSection(), + + // Notes + _buildNotesSection(), + + // Supplements + _buildSupplementsSection(), + + // Peptides + _buildPeptidesSection(), + + // Upload Area + _buildUploadSection(), + + // Empty State + _buildEmptyStateSection(), + + const SizedBox(height: 48), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + String _selectedLLM = 'claude'; + String _selectedUnits = 'metric'; + + Widget _buildSettingsSection() { + return InouCard( + title: 'Settings', + indicatorColor: InouTheme.indicatorPrivacy, + child: Column( + children: [ + // LLM Selector + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Primary AI Assistant', style: InouText.label), + const SizedBox(height: 2), + Text( + 'Used for "Ask AI" prompts and analysis', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: Column( + children: [ + _LLMOption( + icon: '🤖', + name: 'Claude (Anthropic)', + selected: _selectedLLM == 'claude', + onTap: () => setState(() => _selectedLLM = 'claude'), + ), + _LLMOption( + icon: '💬', + name: 'ChatGPT (OpenAI)', + selected: _selectedLLM == 'chatgpt', + onTap: () => setState(() => _selectedLLM = 'chatgpt'), + ), + _LLMOption( + icon: '✖', + name: 'Grok (xAI)', + selected: _selectedLLM == 'grok', + onTap: () => setState(() => _selectedLLM = 'grok'), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1, color: InouTheme.border), + // Units + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Units', style: InouText.label), + const SizedBox(height: 2), + Text( + 'Measurement system for vitals', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: InouSelect( + value: _selectedUnits, + options: const [ + InouSelectOption(value: 'metric', label: 'Metric (kg, cm, °C)'), + InouSelectOption(value: 'imperial', label: 'Imperial (lb, in, °F)'), + ], + onChanged: (v) => setState(() => _selectedUnits = v ?? 'metric'), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildGeneticsSection() { + return InouCard( + title: 'Genetics', + subtitle: 'Medication Response · 47 variants', + indicatorColor: InouTheme.indicatorGenetics, + child: Column( + children: [ + InouDataRow( + label: 'Medication Response', + meta: '47 variants', + isExpandable: true, + initiallyExpanded: true, + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('CYP2C19', style: InouText.label), + const SizedBox(width: 8), + Text('rs4244285', style: InouText.mono.copyWith(color: InouTheme.textMuted)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text('G;A', style: InouText.mono.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 8), + Text('intermediate', style: InouText.bodySmall.copyWith(color: InouTheme.accent)), + ], + ), + const SizedBox(height: 8), + Text( + 'Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted, height: 1.4), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.accentLight, + border: Border.all(color: InouTheme.accent), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Ask AI', + style: InouText.bodySmall.copyWith(color: InouTheme.accent, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + ), + InkWell( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Center( + child: Text( + 'Show all 47 variants in Medication Response →', + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ), + ), + ), + const InouDataRow(label: 'Metabolism', meta: '23 variants', isExpandable: true), + const InouDataRow(label: 'Cardiovascular', meta: '18 variants', isExpandable: true), + ], + ), + ); + } + + Widget _buildNotesSection() { + return InouCard( + title: 'Notes', + subtitle: 'Health journal entries', + indicatorColor: InouTheme.indicatorJournal, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Knee injury', + meta: '3 photos', + date: 'Dec 20', + leading: const InouNoteIcon(emoji: '📷', color: Color(0xFF6366F1)), + isExpandable: true, + initiallyExpanded: true, + children: [ + Container( + padding: const EdgeInsets.all(16), + color: InouTheme.bg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Photos + Row( + children: [ + _PhotoPlaceholder(label: 'Dec 20'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 22'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 26'), + const SizedBox(width: 12), + _AddPhotoPlaceholder(), + ], + ), + const SizedBox(height: 16), + // Timeline + _NoteTimelineEntry(date: 'Dec 20, 3:45 PM', text: 'Jim fell on his knee at soccer practice. Swelling visible, applied ice.'), + _NoteTimelineEntry(date: 'Dec 22, 10:20 AM', text: 'Swelling reduced. Still some bruising. Can walk without pain.'), + _NoteTimelineEntry(date: 'Dec 26, 9:15 AM', text: 'Almost fully healed. Light bruise remaining.'), + ], + ), + ), + ], + ), + InouDataRow( + label: 'Mild headache after workout', + date: 'Dec 25', + leading: const InouNoteIcon(emoji: '📝', color: InouTheme.accent), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildSupplementsSection() { + return InouCard( + title: 'Supplements', + subtitle: 'Daily routine', + indicatorColor: InouTheme.indicatorMedications, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: const [ + _SupplementRow(name: 'Vitamin D3', dose: '1 capsule', amount: '5000 IU', timing: 'morning, with food'), + _SupplementRow(name: 'Omega-3 Fish Oil', dose: '2 capsules', amount: '2000 mg EPA/DHA', timing: 'morning, with food'), + _SupplementRow(name: 'Magnesium Glycinate', dose: '2 capsules', amount: '400 mg', timing: 'evening'), + _SupplementRow(name: 'Liquid B12', dose: '5 ml', amount: '1000 mcg', timing: 'morning'), + ], + ), + ); + } + + Widget _buildPeptidesSection() { + return InouCard( + title: 'Peptides', + subtitle: 'Therapeutic protocols', + indicatorColor: InouTheme.indicatorMedications, + child: Column( + children: const [ + _PeptideRow(name: 'BPC-157', dose: '250 mcg subQ · 2x daily', endDate: 'until Jan 23, 2025', status: 'active'), + _PeptideRow(name: 'TB-500', dose: '2.5 mg subQ · 2x weekly', endDate: 'until Feb 5, 2025', status: 'active'), + _PeptideRow(name: 'BPC-157', dose: '250 mcg subQ · 2x daily', endDate: 'Aug 15 – Sep 7, 2025', status: 'completed'), + ], + ), + ); + } + + Widget _buildEmptyStateSection() { + return InouCard( + title: 'Empty State', + indicatorColor: InouTheme.indicatorRecords, + child: Container( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No lab data', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ), + ), + ); + } + + Widget _buildHeaderSection() { + return InouCard( + title: 'Header Component', + indicatorColor: InouTheme.accent, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'The InouHeader widget is shown at the top of this page.', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: ClipRRect( + borderRadius: InouTheme.borderRadiusLg, + child: const InouHeader(), + ), + ), + const SizedBox(height: 16), + Text( + 'Logo: "inou" (accent, 700) + "health" (muted, 300) • Font: Sora 1.75rem', + style: InouText.mono.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextBlocksSection() { + return InouCard( + title: 'Text Blocks', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your data. Your rules.', + style: InouText.heroTitle, + ), + const SizedBox(height: 16), + InouText.rich([ + InouSpan.plain('We built ', style: InouText.intro), + InouSpan.accent('inou', baseStyle: InouText.intro.copyWith(fontWeight: FontWeight.w700)), + InouSpan.plain( + ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + style: InouText.intro, + ), + ]), + const SizedBox(height: 32), + Text( + 'What we collect', + style: InouText.sectionTitle, + ), + const SizedBox(height: 16), + Text( + 'Account information.', + style: InouText.subsectionTitle, + ), + const SizedBox(height: 8), + Text( + 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + style: InouText.intro, + ), + ], + ), + ), + ); + } + + Widget _buildTypographySection() { + return InouCard( + title: 'Typography Scale', + indicatorColor: InouTheme.indicatorLabs, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TypographyRow('Page Title', InouText.pageTitle, '2.5rem / 800'), + _TypographyRow('Section Title', InouText.sectionTitle, '1.4rem / 600'), + _TypographyRow('Subsection Title', InouText.subsectionTitle, '1.1rem / 600'), + _TypographyRow( + 'LABEL / CATEGORY', + InouText.labelCaps, + '0.75rem / 600 / caps', + ), + _TypographyRow( + 'Intro text — larger, lighter', + InouText.intro, + '1.15rem / 300', + ), + _TypographyRow( + 'Body light — long-form', + InouText.bodyLight, + '1rem / 300', + ), + _TypographyRow( + 'Body regular — UI labels', + InouText.body, + '1rem / 400', + ), + _TypographyRow( + 'Mono: 1,234,567.89', + InouText.mono, + 'SF Mono', + ), + ], + ), + ), + ); + } + + Widget _buildColorsSection() { + return InouCard( + title: 'Colors', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _ColorRow('Accent', InouTheme.accent, '#B45309'), + _ColorRow('Text', InouTheme.text, '#1C1917'), + _ColorRow('Text Muted', InouTheme.textMuted, '#78716C'), + _ColorRow('Background', InouTheme.bg, '#F8F7F6', hasBorder: true), + _ColorRow('Success', InouTheme.success, '#059669'), + _ColorRow('Danger', InouTheme.danger, '#DC2626'), + ], + ), + ), + ); + } + + Widget _buildButtonsSection() { + return InouCard( + title: 'Buttons', + indicatorColor: InouTheme.indicatorVitals, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + InouButton(text: 'Primary', onPressed: () {}), + InouButton( + text: 'Secondary', + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + InouButton( + text: 'Danger', + variant: ButtonVariant.danger, + onPressed: () {}, + ), + InouButton( + text: 'Small', + size: ButtonSize.small, + onPressed: () {}, + ), + ], + ), + ), + ); + } + + Widget _buildBadgesSection() { + return InouCard( + title: 'Badges', + indicatorColor: InouTheme.indicatorMedications, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + InouBadge(text: 'default'), + InouBadge(text: 'care', variant: BadgeVariant.care), + InouBadge(text: 'Coming Soon', variant: BadgeVariant.comingSoon), + InouBadge(text: 'processing', variant: BadgeVariant.processing), + ], + ), + ), + ); + } + + Widget _buildMessagesSection() { + return InouCard( + title: 'Messages', + indicatorColor: InouTheme.indicatorRecords, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: const [ + InouMessage( + message: 'Error message — something went wrong.', + type: MessageType.error, + ), + SizedBox(height: 12), + InouMessage( + message: 'Info message — useful information.', + type: MessageType.info, + ), + SizedBox(height: 12), + InouMessage( + message: 'Success message — operation completed.', + type: MessageType.success, + ), + ], + ), + ), + ); + } + + Widget _buildFormsSection() { + return InouCard( + title: 'Form Elements', + indicatorColor: InouTheme.indicatorJournal, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const InouTextField( + label: 'Text Input', + placeholder: 'Enter text...', + ), + const SizedBox(height: 16), + InouSelect( + label: 'Select', + value: _selectedOption, + options: const [ + InouSelectOption(value: 'Option 1', label: 'Option 1'), + InouSelectOption(value: 'Option 2', label: 'Option 2'), + InouSelectOption(value: 'Option 3', label: 'Option 3'), + ], + onChanged: (v) => setState(() => _selectedOption = v), + ), + const SizedBox(height: 16), + const InouTextField( + label: 'Code Input', + placeholder: '123456', + isCode: true, + maxLength: 6, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + InouRadioGroup( + value: _selectedSex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (v) => setState(() => _selectedSex = v ?? 'male'), + ), + const SizedBox(height: 16), + InouCheckbox( + value: _checkboxValue, + label: 'Can add data (supplements, notes, etc.)', + onChanged: (v) => setState(() => _checkboxValue = v ?? false), + ), + ], + ), + ), + ); + } + + Widget _buildProfileCardsSection() { + return InouCard( + title: 'Profile Cards', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(24), + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 700 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Johan Jongsma', + role: 'you', + dob: '1985-03-15', + sex: 'Male', + stats: const [ + ProfileStat('📷', '3 studies'), + ProfileStat('🧪', '12 labs'), + ProfileStat('🧬', 'genome'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Sophia', + role: 'Parent', + dob: '2017-01-01', + sex: 'Female', + isCare: true, + stats: const [ + ProfileStat('📷', '16 studies'), + ProfileStat('🧪', '0 labs'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouAddCard( + label: 'Add dossier', + onTap: () {}, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildDataCardsSection() { + return Column( + children: [ + // Imaging + InouCard( + title: 'Imaging', + subtitle: '16 studies · 4113 slices', + indicatorColor: InouTheme.indicatorImaging, + trailing: InouButton( + text: 'Open viewer', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'MRI BRAIN W/WO CONTRAST', + meta: '13 series', + date: '5/5/2022', + isExpandable: true, + children: [ + InouChildRow(label: 'AX T1', meta: '24 slices'), + InouChildRow(label: 'AX T2 FLAIR', meta: '24 slices'), + InouChildRow(label: 'SAG T1', meta: '20 slices'), + ], + ), + const InouDataRow( + label: 'XR CHEST AP ONLY', + date: '5/6/2022', + ), + ], + ), + ), + + // Labs + InouCard( + title: 'Labs', + subtitle: '4 panels · 23 results', + indicatorColor: InouTheme.indicatorLabs, + child: Column( + children: [ + InouDataRow( + label: 'Complete Blood Count (CBC)', + meta: '8 tests', + date: '12/15/2024', + isExpandable: true, + initiallyExpanded: true, + children: const [ + InouChildRow( + label: 'Hemoglobin', + value: '14.2 g/dL', + meta: '12.0–16.0', + ), + InouChildRow( + label: 'White Blood Cells', + value: '7.8 K/µL', + meta: '4.5–11.0', + ), + InouChildRow( + label: 'Platelets', + value: '142 K/µL', + meta: '150–400', + valueColor: InouTheme.danger, + ), + ], + ), + ], + ), + ), + + // Vitals + InouCard( + title: 'Vitals', + subtitle: 'Self-reported measurements', + indicatorColor: InouTheme.indicatorVitals, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Temperature', + value: '37.2 °C', + meta: 'today', + leading: const InouNoteIcon( + emoji: '🌡', + color: InouTheme.danger, + ), + isExpandable: true, + ), + InouDataRow( + label: 'Weight', + value: '72.4 kg', + meta: 'today', + leading: InouNoteIcon( + emoji: '⚖', + color: Colors.blue.shade600, + ), + isExpandable: true, + ), + InouDataRow( + label: 'Blood Pressure', + value: '118/76', + meta: 'yesterday', + leading: InouNoteIcon( + emoji: '❤', + color: Colors.pink.shade600, + ), + isExpandable: true, + ), + ], + ), + ), + ], + ); + } + + Widget _buildUploadSection() { + return InouCard( + title: 'Upload Area', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 32, + color: InouTheme.accent, + ), + const SizedBox(height: 12), + Text( + 'Click or drag files here', + style: InouText.label, + ), + const SizedBox(height: 4), + Text( + 'DICOM, PDF, CSV, VCF, and more', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ), + ); + } +} + +// Helper widgets +class _TypographyRow extends StatelessWidget { + final String text; + final TextStyle style; + final String spec; + + const _TypographyRow(this.text, this.style, this.spec); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded(child: Text(text, style: style)), + Text( + spec, + style: InouText.mono.copyWith( + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _ColorRow extends StatelessWidget { + final String name; + final Color color; + final String hex; + final bool hasBorder; + + const _ColorRow(this.name, this.color, this.hex, {this.hasBorder = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + border: hasBorder ? Border.all(color: InouTheme.border) : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text(name, style: InouText.label), + ), + Text( + hex, + style: InouText.mono.copyWith( + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _LLMOption extends StatelessWidget { + final String icon; + final String name; + final bool selected; + final VoidCallback onTap; + + const _LLMOption({ + required this.icon, + required this.name, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected ? InouTheme.accentLight : InouTheme.bgCard, + border: Border.all(color: selected ? InouTheme.accent : InouTheme.border), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text(icon, style: const TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + Text( + name, + style: InouText.body.copyWith(color: selected ? InouTheme.accent : InouTheme.text), + ), + ], + ), + ), + ); + } +} + +class _PhotoPlaceholder extends StatelessWidget { + final String label; + + const _PhotoPlaceholder({required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.border, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('🦵', style: TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + Text(label, style: InouText.bodySmall.copyWith(fontSize: 11)), + ], + ); + } +} + +class _AddPhotoPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border), + ), + alignment: Alignment.center, + child: Text('+', style: TextStyle(fontSize: 24, color: InouTheme.accent)), + ), + const SizedBox(height: 4), + Text('Add photo', style: InouText.bodySmall.copyWith(fontSize: 11, color: InouTheme.accent)), + ], + ); + } +} + +class _NoteTimelineEntry extends StatelessWidget { + final String date; + final String text; + + const _NoteTimelineEntry({required this.date, required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + const SizedBox(height: 4), + Text(text, style: InouText.bodySmall), + ], + ), + ); + } +} + +class _SupplementRow extends StatelessWidget { + final String name; + final String dose; + final String amount; + final String timing; + + const _SupplementRow({ + required this.name, + required this.dose, + required this.amount, + required this.timing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: InouText.label), + const SizedBox(height: 2), + Text('$dose · $amount', style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + ], + ), + ), + Text(timing, style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle)), + ], + ), + ); + } +} + +class _PeptideRow extends StatelessWidget { + final String name; + final String dose; + final String endDate; + final String status; + + const _PeptideRow({ + required this.name, + required this.dose, + required this.endDate, + required this.status, + }); + + @override + Widget build(BuildContext context) { + final isActive = status == 'active'; + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(name, style: InouText.label), + const SizedBox(width: 8), + Text(dose, style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + ], + ), + const SizedBox(height: 2), + Text(endDate, style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isActive ? InouTheme.successLight : InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status, + style: InouText.bodySmall.copyWith( + color: isActive ? InouTheme.success : InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_badge.dart b/app/lib/design/widgets/inou_badge.dart new file mode 100644 index 0000000..9ebf366 --- /dev/null +++ b/app/lib/design/widgets/inou_badge.dart @@ -0,0 +1,71 @@ +// AUTO-GENERATED widget — matches web .badge +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum BadgeVariant { normal, care, comingSoon, processing } + +class InouBadge extends StatelessWidget { + final String text; + final BadgeVariant variant; + + const InouBadge({ + super.key, + required this.text, + this.variant = BadgeVariant.normal, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + final isUppercase = variant == BadgeVariant.comingSoon; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), // 2v, 8h per styleguide + decoration: BoxDecoration( + color: style.background, + borderRadius: BorderRadius.circular(InouTheme.radiusSm), + ), + child: Text( + isUppercase ? text.toUpperCase() : text, + style: InouTheme.badgeText.copyWith( + fontSize: variant == BadgeVariant.comingSoon ? 10 : 15, // 15px (1rem) per styleguide + color: style.foreground, + letterSpacing: isUppercase ? 0.5 : 0, + ), + ), + ); + } + + _BadgeStyle _getStyle() { + switch (variant) { + case BadgeVariant.normal: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + case BadgeVariant.care: + return _BadgeStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + ); + case BadgeVariant.comingSoon: + return _BadgeStyle( + background: InouTheme.bg, + foreground: InouTheme.textMuted, + ); + case BadgeVariant.processing: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + } + } +} + +class _BadgeStyle { + final Color background; + final Color foreground; + + _BadgeStyle({required this.background, required this.foreground}); +} diff --git a/app/lib/design/widgets/inou_button.dart b/app/lib/design/widgets/inou_button.dart new file mode 100644 index 0000000..5e1696c --- /dev/null +++ b/app/lib/design/widgets/inou_button.dart @@ -0,0 +1,129 @@ +// AUTO-GENERATED widget — matches web .btn +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum ButtonVariant { primary, secondary, danger } +enum ButtonSize { regular, small } + +class InouButton extends StatelessWidget { + final String text; + final ButtonVariant variant; + final ButtonSize size; + final bool fullWidth; + final VoidCallback? onPressed; + final Widget? icon; + + const InouButton({ + super.key, + required this.text, + this.variant = ButtonVariant.primary, + this.size = ButtonSize.regular, + this.fullWidth = false, + this.onPressed, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final isSmall = size == ButtonSize.small; + final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); + + final style = _getStyle(); + + Widget button = TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + backgroundColor: style.background, + foregroundColor: style.foreground, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + side: style.border, + ), + textStyle: InouText.label.copyWith( + fontSize: 15, // Always 15px (1rem) for both sizes per styleguide + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: 6), + ], + Text(text), + ], + ), + ); + + if (fullWidth) { + button = SizedBox(width: double.infinity, child: button); + } + + return button; + } + + _ButtonStyle _getStyle() { + switch (variant) { + case ButtonVariant.primary: + return _ButtonStyle( + background: InouTheme.accent, + foreground: Colors.white, + border: BorderSide.none, + ); + case ButtonVariant.secondary: + return _ButtonStyle( + background: InouTheme.bgCard, + foreground: InouTheme.text, + border: const BorderSide(color: InouTheme.border), + ); + case ButtonVariant.danger: + return _ButtonStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: BorderSide(color: InouTheme.danger.withOpacity(0.3)), + ); + } + } +} + +class _ButtonStyle { + final Color background; + final Color foreground; + final BorderSide border; + + _ButtonStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} + +/// Icon button (matches .btn-icon) +class InouIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final Color? color; + + const InouIconButton({ + super.key, + required this.icon, + this.onPressed, + this.color, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + onPressed: onPressed, + color: color ?? InouTheme.textSubtle, + iconSize: 20, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(), + ); + } +} diff --git a/app/lib/design/widgets/inou_card.dart b/app/lib/design/widgets/inou_card.dart new file mode 100644 index 0000000..a90ba7e --- /dev/null +++ b/app/lib/design/widgets/inou_card.dart @@ -0,0 +1,310 @@ +// AUTO-GENERATED widget — matches web .data-card +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/inou_badge.dart'; +import 'package:inou_app/design/widgets/inou_button.dart'; + +/// Data card with colored indicator bar +class InouCard extends StatelessWidget { + final String? title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + final Widget? child; + final VoidCallback? onTap; + + const InouCard({ + super.key, + this.title, + this.subtitle, + this.indicatorColor = InouTheme.accent, + this.trailing, + this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) + _Header( + title: title!, + subtitle: subtitle, + indicatorColor: indicatorColor, + trailing: trailing, + ), + if (child != null) child!, + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + + const _Header({ + required this.title, + this.subtitle, + required this.indicatorColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(InouTheme.spaceLg), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: InouTheme.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: InouText.labelCaps, + ), + if (subtitle != null) + Text( + subtitle!, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Simple card without indicator +class InouSimpleCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final VoidCallback? onTap; + + const InouSimpleCard({ + super.key, + required this.child, + this.padding, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final card = Container( + padding: padding ?? const EdgeInsets.all(InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: card, + ); + } + return card; + } +} + +/// Profile card for dashboard +class InouProfileCard extends StatelessWidget { + final String name; + final String? role; + final String? dob; + final String? sex; + final List stats; + final bool isCare; + final VoidCallback? onTap; + final VoidCallback? onEdit; + + const InouProfileCard({ + super.key, + required this.name, + this.role, + this.dob, + this.sex, + this.stats = const [], + this.isCare = false, + this.onTap, + this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(name, style: InouText.h3), + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text('✎', style: TextStyle(color: InouTheme.textMuted)), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + role ?? 'you', + style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle), + ), + if (isCare) ...[ + const SizedBox(width: 8), + const InouBadge(text: 'care', variant: BadgeVariant.care), + ], + ], + ), + if (dob != null) ...[ + const SizedBox(height: 8), + Text( + 'Born: $dob${sex != null ? ' · $sex' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (stats.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: stats.map((s) => _StatChip(stat: s)).toList(), + ), + ], + const Spacer(), + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: onTap, + ), + ], + ), + ), + ); + } +} + +class ProfileStat { + final String emoji; + final String label; + + const ProfileStat(this.emoji, this.label); +} + +class _StatChip extends StatelessWidget { + final ProfileStat stat; + + const _StatChip({required this.stat}); + + @override + Widget build(BuildContext context) { + return Text( + '${stat.emoji} ${stat.label}', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +/// Add card (dashed border) +class InouAddCard extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const InouAddCard({ + super.key, + required this.label, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: InouTheme.borderRadiusLg, + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, // Note: Flutter doesn't support dashed directly + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + color: InouTheme.accent, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 6), + Text( + label, + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_data_row.dart b/app/lib/design/widgets/inou_data_row.dart new file mode 100644 index 0000000..2119246 --- /dev/null +++ b/app/lib/design/widgets/inou_data_row.dart @@ -0,0 +1,233 @@ +// AUTO-GENERATED widget — matches web .data-row +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Expandable data row (for imaging, labs, etc.) +class InouDataRow extends StatefulWidget { + final String label; + final String? meta; + final String? date; + final String? value; + final bool isExpandable; + final List? children; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool initiallyExpanded; + + const InouDataRow({ + super.key, + required this.label, + this.meta, + this.date, + this.value, + this.isExpandable = false, + this.children, + this.leading, + this.trailing, + this.onTap, + this.initiallyExpanded = false, + }); + + @override + State createState() => _InouDataRowState(); +} + +class _InouDataRowState extends State { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + onTap: widget.isExpandable + ? () => setState(() => _expanded = !_expanded) + : widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + if (widget.isExpandable) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: TextStyle( + color: InouTheme.textMuted, + fontSize: 14, + fontFamily: 'monospace', + ), + ), + ) + else if (widget.leading == null) + const SizedBox(width: 32), + if (widget.leading != null) ...[ + widget.leading!, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.label, + style: InouText.body.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.value != null) + Text( + widget.value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: InouTheme.text, + ), + ), + if (widget.meta != null) ...[ + const SizedBox(width: 16), + Text( + widget.meta!, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + if (widget.date != null) ...[ + const SizedBox(width: 16), + Text( + widget.date!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + if (widget.trailing != null) ...[ + const SizedBox(width: 8), + widget.trailing!, + ], + ], + ), + ), + ), + if (_expanded && widget.children != null) + Container( + color: InouTheme.bg, + child: Column(children: widget.children!), + ), + ], + ); + } +} + +/// Child row (indented) +class InouChildRow extends StatelessWidget { + final String label; + final String? value; + final String? meta; + final Widget? trailing; + final Color? valueColor; + + const InouChildRow({ + super.key, + required this.label, + this.value, + this.meta, + this.trailing, + this.valueColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: InouTheme.spaceXxxl), // 48px indent per styleguide + Expanded( + child: Text( + label, + style: InouText.body, + ), + ), + if (value != null) + Text( + value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: valueColor ?? InouTheme.text, + ), + ), + if (meta != null) ...[ + const SizedBox(width: 16), + Text( + meta!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ); + } +} + +/// Icon for notes/vitals +class InouNoteIcon extends StatelessWidget { + final String emoji; + final Color color; + + const InouNoteIcon({ + super.key, + required this.emoji, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 16)), + ); + } +} diff --git a/app/lib/design/widgets/inou_footer.dart b/app/lib/design/widgets/inou_footer.dart new file mode 100644 index 0000000..ffb01d8 --- /dev/null +++ b/app/lib/design/widgets/inou_footer.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Footer link group +class FooterLinkGroup { + final String title; + final List links; + + const FooterLinkGroup({ + required this.title, + required this.links, + }); +} + +/// Individual footer link +class FooterLink { + final String label; + final String route; + final bool isExternal; + + const FooterLink({ + required this.label, + required this.route, + this.isExternal = false, + }); +} + +/// inou Footer - responsive, matches web design +class InouFooter extends StatelessWidget { + final List linkGroups; + final Function(FooterLink)? onLinkTap; + final String? copyrightText; + + const InouFooter({ + super.key, + this.linkGroups = const [], + this.onLinkTap, + this.copyrightText, + }); + + static final defaultLinkGroups = [ + const FooterLinkGroup( + title: 'Product', + links: [ + FooterLink(label: 'Features', route: '/features'), + FooterLink(label: 'Security', route: '/security'), + FooterLink(label: 'FAQ', route: '/faq'), + ], + ), + const FooterLinkGroup( + title: 'Legal', + links: [ + FooterLink(label: 'Privacy', route: '/privacy'), + FooterLink(label: 'Terms', route: '/terms'), + FooterLink(label: 'DPA', route: '/dpa'), + ], + ), + const FooterLinkGroup( + title: 'Connect', + links: [ + FooterLink(label: 'Contact', route: '/contact'), + FooterLink(label: 'Invite a friend', route: '/invite'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 768; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border( + top: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: isMobile ? 32 : 48, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + isMobile + ? _buildMobileLinks(context) + : _buildDesktopLinks(context), + const SizedBox(height: 32), + _buildBottomBar(context, isMobile), + ], + ), + ), + ), + ); + } + + Widget _buildDesktopLinks(BuildContext context) { + final groups = linkGroups.isEmpty ? defaultLinkGroups : linkGroups; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo and tagline + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 12), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ), + + // Link groups + for (final group in groups) ...[ + const SizedBox(width: 48), + Expanded( + child: _buildLinkGroup(context, group), + ), + ], + ], + ); + } + + Widget _buildMobileLinks(BuildContext context) { + final groups = linkGroups.isEmpty ? defaultLinkGroups : linkGroups; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo + Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 32), + + // Links in 2-column grid + Wrap( + spacing: 32, + runSpacing: 24, + children: [ + for (final group in groups) + SizedBox( + width: 140, + child: _buildLinkGroup(context, group), + ), + ], + ), + ], + ); + } + + Widget _buildLinkGroup(BuildContext context, FooterLinkGroup group) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.title.toUpperCase(), + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 12), + for (final link in group.links) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () => _handleLinkTap(context, link), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + link.label, + style: InouText.bodySmall.copyWith( + color: InouTheme.text, + ), + ), + if (link.isExternal) ...[ + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 12, + color: InouTheme.textMuted, + ), + ], + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildBottomBar(BuildContext context, bool isMobile) { + final year = DateTime.now().year; + final copyright = copyrightText ?? '© $year inou health. All rights reserved.'; + + return Container( + padding: const EdgeInsets.only(top: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + copyright, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 12), + _buildSocialLinks(), + ], + ) + : Row( + children: [ + Text( + copyright, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + const Spacer(), + _buildSocialLinks(), + ], + ), + ); + } + + Widget _buildSocialLinks() { + // Placeholder for social links if needed + return const SizedBox.shrink(); + } + + void _handleLinkTap(BuildContext context, FooterLink link) { + if (onLinkTap != null) { + onLinkTap!(link); + return; + } + + if (link.isExternal) { + // Handle external links (url_launcher) + return; + } + + Navigator.pushNamed(context, link.route); + } +} diff --git a/app/lib/design/widgets/inou_header.dart b/app/lib/design/widgets/inou_header.dart new file mode 100644 index 0000000..fc6effb --- /dev/null +++ b/app/lib/design/widgets/inou_header.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/main.dart'; +import 'package:inou_app/core/locale_provider.dart'; + +/// Navigation item for header +class NavItem { + final String label; + final String route; + final bool isExternal; + + const NavItem({ + required this.label, + required this.route, + this.isExternal = false, + }); +} + +/// inou Header - responsive, matches web design with language switcher +class InouHeader extends StatelessWidget { + final VoidCallback? onLogoTap; + final List navItems; + final String? currentRoute; + final VoidCallback? onLoginTap; + final VoidCallback? onSignupTap; + final bool isLoggedIn; + final String? userName; + final VoidCallback? onProfileTap; + final VoidCallback? onLogoutTap; + + const InouHeader({ + super.key, + this.onLogoTap, + this.navItems = const [], + this.currentRoute, + this.onLoginTap, + this.onSignupTap, + this.isLoggedIn = false, + this.userName, + this.onProfileTap, + this.onLogoutTap, + }); + + static const defaultNavItems = [ + NavItem(label: 'Dossiers', route: '/dossiers'), + NavItem(label: 'Privacy', route: '/privacy'), + NavItem(label: 'Connect', route: '/connect'), + NavItem(label: 'Invite a friend', route: '/invite'), + NavItem(label: 'Demo', route: '/demo'), + ]; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 768; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border( + bottom: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: SafeArea( + bottom: false, + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: 12, + ), + child: isMobile ? _buildMobileHeader(context) : _buildDesktopHeader(context), + ), + ), + ), + ); + } + + Widget _buildDesktopHeader(BuildContext context) { + return Row( + children: [ + // Logo + _buildLogo(context), + + const SizedBox(width: 48), + + // Navigation + Expanded( + child: Row( + children: [ + for (final item in navItems.isEmpty ? defaultNavItems : navItems) + _buildNavItem(context, item), + ], + ), + ), + + // Language switcher + _LanguageSwitcher(), + + const SizedBox(width: 16), + + // Auth buttons + _buildAuthSection(context), + ], + ); + } + + Widget _buildMobileHeader(BuildContext context) { + return Row( + children: [ + _buildLogo(context), + const Spacer(), + _LanguageSwitcher(), + const SizedBox(width: 8), + _buildMobileMenuButton(context), + ], + ); + } + + Widget _buildLogo(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return GestureDetector( + onTap: onLogoTap, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + 'inou', + style: InouText.logo.copyWith( + color: InouTheme.accent, + ), + ), + Text( + 'health', + style: InouText.logoLight.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(width: 12), + Text( + l10n?.appTagline ?? 'ai answers for you', + style: InouText.logoTagline, + ), + ], + ), + ); + } + + Widget _buildNavItem(BuildContext context, NavItem item) { + final isActive = currentRoute == item.route; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: InkWell( + onTap: () => _navigateTo(context, item), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text( + item.label, + style: isActive ? InouText.navActive : InouText.nav, + ), + ), + ), + ); + } + + Widget _buildAuthSection(BuildContext context) { + final l10n = AppLocalizations.of(context); + + if (isLoggedIn) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onProfileTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: InouTheme.accentLight, + child: Text( + (userName ?? 'U')[0].toUpperCase(), + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + userName ?? 'Account', + style: InouText.body, + ), + ], + ), + ), + ), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onLoginTap, + child: Text( + l10n?.signIn ?? 'Log in', + style: InouText.nav, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: onSignupTap, + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + ), + child: Text(l10n?.getStarted ?? 'Get started', style: InouText.button.copyWith(color: Colors.white)), + ), + ], + ); + } + + Widget _buildMobileMenuButton(BuildContext context) { + return IconButton( + icon: const Icon(Icons.menu, color: InouTheme.text), + onPressed: () => _showMobileMenu(context), + ); + } + + void _showMobileMenu(BuildContext context) { + final l10n = AppLocalizations.of(context); + + showModalBottomSheet( + context: context, + backgroundColor: InouTheme.bgCard, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in navItems.isEmpty ? defaultNavItems : navItems) + ListTile( + title: Text(item.label, style: InouText.body), + trailing: item.isExternal + ? Icon(Icons.open_in_new, size: 18, color: InouTheme.textMuted) + : null, + onTap: () { + Navigator.pop(context); + _navigateTo(context, item); + }, + ), + const Divider(height: 32), + if (!isLoggedIn) ...[ + OutlinedButton( + onPressed: () { + Navigator.pop(context); + onLoginTap?.call(); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(l10n?.signIn ?? 'Log in', style: InouText.button), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + onSignupTap?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(l10n?.getStarted ?? 'Get started', style: InouText.button.copyWith(color: Colors.white)), + ), + ] else ...[ + ListTile( + leading: CircleAvatar( + backgroundColor: InouTheme.accentLight, + child: Text( + (userName ?? 'U')[0].toUpperCase(), + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ), + title: Text(userName ?? 'Account', style: InouText.body), + onTap: () { + Navigator.pop(context); + onProfileTap?.call(); + }, + ), + ListTile( + leading: const Icon(Icons.logout), + title: Text('Log out', style: InouText.body), + onTap: () { + Navigator.pop(context); + onLogoutTap?.call(); + }, + ), + ], + ], + ), + ), + ), + ); + } + + void _navigateTo(BuildContext context, NavItem item) { + if (item.isExternal) { + // Handle external links (url_launcher would be needed) + return; + } + Navigator.pushNamed(context, item.route); + } +} + +/// Language switcher dropdown matching Go version .lang-menu +class _LanguageSwitcher extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: localeNotifier, + builder: (context, locale, _) { + final currentCode = LocaleProvider.localeCodes[locale.languageCode] ?? 'EN'; + + return PopupMenuButton( + offset: const Offset(0, 40), + tooltip: 'Change language', + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + color: InouTheme.bgCard, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + currentCode, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 16, + color: InouTheme.textMuted, + ), + ], + ), + ), + onSelected: (selectedLocale) { + InouApp.setLocale(context, selectedLocale); + }, + itemBuilder: (context) => [ + for (final supportedLocale in LocaleProvider.supportedLocales) + PopupMenuItem( + value: supportedLocale, + child: Row( + children: [ + Text( + LocaleProvider.localeNames[supportedLocale.languageCode] ?? '', + style: InouText.bodySmall.copyWith( + color: locale.languageCode == supportedLocale.languageCode + ? InouTheme.accent + : InouTheme.text, + fontWeight: locale.languageCode == supportedLocale.languageCode + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + if (locale.languageCode == supportedLocale.languageCode) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 16, color: InouTheme.accent), + ], + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/app/lib/design/widgets/inou_input.dart b/app/lib/design/widgets/inou_input.dart new file mode 100644 index 0000000..b01ecae --- /dev/null +++ b/app/lib/design/widgets/inou_input.dart @@ -0,0 +1,292 @@ +// Form input widgets +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Text input field with validation support +class InouTextField extends StatelessWidget { + final String? label; + final String? placeholder; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final int? maxLength; + final int? maxLines; + final bool isCode; + final ValueChanged? onChanged; + final FormFieldValidator? validator; + final Widget? suffixIcon; + final Iterable? autofillHints; + final bool enabled; + + const InouTextField({ + super.key, + this.label, + this.placeholder, + this.controller, + this.obscureText = false, + this.keyboardType, + this.maxLength, + this.maxLines = 1, + this.isCode = false, + this.onChanged, + this.validator, + this.suffixIcon, + this.autofillHints, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: InouText.label, + ), + const SizedBox(height: 4), + ], + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + maxLength: maxLength, + maxLines: maxLines, + enabled: enabled, + textAlign: isCode ? TextAlign.center : TextAlign.start, + onChanged: onChanged, + validator: validator, + autofillHints: autofillHints, + style: isCode + ? const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: 8, + fontFamily: 'SF Mono', + ) + : InouText.body, + decoration: InputDecoration( + hintText: placeholder, + counterText: '', + suffixIcon: suffixIcon, + filled: true, + fillColor: InouTheme.bgCard, + border: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.accent, width: 1), // 1px per styleguide + ), + errorBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.danger, width: 1), // 1px per styleguide + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: maxLines != null && maxLines! > 1 ? 12 : 14, + ), + ), + ), + ], + ); + } +} + +/// Dropdown select +class InouSelect extends StatelessWidget { + final String? label; + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouSelect({ + super.key, + this.label, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouText.label), + const SizedBox(height: 4), + ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusMd, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: options + .map((o) => DropdownMenuItem( + value: o.value, + child: Text(o.label), + )) + .toList(), + onChanged: onChanged, + ), + ), + ), + ], + ); + } +} + +class InouSelectOption { + final T value; + final String label; + + const InouSelectOption({required this.value, required this.label}); +} + +/// Radio group +class InouRadioGroup extends StatelessWidget { + final String? label; + final String? hint; + final T? value; + final List> options; + final ValueChanged? onChanged; + final Axis direction; + + const InouRadioGroup({ + super.key, + this.label, + this.hint, + this.value, + required this.options, + this.onChanged, + this.direction = Axis.horizontal, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouText.label), + if (hint != null) ...[ + const SizedBox(height: 2), + Text( + hint!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + const SizedBox(height: 8), + ], + direction == Axis.horizontal + ? Row( + children: _buildOptions(), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildOptions(), + ), + ], + ); + } + + List _buildOptions() { + return options.map((option) { + return Padding( + padding: EdgeInsets.only( + right: direction == Axis.horizontal ? 16 : 0, + bottom: direction == Axis.vertical ? 8 : 0, + ), + child: InkWell( + onTap: () => onChanged?.call(option.value), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio( + value: option.value, + groupValue: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Text(option.label, style: InouText.body), + ], + ), + ), + ); + }).toList(); + } +} + +class InouRadioOption { + final T value; + final String label; + + const InouRadioOption({required this.value, required this.label}); +} + +/// Checkbox with optional custom child +class InouCheckbox extends StatelessWidget { + final bool value; + final String? label; + final Widget? child; + final ValueChanged? onChanged; + + const InouCheckbox({ + super.key, + required this.value, + this.label, + this.child, + this.onChanged, + }) : assert(label != null || child != null, 'Provide either label or child'); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged?.call(!value), + borderRadius: BorderRadius.circular(4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: child ?? + Text( + label!, + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_message.dart b/app/lib/design/widgets/inou_message.dart new file mode 100644 index 0000000..f9b886e --- /dev/null +++ b/app/lib/design/widgets/inou_message.dart @@ -0,0 +1,70 @@ +// AUTO-GENERATED widget — matches web .error/.info/.success +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum MessageType { error, info, success } + +class InouMessage extends StatelessWidget { + final String message; + final MessageType type; + + const InouMessage({ + super.key, + required this.message, + this.type = MessageType.info, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: style.background, + border: Border.all(color: style.border), + borderRadius: BorderRadius.circular(InouTheme.radiusMd), + ), + child: Text( + message, + style: InouText.body.copyWith(color: style.foreground), + ), + ); + } + + _MessageStyle _getStyle() { + switch (type) { + case MessageType.error: + return _MessageStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: InouTheme.errorBorder, + ); + case MessageType.info: + return _MessageStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + border: InouTheme.infoBorder, + ); + case MessageType.success: + return _MessageStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + border: InouTheme.successBorder, + ); + } + } +} + +class _MessageStyle { + final Color background; + final Color foreground; + final Color border; + + _MessageStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} diff --git a/app/lib/design/widgets/inou_page.dart b/app/lib/design/widgets/inou_page.dart new file mode 100644 index 0000000..6a14ea1 --- /dev/null +++ b/app/lib/design/widgets/inou_page.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/inou_header.dart'; +import 'package:inou_app/design/widgets/inou_footer.dart'; + +/// Page scaffold with header and footer +/// +/// Use [InouPage] for public pages (landing, security, FAQ, etc.) +/// Use [InouAuthPage] for authenticated pages (dashboard, dossier) +class InouPage extends StatelessWidget { + final Widget child; + final String? currentRoute; + final bool showHeader; + final bool showFooter; + final List? navItems; + final bool isLoggedIn; + final String? userName; + final VoidCallback? onLoginTap; + final VoidCallback? onSignupTap; + final VoidCallback? onProfileTap; + final VoidCallback? onLogoutTap; + final EdgeInsets? padding; + final bool centerContent; + final double? maxWidth; + + const InouPage({ + super.key, + required this.child, + this.currentRoute, + this.showHeader = true, + this.showFooter = true, + this.navItems, + this.isLoggedIn = false, + this.userName, + this.onLoginTap, + this.onSignupTap, + this.onProfileTap, + this.onLogoutTap, + this.padding, + this.centerContent = true, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: Column( + children: [ + if (showHeader) + InouHeader( + currentRoute: currentRoute, + navItems: navItems ?? InouHeader.defaultNavItems, + isLoggedIn: isLoggedIn, + userName: userName, + onLogoTap: () => Navigator.pushNamedAndRemoveUntil( + context, + '/', + (route) => false, + ), + onLoginTap: onLoginTap ?? () => Navigator.pushNamed(context, '/login'), + onSignupTap: onSignupTap ?? () => Navigator.pushNamed(context, '/signup'), + onProfileTap: onProfileTap, + onLogoutTap: onLogoutTap, + ), + + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildContent(context), + if (showFooter) const InouFooter(), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(BuildContext context) { + final content = centerContent + ? Center( + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidth, + ), + padding: padding ?? const EdgeInsets.all(24), + child: child, + ), + ) + : Padding( + padding: padding ?? const EdgeInsets.all(24), + child: child, + ); + + return content; + } +} + +/// Authenticated page scaffold with mandatory header +/// +/// For deep pages like dashboard and dossier +class InouAuthPage extends StatelessWidget { + final Widget child; + final String? currentRoute; + final String? title; + final List? actions; + final String userName; + final VoidCallback onProfileTap; + final VoidCallback onLogoutTap; + final bool showBackButton; + final VoidCallback? onBackTap; + final EdgeInsets? padding; + final double? maxWidth; + + const InouAuthPage({ + super.key, + required this.child, + this.currentRoute, + this.title, + this.actions, + required this.userName, + required this.onProfileTap, + required this.onLogoutTap, + this.showBackButton = false, + this.onBackTap, + this.padding, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: Column( + children: [ + _buildAuthHeader(context), + Expanded( + child: SingleChildScrollView( + child: Center( + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidth, + ), + padding: padding ?? const EdgeInsets.all(24), + child: child, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAuthHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + if (showBackButton) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBackTap ?? () => Navigator.pop(context), + color: InouTheme.text, + ), + + if (title != null) ...[ + if (showBackButton) const SizedBox(width: 8), + Text(title!, style: InouText.h3), + ] else ...[ + // Logo + GestureDetector( + onTap: () => Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ), + child: Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + ), + ], + + const Spacer(), + + if (actions != null) + Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ), + + const SizedBox(width: 12), + + // User menu + PopupMenuButton( + offset: const Offset(0, 48), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: InouTheme.accentLight, + child: Text( + userName[0].toUpperCase(), + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.keyboard_arrow_down, color: InouTheme.textMuted, size: 20), + ], + ), + onSelected: (value) { + switch (value) { + case 'profile': + onProfileTap(); + break; + case 'logout': + onLogoutTap(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'profile', + child: Row( + children: [ + Icon(Icons.person_outline, size: 20, color: InouTheme.text), + const SizedBox(width: 12), + Text('Profile', style: InouText.body), + ], + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout, size: 20, color: InouTheme.danger), + const SizedBox(width: 12), + Text('Log out', style: InouText.body.copyWith(color: InouTheme.danger)), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +/// Minimal page for auth flows (login, signup, forgot password) +class InouAuthFlowPage extends StatelessWidget { + final Widget child; + final bool showLogo; + final double? maxWidth; + + const InouAuthFlowPage({ + super.key, + required this.child, + this.showLogo = true, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidthForm, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (showLogo) ...[ + Text( + 'inou', + style: InouTheme.pageTitle.copyWith( + color: InouTheme.accent, + letterSpacing: -1, + ), + ), + const SizedBox(height: 8), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 48), + ], + child, + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/design/widgets/widgets.dart b/app/lib/design/widgets/widgets.dart new file mode 100644 index 0000000..48e2ef5 --- /dev/null +++ b/app/lib/design/widgets/widgets.dart @@ -0,0 +1,12 @@ +// Barrel file for all inou widgets +export 'inou_card.dart'; +export 'inou_button.dart'; +export 'inou_badge.dart'; +export 'inou_message.dart'; +export 'inou_input.dart'; +export 'inou_data_row.dart'; + +// Layout components +export 'inou_header.dart'; +export 'inou_footer.dart'; +export 'inou_page.dart'; diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart new file mode 100644 index 0000000..ca4d3f4 --- /dev/null +++ b/app/lib/features/auth/auth.dart @@ -0,0 +1,3 @@ +// Barrel file for auth pages +export 'login_page.dart'; +export 'signup_page.dart'; diff --git a/app/lib/features/auth/login_page.dart b/app/lib/features/auth/login_page.dart new file mode 100644 index 0000000..0318dd3 --- /dev/null +++ b/app/lib/features/auth/login_page.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Login page with biometric support +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _obscurePassword = true; + bool _rememberMe = false; + + // Biometric state + bool _biometricAvailable = false; + bool _biometricEnrolled = false; + + @override + void initState() { + super.initState(); + _checkBiometricAvailability(); + } + + Future _checkBiometricAvailability() async { + // TODO: Check actual biometric availability using local_auth + // For now, simulate availability on mobile + setState(() { + _biometricAvailable = true; // Placeholder + _biometricEnrolled = false; // Placeholder - check if user has enrolled + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouAuthFlowPage( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Welcome back', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Sign in to access your health dossier', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Biometric login button (if available and enrolled) + if (_biometricAvailable && _biometricEnrolled) ...[ + _buildBiometricButton(), + const SizedBox(height: 24), + _buildDivider('or sign in with email'), + const SizedBox(height: 24), + ], + + // Email field + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password field + InouTextField( + label: 'Password', + controller: _passwordController, + placeholder: '••••••••', + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Remember me & forgot password row + Row( + children: [ + InouCheckbox( + value: _rememberMe, + label: 'Remember me', + onChanged: (value) { + setState(() => _rememberMe = value ?? false); + }, + ), + const Spacer(), + TextButton( + onPressed: _handleForgotPassword, + child: Text( + 'Forgot password?', + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + ), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Login button + InouButton( + text: _isLoading ? 'Signing in...' : 'Sign in', + onPressed: _isLoading ? null : _handleLogin, + ), + + const SizedBox(height: 24), + + // Sign up link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Don\'t have an account? ', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + TextButton( + onPressed: () => Navigator.pushReplacementNamed(context, '/signup'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Sign up', + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildBiometricButton() { + return OutlinedButton.icon( + onPressed: _handleBiometricLogin, + icon: const Icon(Icons.fingerprint, size: 24), + label: const Text('Sign in with biometrics'), + style: OutlinedButton.styleFrom( + foregroundColor: InouTheme.text, + side: BorderSide(color: InouTheme.border), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + ), + ); + } + + Widget _buildDivider(String text) { + return Row( + children: [ + Expanded(child: Divider(color: InouTheme.border)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + text, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ), + Expanded(child: Divider(color: InouTheme.border)), + ], + ); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + // TODO: Implement actual login + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + // After successful login, offer biometric enrollment if available + if (_biometricAvailable && !_biometricEnrolled) { + await _offerBiometricEnrollment(); + } + + // Navigate to dashboard + Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login failed: ${e.toString()}'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _handleBiometricLogin() async { + // TODO: Implement biometric authentication using local_auth + // On success, retrieve stored credentials and login + } + + Future _handleForgotPassword() async { + // Navigate to forgot password flow + Navigator.pushNamed(context, '/forgot-password'); + } + + Future _offerBiometricEnrollment() async { + final shouldEnroll = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusLg, + ), + title: const Text('Enable biometric login?'), + content: const Text( + 'Sign in faster next time using your fingerprint or face.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Not now', + style: TextStyle(color: InouTheme.textMuted), + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + ), + child: const Text('Enable'), + ), + ], + ), + ); + + if (shouldEnroll == true) { + // TODO: Enroll biometric credentials + // Store encrypted credentials securely + } + } +} diff --git a/app/lib/features/auth/signup_page.dart b/app/lib/features/auth/signup_page.dart new file mode 100644 index 0000000..d0c7269 --- /dev/null +++ b/app/lib/features/auth/signup_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Signup page with step-by-step flow +class SignupPage extends StatefulWidget { + const SignupPage({super.key}); + + @override + State createState() => _SignupPageState(); +} + +class _SignupPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirm = true; + bool _acceptedTerms = false; + + // Additional profile info + DateTime? _dateOfBirth; + String? _sex; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouAuthFlowPage( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Create your account', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Start understanding your health better', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Name field + InouTextField( + label: 'Full name', + controller: _nameController, + placeholder: 'Your name', + autofillHints: const [AutofillHints.name], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Email field + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Date of birth + _buildDateOfBirthField(), + const SizedBox(height: 16), + + // Sex selection + InouRadioGroup( + label: 'Biological sex', + hint: 'Used for accurate medical context', + value: _sex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (value) { + setState(() => _sex = value); + }, + ), + const SizedBox(height: 16), + + // Password field + InouTextField( + label: 'Password', + controller: _passwordController, + placeholder: 'At least 8 characters', + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.newPassword], + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Confirm password field + InouTextField( + label: 'Confirm password', + controller: _confirmPasswordController, + placeholder: 'Re-enter your password', + obscureText: _obscureConfirm, + autofillHints: const [AutofillHints.newPassword], + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscureConfirm = !_obscureConfirm); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // Terms acceptance + InouCheckbox( + value: _acceptedTerms, + onChanged: (value) { + setState(() => _acceptedTerms = value ?? false); + }, + child: RichText( + text: TextSpan( + style: InouText.bodySmall.copyWith(color: InouTheme.text), + children: [ + const TextSpan(text: 'I agree to the '), + TextSpan( + text: 'Terms of Service', + style: TextStyle(color: InouTheme.accent), + // TODO: Make tappable + ), + const TextSpan(text: ' and '), + TextSpan( + text: 'Privacy Policy', + style: TextStyle(color: InouTheme.accent), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Sign up button + InouButton( + text: _isLoading ? 'Creating account...' : 'Create account', + onPressed: (_isLoading || !_acceptedTerms) ? null : _handleSignup, + ), + + const SizedBox(height: 24), + + // Login link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Already have an account? ', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + TextButton( + onPressed: () => Navigator.pushReplacementNamed(context, '/login'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Sign in', + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildDateOfBirthField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Date of birth', + style: InouText.label, + ), + const SizedBox(height: 4), + Text( + 'Used for accurate medical context', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(height: 8), + InkWell( + onTap: _selectDateOfBirth, + borderRadius: InouTheme.borderRadiusMd, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusMd, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Text( + _dateOfBirth != null + ? '${_dateOfBirth!.month}/${_dateOfBirth!.day}/${_dateOfBirth!.year}' + : 'Select date', + style: InouText.body.copyWith( + color: _dateOfBirth != null + ? InouTheme.text + : InouTheme.textMuted, + ), + ), + ), + Icon( + Icons.calendar_today, + size: 20, + color: InouTheme.textMuted, + ), + ], + ), + ), + ), + ], + ); + } + + Future _selectDateOfBirth() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _dateOfBirth ?? DateTime(now.year - 30), + firstDate: DateTime(1900), + lastDate: now, + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: InouTheme.accent, + onPrimary: Colors.white, + surface: InouTheme.bgCard, + onSurface: InouTheme.text, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + setState(() => _dateOfBirth = picked); + } + } + + Future _handleSignup() async { + if (!_formKey.currentState!.validate()) return; + + if (_dateOfBirth == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please select your date of birth'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + if (_sex == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please select your biological sex'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // TODO: Implement actual signup + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + // Navigate to email verification or dashboard + Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Signup failed: ${e.toString()}'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/app/lib/features/dashboard/dashboard.dart b/app/lib/features/dashboard/dashboard.dart new file mode 100644 index 0000000..60a3e99 --- /dev/null +++ b/app/lib/features/dashboard/dashboard.dart @@ -0,0 +1,4 @@ +export 'models.dart'; +export 'mock_data.dart'; +export 'dashboard_page.dart'; +export 'dossier_page.dart'; diff --git a/app/lib/features/dashboard/dashboard_page.dart b/app/lib/features/dashboard/dashboard_page.dart new file mode 100644 index 0000000..a6dbac6 --- /dev/null +++ b/app/lib/features/dashboard/dashboard_page.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; +import 'models.dart'; +import 'mock_data.dart'; + +class DashboardPage extends StatelessWidget { + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouAuthPage( + userName: 'Johan', // TODO: get from auth state + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text('Dossiers', style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + 'Manage your health dossiers and those shared with you.', + style: InouText.intro, + ), + const SizedBox(height: 32), + + // Dossier grid + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 900 + ? 3 + : constraints.maxWidth > 600 + ? 2 + : 1; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.6, + ), + itemCount: mockDossiers.length + 1, // +1 for add card + itemBuilder: (context, index) { + if (index == mockDossiers.length) { + return _AddDossierCard(); + } + return _DossierCard(dossier: mockDossiers[index]); + }, + ); + }, + ), + + const SizedBox(height: 48), + const InouFooter(), + ], + ), + ), + ), + ), + ); + } +} + +class _DossierCard extends StatelessWidget { + final DossierSummary dossier; + + const _DossierCard({required this.dossier}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.go('/dossier/${dossier.id}'), + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row with name and edit + Row( + children: [ + Expanded( + child: Text( + dossier.name, + style: InouText.h3.copyWith(fontSize: 20), + overflow: TextOverflow.ellipsis, + ), + ), + if (dossier.canEdit) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + color: InouTheme.textMuted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + // TODO: navigate to edit + }, + ), + ], + ), + + // Relation/role + const SizedBox(height: 4), + Row( + children: [ + Text( + dossier.isSelf + ? 'you' + : 'my role: ${dossier.relation ?? "Unknown"}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + if (dossier.isCareReceiver) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.successLight, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'care', + style: InouText.bodySmall.copyWith( + color: InouTheme.success, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + + // DOB & Sex + const SizedBox(height: 8), + Text( + [ + if (dossier.dateOfBirth != null) dossier.dateOfBirth, + if (dossier.sex != null) dossier.sex, + ].join(' · '), + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + + const Spacer(), + + // Stats row + if (dossier.stats.hasAnyData) ...[ + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + if (dossier.stats.imaging > 0) + _StatChip( + '📷 ${dossier.stats.imaging} ${dossier.stats.imaging == 1 ? 'study' : 'studies'}', + ), + if (dossier.stats.labs > 0) + _StatChip( + '🧪 ${dossier.stats.labs} ${dossier.stats.labs == 1 ? 'lab' : 'labs'}', + ), + if (dossier.stats.genome) const _StatChip('🧬 genome'), + if (dossier.stats.documents > 0) + _StatChip( + '📄 ${dossier.stats.documents} ${dossier.stats.documents == 1 ? 'doc' : 'docs'}', + ), + if (dossier.stats.medications > 0) + _StatChip('💊 ${dossier.stats.medications} meds'), + if (dossier.stats.supplements > 0) + _StatChip('🌿 ${dossier.stats.supplements} supps'), + ], + ), + ] else ...[ + Text( + 'No data yet', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + + const SizedBox(height: 12), + + // View button + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: () => context.go('/dossier/${dossier.id}'), + ), + ], + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + final String text; + + const _StatChip(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +class _AddDossierCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + // TODO: navigate to add dossier + }, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: CustomPaint( + painter: _DashedBorderPainter(), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w300, + color: InouTheme.accent, + ), + ), + const SizedBox(height: 4), + Text( + 'Add dossier', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DashedBorderPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + // Empty - we use the container border for now + // Could implement dashed border if needed + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/features/dashboard/dossier_page.dart b/app/lib/features/dashboard/dossier_page.dart new file mode 100644 index 0000000..63b7e95 --- /dev/null +++ b/app/lib/features/dashboard/dossier_page.dart @@ -0,0 +1,810 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; +import 'models.dart'; +import 'mock_data.dart'; + +class DossierPage extends StatelessWidget { + final String dossierId; + + const DossierPage({super.key, required this.dossierId}); + + @override + Widget build(BuildContext context) { + final data = getDossierById(dossierId); + + if (data == null) { + return InouAuthPage( + userName: 'Johan', + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Dossier not found', style: InouText.sectionTitle), + const SizedBox(height: 16), + InouButton( + text: '← Back to dossiers', + variant: ButtonVariant.secondary, + onPressed: () => context.go('/dashboard'), + ), + ], + ), + ), + ); + } + + return InouAuthPage( + userName: 'Johan', + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: InouTheme.spaceXl, + vertical: InouTheme.spaceXxxl, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidthNarrow), // 800px per styleguide + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _DossierHeader(data: data), + const SizedBox(height: 32), + + // Data sections + _ImagingSection(studies: data.studies, dossierId: dossierId), + _LabsSection(labs: data.labs), + if (data.documents.isNotEmpty) _DocumentsSection(documents: data.documents), + if (data.procedures.isNotEmpty) _ProceduresSection(procedures: data.procedures), + if (data.assessments.isNotEmpty) _AssessmentsSection(assessments: data.assessments), + if (data.hasGenome) _GeneticsSection(categories: data.geneticCategories), + _UploadsSection(count: data.uploadCount, size: data.uploadSize, canEdit: data.canEdit), + if (data.medications.isNotEmpty) _MedicationsSection(medications: data.medications), + if (data.symptoms.isNotEmpty) _SymptomsSection(symptoms: data.symptoms), + if (data.hospitalizations.isNotEmpty) _HospitalizationsSection(hospitalizations: data.hospitalizations), + if (data.therapies.isNotEmpty) _TherapiesSection(therapies: data.therapies), + _VitalsSection(), // Coming soon + _PrivacySection(accessList: data.accessList, dossierId: dossierId, canManageAccess: data.canManageAccess), + + const SizedBox(height: 48), + const InouFooter(), + ], + ), + ), + ), + ), + ); + } +} + +class _DossierHeader extends StatelessWidget { + final DossierData data; + + const _DossierHeader({required this.data}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.dossier.name, style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + [ + if (data.dossier.dateOfBirth != null) 'Born: ${data.dossier.dateOfBirth}', + if (data.dossier.sex != null) data.dossier.sex, + ].join(' · '), + style: InouText.intro, + ), + ], + ), + ), + InouButton( + text: '← Back to dossiers', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () => context.go('/dashboard'), + ), + ], + ); + } +} + +// ============================================ +// DATA SECTION CARDS +// ============================================ + +class _ImagingSection extends StatelessWidget { + final List studies; + final String dossierId; + + const _ImagingSection({required this.studies, required this.dossierId}); + + @override + Widget build(BuildContext context) { + final totalSlices = studies.fold(0, (sum, s) => sum + s.series.fold(0, (ss, ser) => ss + ser.sliceCount)); + + return _DataCard( + title: 'IMAGING', + indicatorColor: InouTheme.indicatorImaging, + summary: studies.isEmpty + ? 'No imaging data' + : '${studies.length} studies, $totalSlices slices', + trailing: studies.isNotEmpty + ? InouButton( + text: 'Open viewer', + size: ButtonSize.small, + onPressed: () { + // TODO: open viewer + }, + ) + : null, + child: studies.isEmpty + ? null + : Column( + children: [ + for (var i = 0; i < studies.length && i < 5; i++) + _ImagingStudyRow(study: studies[i], dossierId: dossierId), + if (studies.length > 5) + _ShowMoreRow( + text: 'Show all ${studies.length} studies', + onTap: () { + // TODO: expand + }, + ), + ], + ), + ); + } +} + +class _ImagingStudyRow extends StatefulWidget { + final ImagingStudy study; + final String dossierId; + + const _ImagingStudyRow({required this.study, required this.dossierId}); + + @override + State<_ImagingStudyRow> createState() => _ImagingStudyRowState(); +} + +class _ImagingStudyRowState extends State<_ImagingStudyRow> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final hasSeries = widget.study.seriesCount > 1; + + return Column( + children: [ + InkWell( + onTap: hasSeries ? () => setState(() => _expanded = !_expanded) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + if (hasSeries) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + ) + else + const SizedBox(width: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.study.description, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + ), + if (hasSeries) + Text( + '${widget.study.seriesCount} series', + style: InouText.mono, + ), + const SizedBox(width: 16), + Text( + _formatDate(widget.study.date), + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 16, color: InouTheme.accent), + ], + ), + ), + ), + if (_expanded) + Container( + color: InouTheme.bg, + child: Column( + children: [ + for (final series in widget.study.series) + if (series.sliceCount > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const SizedBox(width: 32), + Expanded( + child: Text( + series.description ?? series.modality, + style: InouText.bodySmall, + ), + ), + Text( + '${series.sliceCount} ${series.sliceCount == 1 ? 'slice' : 'slices'}', + style: InouText.mono, + ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 14, color: InouTheme.accent), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + ], + ); + } + + String _formatDate(String yyyymmdd) { + if (yyyymmdd.length != 8) return yyyymmdd; + return '${yyyymmdd.substring(4, 6)}/${yyyymmdd.substring(6, 8)}/${yyyymmdd.substring(0, 4)}'; + } +} + +class _LabsSection extends StatelessWidget { + final List labs; + + const _LabsSection({required this.labs}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'LABS', + indicatorColor: InouTheme.indicatorLabs, + summary: labs.isEmpty ? 'No lab data' : '${labs.length} results', + child: labs.isEmpty + ? null + : Column( + children: [ + for (final lab in labs) _DataRow(item: lab), + ], + ), + ); + } +} + +class _DocumentsSection extends StatelessWidget { + final List documents; + + const _DocumentsSection({required this.documents}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'RECORDS', + indicatorColor: InouTheme.indicatorRecords, + summary: '${documents.length} documents', + child: Column( + children: [ + for (final doc in documents) _DataRow(item: doc, showType: true), + ], + ), + ); + } +} + +class _ProceduresSection extends StatelessWidget { + final List procedures; + + const _ProceduresSection({required this.procedures}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'PROCEDURES & SURGERY', + indicatorColor: InouTheme.danger, + summary: '${procedures.length} procedures', + child: Column( + children: [ + for (final proc in procedures) _DataRow(item: proc), + ], + ), + ); + } +} + +class _AssessmentsSection extends StatelessWidget { + final List assessments; + + const _AssessmentsSection({required this.assessments}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'CLINICAL ASSESSMENTS', + indicatorColor: const Color(0xFF7C3AED), + summary: '${assessments.length} assessments', + child: Column( + children: [ + for (final assessment in assessments) _DataRow(item: assessment), + ], + ), + ); + } +} + +class _GeneticsSection extends StatelessWidget { + final List categories; + + const _GeneticsSection({required this.categories}); + + @override + Widget build(BuildContext context) { + final totalShown = categories.fold(0, (sum, c) => sum + c.shown); + final totalHidden = categories.fold(0, (sum, c) => sum + c.hidden); + + return _DataCard( + title: 'GENETICS', + indicatorColor: InouTheme.indicatorGenetics, + summary: '$totalShown variants${totalHidden > 0 ? ' ($totalHidden hidden)' : ''}', + trailing: totalHidden > 0 + ? InouButton( + text: 'Show all', + size: ButtonSize.small, + onPressed: () { + // TODO: show warning modal + }, + ) + : null, + child: Column( + children: [ + for (final cat in categories.take(5)) + if (cat.shown > 0) + _GeneticCategoryRow(category: cat), + if (categories.length > 5) + _ShowMoreRow( + text: 'Show all ${categories.length} categories', + onTap: () {}, + ), + ], + ), + ); + } +} + +class _GeneticCategoryRow extends StatelessWidget { + final GeneticCategory category; + + const _GeneticCategoryRow({required this.category}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const SizedBox( + width: 20, + child: Text('+', style: TextStyle(color: InouTheme.textMuted)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + category.displayName, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + ), + Text( + '${category.shown} variants${category.hidden > 0 ? ' (${category.hidden} hidden)' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _UploadsSection extends StatelessWidget { + final int count; + final String size; + final bool canEdit; + + const _UploadsSection({required this.count, required this.size, required this.canEdit}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'UPLOADS', + indicatorColor: InouTheme.indicatorUploads, + summary: count == 0 ? 'No files' : '$count files, $size', + trailing: InouButton( + text: 'Manage', + size: ButtonSize.small, + variant: canEdit ? ButtonVariant.secondary : ButtonVariant.secondary, + onPressed: canEdit ? () {} : null, + ), + ); + } +} + +class _MedicationsSection extends StatelessWidget { + final List medications; + + const _MedicationsSection({required this.medications}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'MEDICATIONS', + indicatorColor: InouTheme.indicatorMedications, + summary: '${medications.length} medications', + child: Column( + children: [ + for (final med in medications) _DataRow(item: med), + ], + ), + ); + } +} + +class _SymptomsSection extends StatelessWidget { + final List symptoms; + + const _SymptomsSection({required this.symptoms}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'SYMPTOMS', + indicatorColor: const Color(0xFFF59E0B), + summary: '${symptoms.length} symptoms', + child: Column( + children: [ + for (final symptom in symptoms) _DataRow(item: symptom), + ], + ), + ); + } +} + +class _HospitalizationsSection extends StatelessWidget { + final List hospitalizations; + + const _HospitalizationsSection({required this.hospitalizations}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'HOSPITALIZATIONS', + indicatorColor: const Color(0xFFEF4444), + summary: '${hospitalizations.length} hospitalizations', + child: Column( + children: [ + for (final hosp in hospitalizations) _DataRow(item: hosp), + ], + ), + ); + } +} + +class _TherapiesSection extends StatelessWidget { + final List therapies; + + const _TherapiesSection({required this.therapies}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'THERAPIES', + indicatorColor: const Color(0xFF10B981), + summary: '${therapies.length} therapies', + child: Column( + children: [ + for (final therapy in therapies) _DataRow(item: therapy), + ], + ), + ); + } +} + +class _VitalsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'VITALS', + indicatorColor: InouTheme.indicatorVitals, + summary: 'Track blood pressure, weight, temperature, and more', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border.all(color: InouTheme.border), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'COMING SOON', + style: InouText.labelCaps.copyWith(color: InouTheme.textMuted), + ), + ), + comingSoon: true, + ); + } +} + +class _PrivacySection extends StatelessWidget { + final List accessList; + final String dossierId; + final bool canManageAccess; + + const _PrivacySection({ + required this.accessList, + required this.dossierId, + required this.canManageAccess, + }); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'PRIVACY', + indicatorColor: InouTheme.indicatorPrivacy, + summary: '${accessList.length} people with access', + child: Column( + children: [ + for (final access in accessList) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${access.name}${access.isSelf ? ' (you)' : ''}${access.isPending ? ' (pending)' : ''}', + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + Text( + '${access.relation}${access.canEdit ? ' · can edit' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (canManageAccess && !access.isSelf) ...[ + InouButton( + text: 'Edit', + size: ButtonSize.small, + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + const SizedBox(width: 8), + InouButton( + text: 'Remove', + size: ButtonSize.small, + variant: ButtonVariant.danger, + onPressed: () {}, + ), + ], + ], + ), + ), + // Privacy actions row + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + _PrivacyAction(text: 'Share access', onTap: () {}), + const SizedBox(width: 24), + if (canManageAccess) ...[ + _PrivacyAction(text: 'Manage permissions', onTap: () {}), + const SizedBox(width: 24), + ], + _PrivacyAction(text: 'View audit log', onTap: () {}), + const SizedBox(width: 24), + _PrivacyAction(text: 'Export data', onTap: () {}), + ], + ), + ), + ], + ), + ); + } +} + +class _PrivacyAction extends StatelessWidget { + final String text; + final VoidCallback onTap; + + const _PrivacyAction({required this.text, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Text( + text, + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ); + } +} + +// ============================================ +// SHARED WIDGETS +// ============================================ + +class _DataCard extends StatelessWidget { + final String title; + final Color indicatorColor; + final String summary; + final Widget? trailing; + final Widget? child; + final bool comingSoon; + + const _DataCard({ + required this.title, + required this.indicatorColor, + required this.summary, + this.trailing, + this.child, + this.comingSoon = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Opacity( + opacity: comingSoon ? 0.6 : 1.0, + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Indicator bar + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + // Title and summary + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.labelCaps.copyWith( + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 2), + Text( + summary, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + // Content + if (child != null) + Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: child, + ), + ], + ), + ), + ); + } +} + +class _DataRow extends StatelessWidget { + final DataItem item; + final bool showType; + + const _DataRow({required this.item, this.showType = false}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border, style: BorderStyle.solid)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.value, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + if (item.summary != null) + Text( + item.summary!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (showType && item.type != null) ...[ + Text( + item.type!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(width: 16), + ], + if (item.date != null) + Text( + item.date!, + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _ShowMoreRow extends StatelessWidget { + final String text; + final VoidCallback onTap; + + const _ShowMoreRow({required this.text, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Text( + text, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ), + ); + } +} diff --git a/app/lib/features/dashboard/mock_data.dart b/app/lib/features/dashboard/mock_data.dart new file mode 100644 index 0000000..d5e453e --- /dev/null +++ b/app/lib/features/dashboard/mock_data.dart @@ -0,0 +1,239 @@ +/// Mock data for testing - based on Anastasia's dossier (most categories) +import 'models.dart'; + +/// Mock dossiers for dashboard +final mockDossiers = [ + const DossierSummary( + id: 'self-001', + name: 'Johan Jongsma', + dateOfBirth: '1985-03-15', + sex: 'Male', + isSelf: true, + canEdit: true, + stats: DossierStats( + imaging: 3, + labs: 12, + genome: true, + documents: 2, + medications: 4, + supplements: 5, + ), + ), + const DossierSummary( + id: 'sophia-001', + name: 'Sophia', + dateOfBirth: '2017-01-01', + sex: 'Female', + relation: 'Parent', + isCareReceiver: true, + canEdit: true, + stats: DossierStats( + imaging: 16, + labs: 0, + ), + ), + const DossierSummary( + id: 'anastasia-001', + name: 'Anastasia', + dateOfBirth: '1990-07-22', + sex: 'Female', + relation: 'Demo', + canEdit: false, + stats: DossierStats( + imaging: 8, + labs: 24, + genome: true, + documents: 5, + vitals: 12, + medications: 7, + supplements: 3, + ), + ), +]; + +/// Full dossier data for Anastasia (richest example) +final mockAnastasiaDossier = DossierData( + dossier: mockDossiers[2], + canEdit: false, + canManageAccess: false, + hasGenome: true, + uploadCount: 12, + uploadSize: '847 MB', + studies: const [ + ImagingStudy( + id: 'study-001', + description: 'MRI BRAIN W/WO CONTRAST', + date: '20240315', + series: [ + ImagingSeries(id: 's1', description: 'AX T1', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's2', description: 'AX T2 FLAIR', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's3', description: 'AX DWI', modality: 'MR', sliceCount: 60), + ImagingSeries(id: 's4', description: 'SAG T1 POST', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's5', description: 'COR T2', modality: 'MR', sliceCount: 120), + ], + ), + ImagingStudy( + id: 'study-002', + description: 'CT CHEST W CONTRAST', + date: '20240210', + series: [ + ImagingSeries(id: 's6', description: 'AX LUNG', modality: 'CT', sliceCount: 250), + ImagingSeries(id: 's7', description: 'COR RECON', modality: 'CT', sliceCount: 120), + ], + ), + ImagingStudy( + id: 'study-003', + description: 'XR CHEST AP ONLY', + date: '20240115', + series: [ + ImagingSeries(id: 's8', modality: 'XR', sliceCount: 1), + ], + ), + ImagingStudy( + id: 'study-004', + description: 'MRI SPINE CERVICAL', + date: '20231201', + series: [ + ImagingSeries(id: 's9', description: 'SAG T1', modality: 'MR', sliceCount: 20), + ImagingSeries(id: 's10', description: 'SAG T2', modality: 'MR', sliceCount: 20), + ImagingSeries(id: 's11', description: 'AX T2', modality: 'MR', sliceCount: 60), + ], + ), + ImagingStudy( + id: 'study-005', + description: 'US THYROID', + date: '20231015', + series: [ + ImagingSeries(id: 's12', modality: 'US', sliceCount: 24), + ], + ), + ], + labs: const [ + DataItem(value: 'Complete Blood Count (CBC)', summary: '8 tests', date: '2024-03-10'), + DataItem(value: 'Comprehensive Metabolic Panel', summary: '14 tests', date: '2024-03-10'), + DataItem(value: 'Lipid Panel', summary: '4 tests', date: '2024-03-10'), + DataItem(value: 'Thyroid Panel', summary: 'TSH, T3, T4', date: '2024-02-15'), + DataItem(value: 'Vitamin D, 25-Hydroxy', summary: '38 ng/mL (normal)', date: '2024-02-15'), + DataItem(value: 'Hemoglobin A1c', summary: '5.2% (normal)', date: '2024-01-20'), + ], + documents: const [ + DataItem(value: 'Discharge Summary', type: 'PDF', date: '2024-03-16'), + DataItem(value: 'Radiology Report - Brain MRI', type: 'PDF', date: '2024-03-15'), + DataItem(value: 'Lab Results Summary', type: 'PDF', date: '2024-03-10'), + DataItem(value: 'Insurance Authorization', type: 'PDF', date: '2024-02-01'), + DataItem(value: 'Referral Letter', type: 'PDF', date: '2024-01-15'), + ], + procedures: const [ + DataItem(value: 'Lumbar Puncture', summary: 'Diagnostic CSF analysis', date: '2024-02-20'), + DataItem(value: 'Thyroid Biopsy', summary: 'Fine needle aspiration', date: '2023-10-18'), + ], + assessments: const [ + DataItem(value: 'Neurological Exam', summary: 'Normal findings', date: '2024-03-15'), + DataItem(value: 'Cognitive Assessment', summary: 'MMSE 29/30', date: '2024-02-25'), + DataItem(value: 'Physical Therapy Eval', summary: 'Mild cervical dysfunction', date: '2024-01-10'), + ], + medications: const [ + DataItem(value: 'Levothyroxine', summary: '50 mcg daily', date: '2024-01-01'), + DataItem(value: 'Vitamin D3', summary: '5000 IU daily', date: '2024-01-01'), + DataItem(value: 'Magnesium Glycinate', summary: '400 mg evening', date: '2024-01-01'), + DataItem(value: 'Omega-3 Fish Oil', summary: '2000 mg daily', date: '2024-01-01'), + DataItem(value: 'Gabapentin', summary: '300 mg PRN (discontinued)', date: '2023-06-01'), + ], + symptoms: const [ + DataItem(value: 'Headache', summary: 'Tension-type, intermittent', date: '2024-03-01'), + DataItem(value: 'Neck stiffness', summary: 'Morning, improves with movement', date: '2024-02-15'), + DataItem(value: 'Fatigue', summary: 'Mild, afternoon slump', date: '2024-01-20'), + ], + hospitalizations: const [ + DataItem(value: 'Johns Hopkins Hospital', summary: 'Observation - headache evaluation', date: '2024-02-20'), + DataItem(value: 'Local ER', summary: 'Chest pain workup - negative', date: '2023-08-15'), + ], + therapies: const [ + DataItem(value: 'Physical Therapy', summary: 'Cervical spine - 8 sessions completed', date: '2024-03-01'), + DataItem(value: 'Massage Therapy', summary: 'Bi-weekly maintenance', date: '2024-01-15'), + DataItem(value: 'Acupuncture', summary: 'Trial - 4 sessions', date: '2023-11-01'), + ], + geneticCategories: const [ + GeneticCategory(name: 'traits', shown: 12, hidden: 45), + GeneticCategory(name: 'metabolism', shown: 8, hidden: 23), + GeneticCategory(name: 'medication', shown: 15, hidden: 32), + GeneticCategory(name: 'cardiovascular', shown: 6, hidden: 18), + GeneticCategory(name: 'neurological', shown: 4, hidden: 12), + GeneticCategory(name: 'longevity', shown: 3, hidden: 8), + GeneticCategory(name: 'autoimmune', shown: 2, hidden: 5), + GeneticCategory(name: 'cancer', shown: 1, hidden: 4), + ], + accessList: const [ + AccessEntry( + dossierID: 'anastasia-001', + name: 'Anastasia', + relation: 'Owner', + canEdit: true, + isSelf: true, + ), + AccessEntry( + dossierID: 'johan-001', + name: 'Johan Jongsma', + relation: 'Demo access', + canEdit: false, + ), + ], +); + +/// Dossier data for Sophia +final mockSophiaDossier = DossierData( + dossier: mockDossiers[1], + canEdit: true, + canManageAccess: true, + hasGenome: false, + uploadCount: 156, + uploadSize: '12.4 GB', + studies: const [ + ImagingStudy( + id: 'sophia-study-001', + description: 'MRI BRAIN W/WO CONTRAST', + date: '20220505', + series: [ + ImagingSeries(id: 'ss1', description: 'AX T1', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 'ss2', description: 'AX T2 FLAIR', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 'ss3', description: 'AX SWI', modality: 'MR', sliceCount: 120), + ImagingSeries(id: 'ss4', description: 'AX DWI', modality: 'MR', sliceCount: 60), + ImagingSeries(id: 'ss5', description: 'SAG T1', modality: 'MR', sliceCount: 180), + ], + ), + ImagingStudy( + id: 'sophia-study-002', + description: 'CT HEAD W/O CONTRAST', + date: '20220502', + series: [ + ImagingSeries(id: 'ss6', description: 'AX', modality: 'CT', sliceCount: 40), + ], + ), + ], + accessList: const [ + AccessEntry( + dossierID: 'sophia-001', + name: 'Sophia', + relation: 'Self', + isSelf: true, + ), + AccessEntry( + dossierID: 'johan-001', + name: 'Johan Jongsma', + relation: 'Parent', + canEdit: true, + ), + ], +); + +/// Get dossier data by ID +DossierData? getDossierById(String id) { + switch (id) { + case 'anastasia-001': + return mockAnastasiaDossier; + case 'sophia-001': + return mockSophiaDossier; + default: + return null; + } +} diff --git a/app/lib/features/dashboard/models.dart b/app/lib/features/dashboard/models.dart new file mode 100644 index 0000000..dd16600 --- /dev/null +++ b/app/lib/features/dashboard/models.dart @@ -0,0 +1,197 @@ +/// Data models for dashboard and dossier pages + +class DossierStats { + final int imaging; + final int labs; + final bool genome; + final int documents; + final int vitals; + final int medications; + final int supplements; + + const DossierStats({ + this.imaging = 0, + this.labs = 0, + this.genome = false, + this.documents = 0, + this.vitals = 0, + this.medications = 0, + this.supplements = 0, + }); + + bool get hasAnyData => + imaging > 0 || + labs > 0 || + genome || + documents > 0 || + vitals > 0 || + medications > 0 || + supplements > 0; +} + +class DossierSummary { + final String id; + final String name; + final String? dateOfBirth; + final String? sex; + final String? relation; + final bool isSelf; + final bool isCareReceiver; + final bool canEdit; + final DossierStats stats; + + const DossierSummary({ + required this.id, + required this.name, + this.dateOfBirth, + this.sex, + this.relation, + this.isSelf = false, + this.isCareReceiver = false, + this.canEdit = false, + this.stats = const DossierStats(), + }); +} + +class DataItem { + final String value; + final String? summary; + final String? date; + final String? type; + + const DataItem({ + required this.value, + this.summary, + this.date, + this.type, + }); +} + +class ImagingSeries { + final String id; + final String? description; + final String modality; + final int sliceCount; + + const ImagingSeries({ + required this.id, + this.description, + required this.modality, + required this.sliceCount, + }); +} + +class ImagingStudy { + final String id; + final String description; + final String date; + final List series; + + const ImagingStudy({ + required this.id, + required this.description, + required this.date, + required this.series, + }); + + int get seriesCount => series.length; +} + +class GeneticVariant { + final String rsid; + final String? gene; + final String genotype; + final double? magnitude; + final String? summary; + final String category; + + const GeneticVariant({ + required this.rsid, + this.gene, + required this.genotype, + this.magnitude, + this.summary, + required this.category, + }); + + String get formattedAllele { + final letters = genotype.replaceAll(';', ''); + return letters.split('').join(';'); + } +} + +class GeneticCategory { + final String name; + final int shown; + final int hidden; + + const GeneticCategory({ + required this.name, + required this.shown, + this.hidden = 0, + }); + + int get total => shown + hidden; + + String get displayName => + name.substring(0, 1).toUpperCase() + + name.substring(1).replaceAll('_', ' '); +} + +class AccessEntry { + final String dossierID; + final String name; + final String relation; + final bool canEdit; + final bool isSelf; + final bool isPending; + + const AccessEntry({ + required this.dossierID, + required this.name, + required this.relation, + this.canEdit = false, + this.isSelf = false, + this.isPending = false, + }); +} + +class DossierData { + final DossierSummary dossier; + final List studies; + final List labs; + final List documents; + final List procedures; + final List assessments; + final List medications; + final List symptoms; + final List hospitalizations; + final List therapies; + final List geneticCategories; + final List accessList; + final int uploadCount; + final String uploadSize; + final bool hasGenome; + final bool canEdit; + final bool canManageAccess; + + const DossierData({ + required this.dossier, + this.studies = const [], + this.labs = const [], + this.documents = const [], + this.procedures = const [], + this.assessments = const [], + this.medications = const [], + this.symptoms = const [], + this.hospitalizations = const [], + this.therapies = const [], + this.geneticCategories = const [], + this.accessList = const [], + this.uploadCount = 0, + this.uploadSize = '0 KB', + this.hasGenome = false, + this.canEdit = false, + this.canManageAccess = false, + }); +} diff --git a/app/lib/features/static/connect_page.dart b/app/lib/features/static/connect_page.dart new file mode 100644 index 0000000..27753ac --- /dev/null +++ b/app/lib/features/static/connect_page.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Connect/Contact page +class ConnectPage extends StatefulWidget { + const ConnectPage({super.key}); + + @override + State createState() => _ConnectPageState(); +} + +class _ConnectPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _messageController = TextEditingController(); + String _selectedTopic = 'General inquiry'; + bool _isSubmitting = false; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/connect', + maxWidth: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Get in touch', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Have a question, feedback, or just want to say hello? We\'d love to hear from you.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InouTextField( + label: 'Name', + controller: _nameController, + placeholder: 'Your name', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 20), + + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 20), + + InouSelect( + label: 'Topic', + value: _selectedTopic, + options: const [ + InouSelectOption(value: 'General inquiry', label: 'General inquiry'), + InouSelectOption(value: 'Technical support', label: 'Technical support'), + InouSelectOption(value: 'Privacy question', label: 'Privacy question'), + InouSelectOption(value: 'Partnership', label: 'Partnership'), + InouSelectOption(value: 'Press', label: 'Press'), + InouSelectOption(value: 'Other', label: 'Other'), + ], + onChanged: (value) { + if (value != null) { + setState(() => _selectedTopic = value); + } + }, + ), + const SizedBox(height: 20), + + InouTextField( + label: 'Message', + controller: _messageController, + placeholder: 'How can we help?', + maxLines: 5, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a message'; + } + return null; + }, + ), + const SizedBox(height: 32), + + InouButton( + text: _isSubmitting ? 'Sending...' : 'Send message', + onPressed: _isSubmitting ? null : _handleSubmit, + ), + ], + ), + ), + + const SizedBox(height: 64), + + // Direct contact info + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Prefer email?', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'Reach us directly at:', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 12), + _buildEmailLink('hello@inou.com', 'General inquiries'), + const SizedBox(height: 8), + _buildEmailLink('support@inou.com', 'Technical support'), + const SizedBox(height: 8), + _buildEmailLink('privacy@inou.com', 'Privacy questions'), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildEmailLink(String email, String label) { + return Row( + children: [ + Text( + email, + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Text( + '— $label', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ); + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSubmitting = true); + + // TODO: Implement actual form submission + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() => _isSubmitting = false); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Message sent! We\'ll get back to you soon.'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ); + + _nameController.clear(); + _emailController.clear(); + _messageController.clear(); + } + } +} diff --git a/app/lib/features/static/dpa_page.dart b/app/lib/features/static/dpa_page.dart new file mode 100644 index 0000000..98494ba --- /dev/null +++ b/app/lib/features/static/dpa_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Data Processing Agreement page +class DpaPage extends StatelessWidget { + const DpaPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/dpa', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Data Processing Agreement', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Last updated: January 2026', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + + const SizedBox(height: 48), + + _buildSection( + '1. Introduction', + 'This Data Processing Agreement ("DPA") forms part of the Terms of Service between inou health ("Processor", "we", "us") and the user ("Controller", "you") for the processing of personal data, including health data, as defined under applicable data protection laws.', + ), + + _buildSection( + '2. Definitions', + '''• "Personal Data" means any information relating to an identified or identifiable natural person. +• "Health Data" means personal data related to the physical or mental health of a natural person, including the provision of health care services. +• "Processing" means any operation performed on personal data, including collection, storage, use, and deletion. +• "Sub-processor" means any third party engaged by us to process personal data on your behalf.''', + ), + + _buildSection( + '3. Scope and Purpose', + 'We process your personal data and health data solely for the purpose of providing our services: secure storage, organization, and AI-assisted analysis of your health information. We act as a Processor on your behalf; you remain the Controller of your data.', + ), + + _buildSection( + '4. Data Processing Obligations', + '''We shall: +• Process personal data only on your documented instructions +• Ensure persons authorized to process the data are bound by confidentiality +• Implement appropriate technical and organizational security measures +• Assist you in responding to data subject requests +• Delete or return all personal data upon termination of services +• Make available all information necessary to demonstrate compliance''', + ), + + _buildSection( + '5. Security Measures', + '''We implement the following security measures: +• FIPS 140-3 compliant encryption at rest and in transit +• TLS 1.3 for all data transmission +• Dedicated single-tenant infrastructure +• Role-based access control +• Continuous monitoring and intrusion detection +• Regular security assessments and penetration testing''', + ), + + _buildSection( + '6. Sub-processors', + 'We may engage sub-processors to assist in providing our services. A current list of sub-processors is available upon request. We will notify you of any intended changes to sub-processors, giving you the opportunity to object.', + ), + + _buildSection( + '7. International Transfers', + 'Your data is stored and processed in the United States. For transfers to third countries, we ensure appropriate safeguards are in place, including Standard Contractual Clauses where required.', + ), + + _buildSection( + '8. Data Subject Rights', + 'We will assist you in fulfilling your obligations to respond to data subject requests for access, rectification, erasure, restriction, portability, and objection. Contact privacy@inou.com for assistance.', + ), + + _buildSection( + '9. Data Breach Notification', + 'We will notify you without undue delay (and in any event within 72 hours) upon becoming aware of a personal data breach affecting your data, and will provide all information necessary for you to meet your notification obligations.', + ), + + _buildSection( + '10. Term and Termination', + 'This DPA remains in effect for the duration of our processing of your data. Upon termination, we will delete or return all personal data and certify such deletion, unless legally required to retain certain data.', + ), + + const SizedBox(height: 48), + + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contact', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'For questions about this DPA or to exercise your rights, contact:\nprivacy@inou.com', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + height: 1.6, + ), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSection(String title, String content) { + return Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.h3, + ), + const SizedBox(height: 12), + Text( + content, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/static/faq_page.dart b/app/lib/features/static/faq_page.dart new file mode 100644 index 0000000..6096128 --- /dev/null +++ b/app/lib/features/static/faq_page.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// FAQ page +class FaqPage extends StatelessWidget { + const FaqPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/faq', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Frequently Asked Questions', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Everything you need to know about inou.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + ..._buildFaqItems(), + + const SizedBox(height: 48), + + // Contact CTA + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Still have questions?', + style: InouText.h3, + ), + const SizedBox(height: 4), + Text( + 'We\'re here to help.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + InouButton( + text: 'Contact us', + variant: ButtonVariant.secondary, + onPressed: () => Navigator.pushNamed(context, '/connect'), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + List _buildFaqItems() { + final faqs = [ + _FaqItem( + question: 'What is inou?', + answer: 'inou is a secure platform that organizes all your health data — medical images, lab results, genetic information, vitals, and more — in one place. It then connects securely with AI to help you understand your health better than ever before.', + ), + _FaqItem( + question: 'Is my data safe?', + answer: 'Absolutely. Your data is encrypted with FIPS 140-3 compliant encryption at rest and in transit. We run on dedicated hardware (not shared cloud), and we never sell, share, or use your data to train AI models. See our Security page for complete details.', + ), + _FaqItem( + question: 'What file formats do you support?', + answer: 'We support DICOM files (medical imaging), PDF (lab reports, medical records), CSV (lab data, health exports), VCF (genetic data from 23andMe, Ancestry, etc.), and common image formats for documents.', + ), + _FaqItem( + question: 'How does the AI integration work?', + answer: 'You control when and how your data is shared with AI. When you choose to connect, your data is transmitted securely to the AI provider you select. The AI can then analyze your complete health picture and provide insights no single specialist could.', + ), + _FaqItem( + question: 'Can I share my data with my doctor?', + answer: 'Yes. You can generate secure, time-limited sharing links for any part of your health dossier. Your doctor gets read-only access to exactly what you choose to share.', + ), + _FaqItem( + question: 'What does it cost?', + answer: 'Basic storage and organization is free. Premium features like advanced AI analysis, family accounts, and priority support are available with a subscription. See our pricing page for current plans.', + ), + _FaqItem( + question: 'Can I delete my data?', + answer: 'Yes, completely. You can delete individual files or your entire account at any time. When you delete, the data is permanently removed from our systems — no backups retained, no recovery possible.', + ), + _FaqItem( + question: 'Do you support family accounts?', + answer: 'Yes. You can create dossiers for family members you care for — children, elderly parents, anyone who needs an advocate. Each dossier is separate and secure.', + ), + ]; + + return faqs.map((faq) => _FaqExpansionTile(faq: faq)).toList(); + } +} + +class _FaqItem { + final String question; + final String answer; + + const _FaqItem({ + required this.question, + required this.answer, + }); +} + +class _FaqExpansionTile extends StatefulWidget { + final _FaqItem faq; + + const _FaqExpansionTile({required this.faq}); + + @override + State<_FaqExpansionTile> createState() => _FaqExpansionTileState(); +} + +class _FaqExpansionTileState extends State<_FaqExpansionTile> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + title: Text( + widget.faq.question, + style: InouText.body.copyWith( + fontWeight: FontWeight.w500, + ), + ), + trailing: AnimatedRotation( + turns: _isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.keyboard_arrow_down, + color: InouTheme.textMuted, + ), + ), + onExpansionChanged: (expanded) { + setState(() => _isExpanded = expanded); + }, + children: [ + Text( + widget.faq.answer, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + height: 1.7, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/static/invite_page.dart b/app/lib/features/static/invite_page.dart new file mode 100644 index 0000000..11043e1 --- /dev/null +++ b/app/lib/features/static/invite_page.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Invite a friend page +class InvitePage extends StatefulWidget { + const InvitePage({super.key}); + + @override + State createState() => _InvitePageState(); +} + +class _InvitePageState extends State { + final _emailController = TextEditingController(); + final List _invitedEmails = []; + bool _isSending = false; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // TODO: Check if user is logged in, if not show signup prompt + final isLoggedIn = false; // Placeholder + + return InouPage( + currentRoute: '/invite', + maxWidth: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Invite a friend', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Know someone who could benefit from understanding their health better? Invite them to inou.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + if (!isLoggedIn) ...[ + _buildSignupPrompt(context), + ] else ...[ + _buildInviteForm(), + ], + + const SizedBox(height: 48), + + // Why invite section + _buildWhyInvite(), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSignupPrompt(BuildContext context) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + children: [ + Icon( + Icons.person_add_outlined, + size: 48, + color: InouTheme.accent, + ), + const SizedBox(height: 16), + Text( + 'Sign in to invite friends', + style: InouText.h3, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Create an account or sign in to share inou with people you care about.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InouButton( + text: 'Sign up', + onPressed: () => Navigator.pushNamed(context, '/signup'), + ), + const SizedBox(width: 12), + InouButton( + text: 'Log in', + variant: ButtonVariant.secondary, + onPressed: () => Navigator.pushNamed(context, '/login'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildInviteForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Email input + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: InouTextField( + label: 'Friend\'s email', + controller: _emailController, + placeholder: 'friend@example.com', + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(width: 12), + InouButton( + text: _isSending ? 'Sending...' : 'Send invite', + onPressed: _isSending ? null : _handleSendInvite, + ), + ], + ), + + const SizedBox(height: 24), + + // Share link + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: InouTheme.borderRadiusMd, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Or share your invite link', + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 4), + Text( + 'https://inou.com/join/abc123', + style: InouText.body.copyWith( + fontFamily: 'SF Mono', + fontSize: 13, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + color: InouTheme.accent, + onPressed: () { + Clipboard.setData(const ClipboardData( + text: 'https://inou.com/join/abc123', + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Link copied!'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + ), + + // Sent invites + if (_invitedEmails.isNotEmpty) ...[ + const SizedBox(height: 32), + Text( + 'Invites sent', + style: InouText.h3, + ), + const SizedBox(height: 12), + ...(_invitedEmails.map((email) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: 16, + color: InouTheme.success, + ), + const SizedBox(width: 8), + Text(email, style: InouText.body), + ], + ), + ))), + ], + ], + ); + } + + Widget _buildWhyInvite() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why invite?', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + _buildBenefitItem( + Icons.favorite_outline, + 'Help someone you care about', + 'Whether they\'re managing a chronic condition, caring for a family member, or just want to understand their health better — inou can help.', + ), + const SizedBox(height: 20), + _buildBenefitItem( + Icons.people_outline, + 'Build your health circle', + 'When family members use inou, sharing relevant health information becomes seamless and secure.', + ), + const SizedBox(height: 20), + _buildBenefitItem( + Icons.card_giftcard_outlined, + 'Early access perks', + 'Friends you invite get priority access to new features and may qualify for special pricing.', + ), + ], + ); + } + + Widget _buildBenefitItem(IconData icon, String title, String description) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: InouTheme.accentLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: InouTheme.accent, size: 20), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: InouText.h3.copyWith(fontSize: 16)), + const SizedBox(height: 4), + Text( + description, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.6, + ), + ), + ], + ), + ), + ], + ); + } + + Future _handleSendInvite() async { + final email = _emailController.text.trim(); + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please enter a valid email address'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + setState(() => _isSending = true); + + // TODO: Implement actual invite sending + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isSending = false; + _invitedEmails.add(email); + }); + _emailController.clear(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invite sent to $email!'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} diff --git a/app/lib/features/static/landing_page.dart b/app/lib/features/static/landing_page.dart new file mode 100644 index 0000000..2dadadb --- /dev/null +++ b/app/lib/features/static/landing_page.dart @@ -0,0 +1,700 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Landing page - main marketing page matching Go version layout +class LandingPage extends StatelessWidget { + const LandingPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + + // Hero card + _HeroCard(), + + // "You need AI for your health" card + _YouNeedAiCard(), + + // "The challenge" card + _ChallengeCard(), + + // "Why we built this" card + _WhyCard(), + + // Trust section + _TrustSection(), + + const SizedBox(height: 24), + ], + ), + ); + } +} + +/// Landing card wrapper - matches Go .landing-card +class _LandingCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + + const _LandingCard({required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: padding ?? const EdgeInsets.all(48), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + } +} + +/// Hero card - centered text with CTA +class _HeroCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + children: [ + // Hero answer text + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + children: [ + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan( + text: ' ${l10n.heroAnswer.replaceFirst('inou ', '')}', + ), + ], + ), + ), + + const SizedBox(height: 32), + + // Tagline + Text( + l10n.heroTagline, + textAlign: TextAlign.center, + style: InouText.body.copyWith( + fontSize: isMobile ? 20 : 21, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 32), + + // CTA Button + ElevatedButton( + onPressed: () => context.go('/signup'), + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 40 : 56, + vertical: 18, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text( + l10n.signIn.toUpperCase(), + style: InouText.button.copyWith( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 1.28, + ), + ), + ), + ], + ), + ); + } +} + +/// "You need AI for your health" card +class _YouNeedAiCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.youNeedAiTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 32), + + // Warm prose paragraphs + _WarmProse(text: l10n.youNeedAiPara1), + const SizedBox(height: 20), + _WarmProse(text: l10n.youNeedAiPara2), + const SizedBox(height: 20), + + // Emphasis: "But you..." + RichText( + text: TextSpan( + style: _warmProseStyle(context), + children: [ + const TextSpan(text: 'But '), + TextSpan( + text: 'you', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + ), + ), + TextSpan(text: ' ${l10n.youNeedAiPara3.replaceFirst('But you ', '')}'), + ], + ), + ), + + const SizedBox(height: 20), + + // Final emphasis with inou + RichText( + text: TextSpan( + style: _warmProseStyle(context).copyWith( + fontWeight: FontWeight.w600, + fontSize: 18.4, + ), + children: [ + TextSpan(text: l10n.youNeedAiEmphasis.split('inou')[0]), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan(text: l10n.youNeedAiEmphasis.split('inou').last), + ], + ), + ), + ], + ), + ); + } +} + +/// "The challenge" card with data/reality pairs +class _ChallengeCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.challengeTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 24), + + // Data/Reality pairs + _DataRealityPair(data: l10n.challengeMri, reality: l10n.challengeMriReality), + _DataRealityPair(data: l10n.challengeGenome, reality: l10n.challengeGenomeReality), + _DataRealityPair(data: l10n.challengeBlood, reality: l10n.challengeBloodReality), + _DataRealityPair(data: l10n.challengeWatch, reality: l10n.challengeWatchReality), + _DataRealityPair(data: l10n.challengeSupplements, reality: l10n.challengeSupplementsReality), + + // Transition + Container( + margin: const EdgeInsets.symmetric(vertical: 32), + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border), + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Text( + l10n.challengeTransition, + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + ), + ), + + // Gaps section + _GapsSection(l10n: l10n), + + const SizedBox(height: 32), + + // Connections + _ConnectionsSection(l10n: l10n), + + const SizedBox(height: 32), + + // AI section + _AiSection(l10n: l10n), + + const SizedBox(height: 32), + + // Closing + _ClosingText(text: l10n.challengeClosing), + ], + ), + ); + } +} + +/// "Why we built this" card +class _WhyCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.whyTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 24), + + // Prose paragraphs + _StoryProse(text: l10n.whyPara1), + _StoryProse(text: l10n.whyPara2), + _StoryProse(text: l10n.whyPara3), + _StoryProse(text: l10n.whyPara4), + + // AI finally can - emphasis + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + l10n.whyPara5, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.8, + color: InouTheme.text, + ), + ), + ), + + _StoryProse(text: l10n.whyPara6), + + // inou paragraph + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ), + children: [ + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan(text: ' ${l10n.whyPara7.replaceFirst('inou ', '')}'), + ], + ), + ), + ), + + // Closing + Container( + padding: const EdgeInsets.only(top: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border), + ), + ), + child: Text( + l10n.whyClosing, + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ), + ), + ), + ], + ), + ); + } +} + +/// Trust section - 4-column grid +class _TrustSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 600; + final isTablet = screenWidth < 900 && screenWidth >= 600; + + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: EdgeInsets.all(isMobile ? 24 : 48), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Text( + l10n.trustLabel.toUpperCase(), + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + letterSpacing: 0.6, + ), + ), + + const SizedBox(height: 24), + + // Trust grid + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = isMobile ? 1 : (isTablet ? 2 : 4); + + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: isMobile ? 20 : 32, + crossAxisSpacing: 32, + childAspectRatio: isMobile ? 4 : (isTablet ? 2.5 : 1.5), + children: [ + _TrustItem( + title: l10n.neverTraining, + description: l10n.neverTrainingDesc, + ), + _TrustItem( + title: l10n.neverShared, + description: l10n.neverSharedDesc, + ), + _TrustItem( + title: l10n.encrypted, + description: l10n.encryptedDesc, + ), + _TrustItem( + title: l10n.deleteAnytime, + description: l10n.deleteAnytimeDesc, + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +// ============================================================================= +// Helper widgets +// ============================================================================= + +TextStyle _warmProseStyle(BuildContext context) { + return InouText.body.copyWith( + fontSize: 17.6, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.text, + ); +} + +class _WarmProse extends StatelessWidget { + final String text; + const _WarmProse({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: _warmProseStyle(context), + ); + } +} + +class _StoryProse extends StatelessWidget { + final String text; + const _StoryProse({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + text, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ), + ), + ); + } +} + +class _DataRealityPair extends StatelessWidget { + final String data; + final String reality; + + const _DataRealityPair({required this.data, required this.reality}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data, + style: InouText.body.copyWith( + fontSize: 17.6, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ), + ), + const SizedBox(height: 4), + Text( + reality, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + fontStyle: FontStyle.italic, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _GapsSection extends StatelessWidget { + final AppLocalizations l10n; + const _GapsSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.challengeGap1, + style: _gapsStyle(), + ), + Padding( + padding: const EdgeInsets.only(left: 16, top: 4, bottom: 8), + child: Text( + l10n.challengeGap1Indent, + style: _gapsStyle().copyWith(fontStyle: FontStyle.italic), + ), + ), + Text(l10n.challengeGap2, style: _gapsStyle()), + const SizedBox(height: 8), + Text(l10n.challengeGap3, style: _gapsStyle()), + ], + ); + } + + TextStyle _gapsStyle() => InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); +} + +class _ConnectionsSection extends StatelessWidget { + final AppLocalizations l10n; + const _ConnectionsSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + final style = InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.challengeConnection1, style: style), + Text(l10n.challengeConnection2, style: style), + Text(l10n.challengeConnection3, style: style), + ], + ); + } +} + +class _AiSection extends StatelessWidget { + final AppLocalizations l10n; + const _AiSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + final style = InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.challengeAi1, style: style), + Text(l10n.challengeAi2, style: style), + Text(l10n.challengeAi3, style: style), + Text( + l10n.challengeAi4, + style: style.copyWith(fontStyle: FontStyle.italic), + ), + ], + ); + } +} + +class _ClosingText extends StatelessWidget { + final String text; + const _ClosingText({required this.text}); + + @override + Widget build(BuildContext context) { + // Split at 'inou' to style it + final parts = text.split('inou'); + + return RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + children: [ + if (parts.isNotEmpty) TextSpan(text: parts[0]), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + if (parts.length > 1) TextSpan(text: parts[1]), + ], + ), + ); + } +} + +class _TrustItem extends StatelessWidget { + final String title; + final String description; + + const _TrustItem({required this.title, required this.description}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.body.copyWith( + fontSize: 14.4, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + const SizedBox(height: 4), + Expanded( + child: Text( + description, + style: InouText.body.copyWith( + fontSize: 14.4, + fontWeight: FontWeight.w300, + height: 1.6, + color: InouTheme.textMuted, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/static/privacy_page.dart b/app/lib/features/static/privacy_page.dart new file mode 100644 index 0000000..fae432b --- /dev/null +++ b/app/lib/features/static/privacy_page.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Privacy policy page +class PrivacyPage extends StatelessWidget { + const PrivacyPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/privacy', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Your data. Your rules.', + style: InouText.pageTitle, + ), + const SizedBox(height: 24), + RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontWeight: FontWeight.w300, + color: InouTheme.textMuted, + height: 1.8, + ), + children: [ + const TextSpan(text: 'We built '), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + const TextSpan( + text: ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + ), + ], + ), + ), + + const SizedBox(height: 64), + + _buildSection( + 'What we collect', + [ + _PolicyItem( + title: 'Account information.', + content: 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + ), + _PolicyItem( + title: 'Health data you upload.', + content: 'Medical images (DICOM files), lab results (PDF, CSV), genetic data (VCF files from 23andMe, etc.), and any notes or vitals you choose to add. We only have what you give us.', + ), + _PolicyItem( + title: 'Usage information.', + content: 'Basic analytics to understand how the service is used and improve it. We don\'t track you across the web or sell your data to advertisers.', + ), + ], + ), + + const SizedBox(height: 48), + + _buildSection( + 'What we never do', + [ + _PolicyItem( + title: 'Sell your data.', + content: 'Your health information is not a product. We don\'t sell it, rent it, or share it with data brokers. Period.', + ), + _PolicyItem( + title: 'Train AI on your data.', + content: 'Your medical records don\'t train our models or anyone else\'s. Your data is yours alone.', + ), + _PolicyItem( + title: 'Share without consent.', + content: 'We don\'t share your health data with anyone unless you explicitly authorize it — not employers, not insurers, not advertisers.', + ), + ], + ), + + const SizedBox(height: 48), + + _buildSection( + 'Your rights', + [ + _PolicyItem( + title: 'Access your data.', + content: 'You can download everything you\'ve uploaded at any time, in standard formats.', + ), + _PolicyItem( + title: 'Delete your data.', + content: 'Request deletion and we\'ll remove your data from our systems. When you leave, your data leaves with you.', + ), + _PolicyItem( + title: 'Know what we have.', + content: 'You can request a full accounting of all data we store about you.', + ), + ], + ), + + const SizedBox(height: 48), + + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Questions?', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'If you have questions about our privacy practices, contact us at privacy@inou.com', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSection(String title, List<_PolicyItem> items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + ...items.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + item.content, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + )).toList(), + ], + ); + } +} + +class _PolicyItem { + final String title; + final String content; + + const _PolicyItem({ + required this.title, + required this.content, + }); +} diff --git a/app/lib/features/static/security_page.dart b/app/lib/features/static/security_page.dart new file mode 100644 index 0000000..0b07d6d --- /dev/null +++ b/app/lib/features/static/security_page.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Security page - matches inou.com/security +class SecurityPage extends StatelessWidget { + const SecurityPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/security', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + // Hero + Text( + 'How we protect your health dossier.', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Security isn\'t a feature we added. It\'s how we built inou from day one.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + fontSize: 18, + ), + ), + + const SizedBox(height: 64), + + // Security features + ..._buildSecurityFeatures(), + + const SizedBox(height: 48), + ], + ), + ); + } + + List _buildSecurityFeatures() { + final features = [ + _SecurityFeature( + icon: Icons.dns_outlined, + title: 'Your data never shares a server.', + description: 'Most services run on shared cloud infrastructure — your files sitting next to thousands of strangers. Not here. inou runs on dedicated, single-tenant hardware. Your data lives on machines that exist solely for this purpose.', + ), + _SecurityFeature( + icon: Icons.lock_outline, + title: 'Encryption you can trust.', + description: 'FIPS 140-3 is the US government standard for cryptographic security — the same bar the military uses. Your files are encrypted in flight with TLS 1.3, encrypted again at the application layer before they touch the database, and stay encrypted at rest. Three layers deep.', + ), + _SecurityFeature( + icon: Icons.power_outlined, + title: 'Power doesn\'t go out.', + description: 'Servers run on uninterruptible power, backed by a natural gas generator. Not a battery that buys you fifteen minutes — a generator with fuel supply independent of the grid. If the power company fails, we don\'t.', + ), + _SecurityFeature( + icon: Icons.storage_outlined, + title: 'Drives fail. Data doesn\'t.', + description: 'Storage runs on ZFS with RAID-Z2 — enterprise technology that survives the simultaneous failure of any two drives without losing a byte. Backups happen automatically. (Our founder spent two decades building backup systems for a living. We take this seriously.)', + ), + _SecurityFeature( + icon: Icons.satellite_alt_outlined, + title: 'The internet has a backup too.', + description: 'Primary connectivity is dedicated fiber. If that fails, satellite kicks in. Terrestrial and space-based redundancy — because your access matters.', + ), + _SecurityFeature( + icon: Icons.visibility_outlined, + title: 'We watch. We act.', + description: 'Continuous uptime monitoring, automated alerting, 24/7. If something blinks wrong, we know — and our systems respond before you\'d ever notice.', + ), + _SecurityFeature( + icon: Icons.shield_outlined, + title: 'We keep attackers out.', + description: 'Firewall rules block malicious traffic at the edge. Tarpits slow down scanners and bots, wasting their time instead of ours. Role-based access control ensures every request is authenticated and authorized — no exceptions.', + ), + _SecurityFeature( + icon: Icons.code_outlined, + title: 'Built with intention.', + description: 'Most software is assembled from open source libraries — code written by strangers, maintained by volunteers, used by millions. When a vulnerability is discovered, every application using that library is exposed. We write our own code. We control every line. We don\'t inherit other people\'s risks.', + ), + ]; + + return features.map((feature) => Padding( + padding: const EdgeInsets.only(bottom: 48), + child: _buildFeatureCard(feature), + )).toList(); + } + + Widget _buildFeatureCard(_SecurityFeature feature) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: InouTheme.accentLight, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + feature.icon, + color: InouTheme.accent, + size: 24, + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feature.title, + style: InouText.h3, + ), + const SizedBox(height: 12), + Text( + feature.description, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _SecurityFeature { + final IconData icon; + final String title; + final String description; + + const _SecurityFeature({ + required this.icon, + required this.title, + required this.description, + }); +} diff --git a/app/lib/features/static/static.dart b/app/lib/features/static/static.dart new file mode 100644 index 0000000..1df9e43 --- /dev/null +++ b/app/lib/features/static/static.dart @@ -0,0 +1,8 @@ +// Barrel file for static pages +export 'landing_page.dart'; +export 'security_page.dart'; +export 'privacy_page.dart'; +export 'faq_page.dart'; +export 'dpa_page.dart'; +export 'connect_page.dart'; +export 'invite_page.dart'; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb new file mode 100644 index 0000000..493d0da --- /dev/null +++ b/app/lib/l10n/app_en.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "en", + + "appTitle": "inou", + "appTagline": "ai answers for you", + + "heroAnswer": "inou organizes and shares your health dossier with your AI — securely and privately.", + "heroTagline": "Your health, understood.", + "signIn": "Sign in", + "inviteAFriend": "Invite a friend", + "getStarted": "Get started", + + "youNeedAiTitle": "You need AI for your health", + "youNeedAiPara1": "Your health data lives in a dozen different places — with your cardiologist, your neurologist, your lab, your watch, your apps, your 23andMe. And only you know the rest: what you eat, what you drink, what supplements you take. Your exercise routine. Your symptoms. Your goals — whether you're trying to get pregnant, training for a marathon, or just trying to feel less exhausted.", + "youNeedAiPara2": "Whether you're healthy and want to stay that way, navigating a difficult diagnosis, or caring for a family member who can't advocate for themselves — no single doctor sees the full picture. No system connects it.", + "youNeedAiPara3": "But you have access to all of it. You just don't have the expertise to make sense of it all.", + "youNeedAiEmphasis": "Your AI does. inou gives it the full picture.", + + "challengeTitle": "The challenge", + "challengeMri": "Your MRI has 4,000 slices.", + "challengeMriReality": "It was read in 10 minutes.", + "challengeGenome": "Your genome has millions of variants.", + "challengeGenomeReality": "All you learned was your eye color and where your ancestors came from.", + "challengeBlood": "Your blood work has dozens of markers.", + "challengeBloodReality": "Your doctor said \"everything looks fine.\"", + "challengeWatch": "Your watch tracked 10,000 hours of sleep.", + "challengeWatchReality": "Your trainer doesn't know it exists.", + "challengeSupplements": "You've tried a hundred different supplements.", + "challengeSupplementsReality": "Nobody asked which ones.", + + "challengeTransition": "The connections are there.\nThey are just too complex for any one person to grasp.", + + "challengeGap1": "Nobody knows how your body processes Warfarin — not even you.", + "challengeGap1Indent": "But the answer might already be hiding in your 23andMe.", + "challengeGap2": "That 'unremarkable' on your MRI — did anyone look closely at all 4,000 slices?", + "challengeGap3": "Your thyroid is 'in range' — but nobody connected it to your fatigue, your weight, always being cold.", + + "challengeConnection1": "Nobody is connecting your afternoon caffeine to your sleep scores.", + "challengeConnection2": "Your iron levels to your workout fatigue.", + "challengeConnection3": "Your genetics to your brain fog.", + + "challengeAi1": "Your AI doesn't forget.", + "challengeAi2": "Doesn't rush.", + "challengeAi3": "Finds what was missed.", + "challengeAi4": "Doesn't specialize — sees the complete you.", + + "challengeClosing": "inou lets your AI take it all into account — every slice, every marker, every variant — connect it all and finally give you answers no one else could.", + + "whyTitle": "Why we built this", + "whyPara1": "You've collected years of health data. Scans from the hospital. Blood work from the lab. Results from your doctor's portal. Data from your watch. Maybe even your DNA.", + "whyPara2": "And then there's everything only you know — your weight, your blood pressure, your training schedule, the supplements you take, the symptoms you've been meaning to mention.", + "whyPara3": "It's all there — but scattered across systems that don't talk to each other, held by specialists who only see their piece, or locked in your own head.", + "whyPara4": "Your cardiologist doesn't know what your neurologist found. Your trainer hasn't seen your blood work. Your doctor has no idea what supplements you are taking. And none of them have time to sit with you and connect the dots.", + "whyPara5": "AI finally can. It can pull together what no single expert sees — and actually explain it to you.", + "whyPara6": "But this data doesn't fit in a chat window. And the last thing you want is your medical history on someone else's servers, training their models.", + "whyPara7": "inou brings it all together — labs, imaging, genetics, vitals, medications, supplements — encrypted, private, and shared with absolutely no one. Your AI connects securely. Your data stays yours.", + "whyClosing": "Your health, understood.", + + "trustLabel": "Your data stays yours", + "neverTraining": "Never used for training", + "neverTrainingDesc": "Your images are never used to train AI models.", + "neverShared": "Never shared", + "neverSharedDesc": "We never share your data with anyone.", + "encrypted": "Military-grade encryption", + "encryptedDesc": "At rest and in transit. Your data never travels unprotected.", + "deleteAnytime": "Delete anytime", + "deleteAnytimeDesc": "Your data, your control.", + + "footerPrivacy": "Privacy", + "footerSecurity": "Security", + "footerFaq": "FAQ", + "footerContact": "Contact", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb new file mode 100644 index 0000000..43ced2d --- /dev/null +++ b/app/lib/l10n/app_nl.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "nl", + + "appTitle": "inou", + "appTagline": "ai antwoorden voor jou", + + "heroAnswer": "inou organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.", + "heroTagline": "Jouw gezondheid, begrepen.", + "signIn": "Inloggen", + "inviteAFriend": "Nodig een vriend uit", + "getStarted": "Aan de slag", + + "youNeedAiTitle": "Je hebt AI nodig voor je gezondheid", + "youNeedAiPara1": "Je gezondheidsgegevens liggen op tientallen plekken verspreid — bij je cardioloog, je neuroloog, je lab, je horloge, je apps, je 23andMe. En alleen jij weet de rest: wat je eet, wat je drinkt, welke supplementen je neemt. Je trainingsschema. Je symptomen. Je doelen — of je nu zwanger probeert te worden, traint voor een marathon, of gewoon minder moe wilt zijn.", + "youNeedAiPara2": "Of je nu gezond bent en dat wilt blijven, een moeilijke diagnose aan het verwerken bent, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het volledige plaatje. Geen systeem verbindt alles.", + "youNeedAiPara3": "Maar jij hebt toegang tot alles. Je hebt alleen niet de expertise om het te begrijpen.", + "youNeedAiEmphasis": "Je AI wel. inou geeft het het complete beeld.", + + "challengeTitle": "De uitdaging", + "challengeMri": "Je MRI heeft 4.000 beelden.", + "challengeMriReality": "Het werd in 10 minuten beoordeeld.", + "challengeGenome": "Je genoom heeft miljoenen varianten.", + "challengeGenomeReality": "Het enige wat je leerde was je oogkleur en waar je voorouders vandaan kwamen.", + "challengeBlood": "Je bloedonderzoek heeft tientallen markers.", + "challengeBloodReality": "Je arts zei \"alles ziet er goed uit.\"", + "challengeWatch": "Je horloge registreerde 10.000 uur slaap.", + "challengeWatchReality": "Je trainer weet niet dat het bestaat.", + "challengeSupplements": "Je hebt honderd verschillende supplementen geprobeerd.", + "challengeSupplementsReality": "Niemand vroeg welke.", + + "challengeTransition": "De verbanden zijn er.\nZe zijn alleen te complex voor één persoon om te bevatten.", + + "challengeGap1": "Niemand weet hoe je lichaam Warfarine verwerkt — zelfs jij niet.", + "challengeGap1Indent": "Maar het antwoord ligt misschien al in je 23andMe.", + "challengeGap2": "Die 'niet-afwijkend' op je MRI — heeft iemand goed naar alle 4.000 beelden gekeken?", + "challengeGap3": "Je schildklier is 'binnen de norm' — maar niemand verbond het met je vermoeidheid, je gewicht, altijd koud zijn.", + + "challengeConnection1": "Niemand verbindt je middagcafeïne met je slaapscores.", + "challengeConnection2": "Je ijzergehalte met je trainingsvermoeidheid.", + "challengeConnection3": "Je genetica met je hersenmist.", + + "challengeAi1": "Je AI vergeet niet.", + "challengeAi2": "Haast zich niet.", + "challengeAi3": "Vindt wat gemist werd.", + "challengeAi4": "Specialiseert niet — ziet de complete jij.", + + "challengeClosing": "inou laat je AI alles meenemen — elke opname, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.", + + "whyTitle": "Waarom we dit bouwden", + "whyPara1": "Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Resultaten van je artsenportaal. Data van je horloge. Misschien zelfs je DNA.", + "whyPara2": "En dan is er alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je neemt, de symptomen die je steeds vergeet te noemen.", + "whyPara3": "Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, beheerd door specialisten die alleen hun stukje zien, of opgesloten in je eigen hoofd.", + "whyPara4": "Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je arts heeft geen idee welke supplementen je neemt. En geen van hen heeft tijd om met je te zitten en de punten te verbinden.", + "whyPara5": "AI kan dat eindelijk. Het kan samenbrengen wat geen enkele expert ziet — en het je daadwerkelijk uitleggen.", + "whyPara6": "Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, hun modellen trainen.", + "whyPara7": "inou brengt alles samen — labs, beeldvorming, genetica, vitale functies, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.", + "whyClosing": "Jouw gezondheid, begrepen.", + + "trustLabel": "Jouw data blijft van jou", + "neverTraining": "Nooit gebruikt voor training", + "neverTrainingDesc": "Je beelden worden nooit gebruikt om AI-modellen te trainen.", + "neverShared": "Nooit gedeeld", + "neverSharedDesc": "We delen je data nooit met anderen.", + "encrypted": "Versleutelde opslag", + "encryptedDesc": "In rust en onderweg. Je data reist nooit onbeschermd.", + "deleteAnytime": "Altijd verwijderen", + "deleteAnytimeDesc": "Jouw data, jouw controle.", + + "footerPrivacy": "Privacy", + "footerSecurity": "Beveiliging", + "footerFaq": "FAQ", + "footerContact": "Contact", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb new file mode 100644 index 0000000..0094153 --- /dev/null +++ b/app/lib/l10n/app_ru.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "ru", + + "appTitle": "inou", + "appTagline": "ии ответы для вас", + + "heroAnswer": "inou организует и делится вашим медицинским досье с вашим ИИ — безопасно и конфиденциально.", + "heroTagline": "Ваше здоровье, понятно.", + "signIn": "Войти", + "inviteAFriend": "Пригласить друга", + "getStarted": "Начать", + + "youNeedAiTitle": "Вам нужен ИИ для вашего здоровья", + "youNeedAiPara1": "Ваши медицинские данные хранятся в десятках разных мест — у кардиолога, невролога, в лаборатории, в ваших часах, приложениях, 23andMe. И только вы знаете остальное: что вы едите, что пьёте, какие добавки принимаете. Ваш режим тренировок. Ваши симптомы. Ваши цели — беременность, марафон, или просто меньше уставать.", + "youNeedAiPara2": "Здоровы вы или нет, справляетесь со сложным диагнозом или ухаживаете за членом семьи — ни один врач не видит полную картину. Ни одна система не связывает всё воедино.", + "youNeedAiPara3": "Но у вас есть доступ ко всему. Просто нет экспертизы, чтобы во всём разобраться.", + "youNeedAiEmphasis": "У вашего ИИ есть. inou даёт ему полную картину.", + + "challengeTitle": "Проблема", + "challengeMri": "Ваше МРТ содержит 4000 снимков.", + "challengeMriReality": "Их просмотрели за 10 минут.", + "challengeGenome": "Ваш геном содержит миллионы вариантов.", + "challengeGenomeReality": "Всё, что вы узнали — цвет глаз и откуда ваши предки.", + "challengeBlood": "В вашем анализе крови десятки показателей.", + "challengeBloodReality": "Врач сказал «всё в норме».", + "challengeWatch": "Ваши часы записали 10 000 часов сна.", + "challengeWatchReality": "Ваш тренер не знает об этом.", + "challengeSupplements": "Вы перепробовали сотню разных добавок.", + "challengeSupplementsReality": "Никто не спросил каких.", + + "challengeTransition": "Связи существуют.\nОни просто слишком сложны для одного человека.", + + "challengeGap1": "Никто не знает, как ваш организм обрабатывает варфарин — даже вы.", + "challengeGap1Indent": "Но ответ может уже быть в вашем 23andMe.", + "challengeGap2": "Это «без особенностей» на МРТ — кто-нибудь внимательно посмотрел все 4000 снимков?", + "challengeGap3": "Ваша щитовидка «в норме» — но никто не связал это с усталостью, весом, вечным холодом.", + + "challengeConnection1": "Никто не связывает послеобеденный кофе с качеством сна.", + "challengeConnection2": "Уровень железа с усталостью на тренировках.", + "challengeConnection3": "Генетику с туманом в голове.", + + "challengeAi1": "Ваш ИИ не забывает.", + "challengeAi2": "Не торопится.", + "challengeAi3": "Находит упущенное.", + "challengeAi4": "Не специализируется — видит вас полностью.", + + "challengeClosing": "inou позволяет вашему ИИ учесть всё — каждый снимок, каждый показатель, каждый вариант — связать всё вместе и наконец дать ответы, которые никто другой не мог.", + + "whyTitle": "Почему мы это создали", + "whyPara1": "Вы годами собирали данные о здоровье. Снимки из больницы. Анализы из лаборатории. Результаты с портала врача. Данные с часов. Может, даже ДНК.", + "whyPara2": "И есть всё то, что знаете только вы — вес, давление, график тренировок, добавки, симптомы, о которых забываете сказать.", + "whyPara3": "Всё это есть — но разбросано по системам, которые не общаются друг с другом, хранится у специалистов, которые видят только свой кусок, или заперто в вашей голове.", + "whyPara4": "Кардиолог не знает, что нашёл невролог. Тренер не видел анализы крови. Врач понятия не имеет о ваших добавках. И ни у кого нет времени сесть с вами и соединить точки.", + "whyPara5": "ИИ наконец может. Он может собрать то, что не видит ни один эксперт — и объяснить вам.", + "whyPara6": "Но эти данные не влезают в окно чата. И меньше всего вам нужно, чтобы медицинская история хранилась на чужих серверах и обучала их модели.", + "whyPara7": "inou собирает всё вместе — анализы, снимки, генетику, показатели, лекарства, добавки — зашифрованно, приватно, без передачи кому-либо. Ваш ИИ подключается безопасно. Ваши данные остаются вашими.", + "whyClosing": "Ваше здоровье, понятно.", + + "trustLabel": "Ваши данные остаются вашими", + "neverTraining": "Никогда не для обучения", + "neverTrainingDesc": "Ваши снимки никогда не используются для обучения моделей ИИ.", + "neverShared": "Никогда не передаются", + "neverSharedDesc": "Мы никогда не делимся вашими данными.", + "encrypted": "Шифрование военного уровня", + "encryptedDesc": "При хранении и передаче. Ваши данные никогда не путешествуют без защиты.", + "deleteAnytime": "Удалить в любое время", + "deleteAnytimeDesc": "Ваши данные, ваш контроль.", + + "footerPrivacy": "Конфиденциальность", + "footerSecurity": "Безопасность", + "footerFaq": "FAQ", + "footerContact": "Контакты", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..6b339a2 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/core/router.dart'; +import 'package:inou_app/core/locale_provider.dart'; + +void main() { + runApp(const InouApp()); +} + +/// Global locale notifier - accessible throughout the app +final localeNotifier = ValueNotifier(const Locale('en')); + +class InouApp extends StatefulWidget { + const InouApp({super.key}); + + @override + State createState() => _InouAppState(); + + /// Static method to change locale from anywhere + static void setLocale(BuildContext context, Locale locale) { + localeNotifier.value = locale; + // Also persist to SharedPreferences + LocaleProvider().setLocale(locale); + } + + /// Get current locale + static Locale getLocale(BuildContext context) { + return localeNotifier.value; + } +} + +class _InouAppState extends State { + late LocaleProvider _localeProvider; + + @override + void initState() { + super.initState(); + _localeProvider = LocaleProvider(); + _localeProvider.addListener(_onLocaleChanged); + // Initialize with saved locale + _loadSavedLocale(); + } + + Future _loadSavedLocale() async { + // Wait for provider to load saved locale + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + localeNotifier.value = _localeProvider.locale; + } + } + + void _onLocaleChanged() { + if (mounted) { + localeNotifier.value = _localeProvider.locale; + } + } + + @override + void dispose() { + _localeProvider.removeListener(_onLocaleChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: localeNotifier, + builder: (context, locale, _) { + return MaterialApp.router( + title: 'inou', + debugShowCheckedModeBanner: false, + theme: InouTheme.light, + routerConfig: appRouter, + + // Localization setup + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: LocaleProvider.supportedLocales, + ); + }, + ); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..f59b945 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "inou_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.inou.inou_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/main.cc b/app/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/my_application.cc b/app/linux/my_application.cc new file mode 100644 index 0000000..5a1abee --- /dev/null +++ b/app/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "inou_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "inou_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/app/linux/my_application.h b/app/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/app/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b8e2b22 --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b4604a5 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* inou_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "inou_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* inou_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* inou_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..693f7ab --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..8e02df2 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..a200e25 --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = inou_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.inou. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..edd28cb --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,439 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.5.4 <4.0.0" + flutter: ">=3.24.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..509872c --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,100 @@ +name: inou_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.5.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # Internationalization + intl: ^0.19.0 + + # Navigation + go_router: ^14.6.0 + + # State management + shared_preferences: ^2.3.5 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + google_fonts: ^6.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^4.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + fonts: + - family: Sora + fonts: + - asset: fonts/Sora-Thin.ttf + weight: 100 + - asset: fonts/Sora-ExtraLight.ttf + weight: 200 + - asset: fonts/Sora-Light.ttf + weight: 300 + - asset: fonts/Sora-Regular.ttf + weight: 400 + - asset: fonts/Sora-SemiBold.ttf + weight: 600 + - asset: fonts/Sora-Bold.ttf + weight: 700 + - asset: fonts/Sora-ExtraBold.ttf + weight: 800 diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..4734a8e --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + inou_app + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..57419a7 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "inou_app", + "short_name": "inou_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 0000000..072a55b --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(inou_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "inou_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 0000000..b21b2bb --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.inou" "\0" + VALUE "FileDescription", "inou_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "inou_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.inou. All rights reserved." "\0" + VALUE "OriginalFilename", "inou_app.exe" "\0" + VALUE "ProductName", "inou_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 0000000..d238f30 --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"inou_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/design/FLUTTER_STYLEGUIDE_GAPS.md b/design/FLUTTER_STYLEGUIDE_GAPS.md new file mode 100644 index 0000000..aaa8019 --- /dev/null +++ b/design/FLUTTER_STYLEGUIDE_GAPS.md @@ -0,0 +1,559 @@ +# inou Flutter Styleguide Gap Analysis + +**Generated:** 2025-01-28 +**Purpose:** Document gaps between the original web styleguide and the current Flutter implementation to achieve closer visual parity. + +--- + +## Executive Summary + +The Flutter app has a good foundation with `InouTheme` properly implementing the design tokens. However, there are several gaps in how those tokens are applied consistently across the app. The main issues are: + +1. **Typography inconsistencies** - font weights and sizes don't always match the web +2. **Missing CSS-specific styling** - dashed borders, hover states, transitions +3. **Component styling gaps** - buttons, inputs, and badges not pixel-perfect +4. **Layout structure** - page layout and spacing inconsistencies + +--- + +## 1. Typography Gaps + +### Current vs Original + +| Style | Web CSS | Flutter Implementation | Gap | +|-------|---------|------------------------|-----| +| Page Title (h1Large) | 2.5rem (37.5px), 700 | 37.5px, w700 | ✅ Matches | +| h1 | 2.25rem (33.75px), 300, -0.03em | 33.75px, w300, -0.03em | ✅ Matches | +| h2 Section Title | 1.4rem (21px), 600 | 21px, w600 | ✅ Matches | +| h3 Subsection | 1.1rem (16.5px), 600 | 16.5px, w600 | ✅ Matches | +| Intro text | 1.15rem (17.25px), 300, line-height 1.8 | 17.25px, w300, height 1.8 | ✅ Matches | +| Body light | 1rem (15px), 300, line-height 1.8 | Missing `bodyLight` in some places | ⚠️ Inconsistent use | +| Body regular | 1rem (15px), 400 | 15px, w400 | ✅ Matches | +| Label/Category | 0.75rem (11.25px), 600, uppercase, 0.1em | 11.25px, w600, 0.1em spacing | ⚠️ `toUpperCase()` not always called | +| Mono | SF Mono, 0.85rem (12.75px) | SF Mono, 12.75px | ⚠️ Fallback fonts missing | + +### Fixes Needed + +```dart +// In InouTheme, add missing style variants: + +static TextStyle get bodyLight => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w300, // Light, for long-form content + color: textMuted, + height: 1.8, +); + +// Mono font needs fallbacks +static TextStyle get mono => TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: const ['Monaco', 'Consolas', 'monospace'], + fontSize: 12.75, + fontWeight: FontWeight.w400, + color: text, +); +``` + +--- + +## 2. Color Gaps + +### Theme Colors ✅ +All colors match the design tokens exactly: +- `bg: #F8F7F6` ✅ +- `bgCard: #FFFFFF` ✅ +- `border: #E5E2DE` ✅ +- `text: #1C1917` ✅ +- `textMuted: #78716C` ✅ +- `textSubtle: #A8A29E` ✅ +- `accent: #B45309` ✅ +- All indicator colors ✅ + +### Missing Usage Patterns + +**Issue:** The web uses `opacity: 0.6` for "coming soon" and disabled states. Flutter uses hardcoded opacity. + +```dart +// Web CSS: +.data-card.coming-soon { opacity: 0.6; } +.btn-disabled { opacity: 0.6; } + +// Flutter should use consistent disabled opacity: +static const double disabledOpacity = 0.6; +``` + +--- + +## 3. Spacing Gaps + +### Spacing Scale ✅ +All spacing tokens match: +- xs: 4px ✅ +- sm: 8px ✅ +- md: 12px ✅ +- lg: 16px ✅ +- xl: 24px ✅ +- xxl: 32px ✅ +- xxxl: 48px ✅ + +### Usage Inconsistencies + +**Issue:** The dossier page uses hardcoded padding values instead of theme constants. + +```dart +// Current (in dossier_page.dart): +padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + +// Should be: +padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceXl, + vertical: InouTheme.spaceXxxl, +), +``` + +**Issue:** Card margins not consistent with web. + +```dart +// Web CSS: +.data-card { margin-bottom: 16px; } + +// Flutter InouCard uses spaceLg (16px) ✅ but some pages override +``` + +--- + +## 4. Border Radius Gaps + +### Radius Values ✅ +All match the design tokens. + +### Missing Application + +**Issue:** Some components don't use the theme radius: + +```dart +// Current (in various places): +borderRadius: BorderRadius.circular(4), + +// Should use: +borderRadius: InouTheme.borderRadiusSm, +``` + +--- + +## 5. Component Gaps + +### 5.1 Buttons + +**Web CSS:** +```css +.btn { + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + border-radius: 6px; + transition: all 0.15s; +} +.btn-small { + padding: 6px 12px; + font-size: 1rem; /* Same font size as regular */ +} +``` + +**Flutter Current:** +```dart +final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); +// Font size differs: small uses 14px, regular uses 15px +``` + +**Fix:** +```dart +// Both sizes should use 15px (1rem) font +textStyle: InouTheme.labelLarge.copyWith( + fontSize: 15, // Always 15px, not 14 for small +), +``` + +### 5.2 Badges + +**Web CSS:** +```css +.badge { + padding: 2px 8px; + font-size: 1rem; /* 15px */ + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} +.badge-care { + background: var(--success-light); + color: var(--success); +} +``` + +**Flutter Current:** (inou_badge.dart) +- Padding: 4h, 8v (should be 2v, 8h) ❌ +- Font size: uses bodySmall (12.75px) instead of 15px ❌ + +**Fix:** +```dart +// InouBadge needs updating: +static TextStyle get badgeText => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, + color: text, +); + +// Padding should be: +padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), +``` + +### 5.3 Data Cards + +**Web CSS:** +```css +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; +} +.section-heading { + font-size: 0.75rem; /* 11.25px */ + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} +``` + +**Flutter Current:** +The `_DataCard` in dossier_page.dart mostly matches, but: +- Letter spacing is 0.8 instead of 0.08em (0.9px) ❌ +- Not using `InouTheme.labelSmall` which has correct specs + +**Fix:** +```dart +// In _DataCard header: +Text( + title, // Don't call toUpperCase() here + style: InouTheme.labelSmall, // Already has correct specs + uppercase handling +), +``` + +### 5.4 Data Rows + +**Web CSS:** +```css +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); /* DASHED! */ +} +.data-row:last-child { + border-bottom: none; +} +.data-row.child { + padding-left: 48px; +} +``` + +**Flutter Current:** +- Uses solid borders instead of dashed ❌ +- Child row padding is 32px instead of 48px ❌ + +**Fix:** +```dart +// Flutter can't do dashed borders easily, but can simulate with DashedLine widget +// or use a dotted pattern via CustomPaint + +// For child padding: +padding: const EdgeInsets.only(left: 48, right: 16, top: 12, bottom: 12), +``` + +### 5.5 Form Inputs + +**Web CSS:** +```css +.form-group input { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: 6px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.form-group input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +``` + +**Flutter Current:** +The theme's `InputDecorationTheme` uses 2px border on focus, not 1px + shadow. + +**Fix:** +```dart +// In InputDecorationTheme: +focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 1), // 1px, not 2px +), +// Add focus shadow via Container wrapper or InputDecoration +``` + +### 5.6 Messages (Error/Info/Success) + +**Web CSS:** +```css +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; +} +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); +} +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); +} +``` + +**Flutter Current:** +`InouMessage` widget exists but check border colors. + +**Add specific border colors:** +```dart +static const Color errorBorder = Color(0xFFFECACA); +static const Color infoBorder = Color(0xFFFDE68A); +static const Color successBorder = Color(0xFFA7F3D0); +``` + +--- + +## 6. Layout Gaps + +### 6.1 Page Container + +**Web CSS:** +```css +.container { + max-width: 800px; /* maxWidthNarrow */ + margin: 0 auto; + padding: 40px 20px; +} +.container-narrow { + max-width: 360px; /* maxWidthForm */ + padding: 60px 20px 40px; +} +``` + +**Flutter:** Uses `InouPage` and `InouAuthFlowPage` which are close but: +- Auth pages should use `maxWidthForm` (360px) ✅ +- Dossier uses `maxWidth` (1200px) but should probably use `maxWidthNarrow` (800px) ❌ + +### 6.2 Nav Bar + +**Web CSS:** +```css +.nav { + padding: 12px 24px; + max-width: 1200px; + border-bottom: 1px solid var(--border); +} +.logo { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + text-transform: lowercase; +} +``` + +**Flutter Current:** +- Logo font size uses `h3` style (~18px) instead of 1.75rem (26.25px) ❌ +- Missing letter-spacing: -0.02em ❌ + +**Fix:** +```dart +// In InouHeader _buildLogo(): +Text( + 'inou', + style: GoogleFonts.sora( + fontSize: 26.25, // 1.75rem + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.02 * 26.25, // -0.02em + ), +), +``` + +### 6.3 Footer + +**Web CSS:** +```css +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +``` + +**Flutter:** Check `InouFooter` for consistency. + +--- + +## 7. Missing Features + +### 7.1 Dashed Borders +The web uses `border-bottom: 1px dashed var(--border)` for data rows. Flutter doesn't support dashed borders natively. + +**Solutions:** +1. Use `dotted_border` package +2. Custom `CustomPainter` for dashed lines +3. Accept solid borders (simpler but less accurate) + +### 7.2 Hover States +Web has hover effects everywhere. Flutter web supports hover but mobile doesn't. + +**Solutions:** +1. Use `InkWell` with splash colors +2. On web, implement `MouseRegion` for hover states +3. Accept that mobile won't have hover + +### 7.3 Transitions +Web CSS has `transition: all 0.15s` on interactive elements. + +**Solution:** +Add `AnimatedContainer` or `AnimatedDefaultTextStyle` where appropriate. + +--- + +## 8. Priority Fixes + +### High Priority (Visual Impact) +1. **Badge font size and padding** - very noticeable +2. **Logo size in header** - brand consistency +3. **Button small font size** - should match regular +4. **Data card title letter-spacing** - subtle but important + +### Medium Priority +5. **Child row padding** (32px → 48px) +6. **Dossier page max-width** (1200px → 800px) +7. **Input focus style** (2px → 1px + shadow) +8. **Message border colors** - semantic colors + +### Low Priority (Polish) +9. Dashed borders (requires package or custom paint) +10. Hover states (platform-specific) +11. Transitions (nice to have) + +--- + +## 9. Recommended Actions + +### Immediate (Theme File Updates) + +```dart +// Add to InouTheme: + +// 1. Badge text style +static TextStyle get badgeText => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, +); + +// 2. Logo style +static TextStyle get logo => GoogleFonts.sora( + fontSize: 26.25, + fontWeight: FontWeight.w700, + letterSpacing: -0.02 * 26.25, +); + +// 3. Message border colors +static const Color errorBorder = Color(0xFFFECACA); +static const Color infoBorder = Color(0xFFFDE68A); +static const Color successBorder = Color(0xFFA7F3D0); + +// 4. Consistent disabled opacity +static const double disabledOpacity = 0.6; +``` + +### Widget Updates + +1. **InouBadge** - Fix padding (2v, 8h) and font size (15px) +2. **InouButton** - Use 15px for small buttons too +3. **InouHeader** - Use new logo style +4. **InouCard** - Ensure labelSmall is used correctly + +### Page Updates + +1. **DossierPage** - Change max-width to 800px +2. **All pages** - Use InouTheme spacing constants instead of hardcoded values + +--- + +## 10. Testing Checklist + +After applying fixes, verify: + +- [ ] Landing page hero text matches web +- [ ] Login form looks identical to web +- [ ] Dashboard profile cards match +- [ ] Dossier data cards and rows match +- [ ] Badges render at correct size +- [ ] Buttons (both sizes) match +- [ ] Header logo size and spacing correct +- [ ] Footer styling matches +- [ ] Messages (error/info/success) match +- [ ] Form inputs match (esp. focus state) + +--- + +## Appendix: Quick Reference + +### Font Sizes (Base 15px) +| Name | rem | px | +|------|-----|-----| +| h1Large | 2.5 | 37.5 | +| h1 | 2.25 | 33.75 | +| h2 | 1.4 | 21 | +| h3 | 1.1 | 16.5 | +| intro | 1.15 | 17.25 | +| body | 1.0 | 15 | +| small | 0.85 | 12.75 | +| label | 0.75 | 11.25 | +| logo | 1.75 | 26.25 | + +### Key Spacing +| Use Case | Value | +|----------|-------| +| Card margin-bottom | 16px | +| Card padding | 16px | +| Header padding | 12px 24px | +| Container padding | 40px 20px | +| Button padding | 10px 18px | +| Button small padding | 6px 12px | +| Badge padding | 2px 8px | + +### Border Radius +| Use Case | Value | +|----------|-------| +| Buttons | 6px (radiusMd) | +| Cards | 8px (radiusLg) | +| Badges | 4px (radiusSm) | +| Inputs | 6px (radiusMd) | diff --git a/design/flutter/inou_theme.dart b/design/flutter/inou_theme.dart new file mode 100644 index 0000000..b222f52 --- /dev/null +++ b/design/flutter/inou_theme.dart @@ -0,0 +1,270 @@ +// AUTO-GENERATED from tokens.json — do not edit directly +// Run: node design/generate.js + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// inou Design System +/// Single source of truth: design/tokens.json +class InouTheme { + InouTheme._(); + + // ============================================ + // COLORS + // ============================================ + static const Color bg = Color(0xFFF8F7F6); + static const Color bgCard = Color(0xFFFFFFFF); + static const Color border = Color(0xFFE5E2DE); + static const Color borderHover = Color(0xFFC4BFB8); + static const Color text = Color(0xFF1C1917); + static const Color textMuted = Color(0xFF78716C); + static const Color textSubtle = Color(0xFFA8A29E); + static const Color accent = Color(0xFFB45309); + static const Color accentHover = Color(0xFF92400E); + static const Color accentLight = Color(0xFFFEF3C7); + static const Color danger = Color(0xFFDC2626); + static const Color dangerLight = Color(0xFFFEF2F2); + static const Color success = Color(0xFF059669); + static const Color successLight = Color(0xFFECFDF5); + + // Indicator colors (data sections) + static const Color indicatorImaging = Color(0xFFB45309); + static const Color indicatorLabs = Color(0xFF059669); + static const Color indicatorUploads = Color(0xFF6366F1); + static const Color indicatorVitals = Color(0xFFEC4899); + static const Color indicatorMedications = Color(0xFF8B5CF6); + static const Color indicatorRecords = Color(0xFF06B6D4); + static const Color indicatorJournal = Color(0xFFF59E0B); + static const Color indicatorPrivacy = Color(0xFF64748B); + static const Color indicatorGenetics = Color(0xFF10B981); + + // ============================================ + // SPACING + // ============================================ + static const double spaceXs = 4.0; + static const double spaceSm = 8.0; + static const double spaceMd = 12.0; + static const double spaceLg = 16.0; + static const double spaceXl = 24.0; + static const double spaceXxl = 32.0; + static const double spaceXxxl = 48.0; + + // ============================================ + // BORDER RADIUS + // ============================================ + static const double radiusSm = 4.0; + static const double radiusMd = 6.0; + static const double radiusLg = 8.0; + static const double radiusXl = 12.0; + static const double radiusFull = 9999.0; + static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm); + static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd); + static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg); + + // ============================================ + // LAYOUT + // ============================================ + static const double maxWidth = 1200.0; + static const double maxWidthNarrow = 800.0; + static const double maxWidthForm = 360.0; + + // ============================================ + // TYPOGRAPHY — matches inou.com/static/style.css + // Base: 15px (body) + // ============================================ + static String get fontFamily => 'Sora'; + + // Page Title: 2.5rem / 700 = 37.5px + static TextStyle get pageTitle => GoogleFonts.sora( + fontSize: 37.5, + fontWeight: FontWeight.w700, + color: text, + ); + + // h1: 2.25rem / 300 / -0.03em = 33.75px (used in forms, etc.) + static TextStyle get h1 => GoogleFonts.sora( + fontSize: 33.75, + fontWeight: FontWeight.w300, + color: text, + letterSpacing: -0.45, // -0.03em × 15px + ); + + // h1.small: 1.5rem / 300 = 22.5px + static TextStyle get h1Small => GoogleFonts.sora( + fontSize: 22.5, + fontWeight: FontWeight.w300, + color: text, + ); + + // Section Title: 1.4rem / 600 = 21px (.privacy-container h2) + static TextStyle get sectionTitle => GoogleFonts.sora( + fontSize: 21.0, + fontWeight: FontWeight.w600, + color: text, + ); + + // h2 (regular): 1.5rem / 300 / -0.02em = 22.5px + static TextStyle get h2 => GoogleFonts.sora( + fontSize: 22.5, + fontWeight: FontWeight.w300, + color: text, + letterSpacing: -0.3, // -0.02em × 15px + ); + + // Subsection Title: 1.1rem / 600 = 16.5px (.privacy-container h3) + static TextStyle get subsectionTitle => GoogleFonts.sora( + fontSize: 16.5, + fontWeight: FontWeight.w600, + color: text, + ); + + // h3 (regular): 1.125rem / 500 = 16.875px + static TextStyle get h3 => GoogleFonts.sora( + fontSize: 16.875, + fontWeight: FontWeight.w500, + color: text, + ); + + // Intro text: 1.15rem / 300 = 17.25px + static TextStyle get intro => GoogleFonts.sora( + fontSize: 17.25, + fontWeight: FontWeight.w300, + color: textMuted, + height: 1.8, + ); + + // Body light (long-form): 1rem / 300 = 15px + static TextStyle get bodyLight => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w300, + color: textMuted, + height: 1.8, + ); + + // Body regular (UI labels): 1rem / 400 = 15px + static TextStyle get body => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w400, + color: text, + ); + + // Label / Category: 0.75rem / 600 / caps / 0.1em = 11.25px + static TextStyle get label => GoogleFonts.sora( + fontSize: 11.25, + fontWeight: FontWeight.w600, + color: textSubtle, + letterSpacing: 1.125, // 0.1em × 11.25px + ); + + // Small text: 0.85rem / 400 = 12.75px + static TextStyle get small => GoogleFonts.sora( + fontSize: 12.75, + fontWeight: FontWeight.w400, + color: textMuted, + ); + + // Mono (SF Mono fallback) + static TextStyle get mono => const TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 12.75, + color: text, + ); + + // Legacy aliases for compatibility + static TextStyle get h1Large => pageTitle; + static TextStyle get bodyLarge => intro; + static TextStyle get bodyMedium => body; + static TextStyle get bodySmall => small; + static TextStyle get labelLarge => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, + color: text, + ); + static TextStyle get labelSmall => label; + + // ============================================ + // THEME DATA + // ============================================ + static ThemeData get light => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: bg, + colorScheme: ColorScheme.light( + primary: accent, + onPrimary: Colors.white, + secondary: accentLight, + onSecondary: accent, + surface: bgCard, + onSurface: text, + error: danger, + onError: Colors.white, + outline: border, + ), + textTheme: TextTheme( + displayLarge: pageTitle, + displayMedium: h1, + headlineMedium: sectionTitle, + headlineSmall: subsectionTitle, + bodyLarge: intro, + bodyMedium: body, + bodySmall: small, + labelLarge: labelLarge, + labelSmall: label, + ), + appBarTheme: AppBarTheme( + backgroundColor: bg, + foregroundColor: text, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardTheme( + color: bgCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadiusLg, + side: BorderSide(color: border), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: labelLarge, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + side: BorderSide(color: border), + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: labelLarge, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: bgCard, + border: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 2), + ), + contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd), + ), + dividerTheme: DividerThemeData( + color: border, + thickness: 1, + ), + ); +} \ No newline at end of file diff --git a/design/flutter/main_example.dart b/design/flutter/main_example.dart new file mode 100644 index 0000000..68aa257 --- /dev/null +++ b/design/flutter/main_example.dart @@ -0,0 +1,24 @@ +// Example main.dart showing how to use the inou design system +// Copy this to your Flutter project's lib/main.dart + +import 'package:flutter/material.dart'; +import 'inou_theme.dart'; +import 'screens/styleguide_screen.dart'; + +void main() { + runApp(const InouApp()); +} + +class InouApp extends StatelessWidget { + const InouApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'inou', + debugShowCheckedModeBanner: false, + theme: InouTheme.light, // ← One line to apply the entire design system + home: const StyleguideScreen(), + ); + } +} diff --git a/design/flutter/screens/styleguide_screen.dart b/design/flutter/screens/styleguide_screen.dart new file mode 100644 index 0000000..083a057 --- /dev/null +++ b/design/flutter/screens/styleguide_screen.dart @@ -0,0 +1,1333 @@ +// Flutter Styleguide — matches inou.com/styleguide exactly +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; +import '../widgets/widgets.dart'; + +class StyleguideScreen extends StatefulWidget { + const StyleguideScreen({super.key}); + + @override + State createState() => _StyleguideScreenState(); +} + +class _StyleguideScreenState extends State { + String? _selectedOption = 'Option 1'; + String _selectedSex = 'male'; + bool _checkboxValue = true; + String _selectedLLM = 'claude'; + String _selectedUnits = 'metric'; + bool _showGeneDetails = true; + bool _showVitalHistory = false; + bool _showNoteDetails = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text('Style Guide', style: InouTheme.pageTitle), + const SizedBox(height: 8), + Text( + 'Design system components for inou', + style: InouTheme.intro, + ), + const SizedBox(height: 32), + + // Text Blocks + _buildTextBlocksSection(), + + // Typography + _buildTypographySection(), + + // Colors + _buildColorsSection(), + + // Buttons + _buildButtonsSection(), + + // Badges + _buildBadgesSection(), + + // Messages + _buildMessagesSection(), + + // Form Elements + _buildFormsSection(), + + // Settings + _buildSettingsSection(), + + // Profile Cards + _buildProfileCardsSection(), + + // Data Cards (Imaging, Labs) + _buildDataCardsSection(), + + // Genetics + _buildGeneticsSection(), + + // Vitals + _buildVitalsSection(), + + // Notes + _buildNotesSection(), + + // Supplements + _buildSupplementsSection(), + + // Peptides + _buildPeptidesSection(), + + // Upload Area + _buildUploadSection(), + + // Empty State + _buildEmptyStateSection(), + + const SizedBox(height: 48), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTextBlocksSection() { + return InouCard( + title: 'Text Blocks', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your data. Your rules.', + style: InouTheme.pageTitle, + ), + const SizedBox(height: 16), + RichText( + text: TextSpan( + style: InouTheme.bodyLight, + children: [ + const TextSpan(text: 'We built '), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + const TextSpan( + text: ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + ), + ], + ), + ), + const SizedBox(height: 32), + Text( + 'What we collect', + style: InouTheme.sectionTitle, + ), + const SizedBox(height: 16), + Text( + 'Account information.', + style: InouTheme.subsectionTitle, + ), + const SizedBox(height: 8), + Text( + 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + style: InouTheme.bodyLight, + ), + ], + ), + ), + ); + } + + Widget _buildTypographySection() { + return InouCard( + title: 'Typography Scale', + indicatorColor: InouTheme.indicatorLabs, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TypographyRow('Page Title', InouTheme.pageTitle, '2.5rem / 700'), + _TypographyRow('Section Title', InouTheme.sectionTitle, '1.4rem / 600'), + _TypographyRow('Subsection Title', InouTheme.subsectionTitle, '1.1rem / 600'), + _TypographyRow( + 'LABEL / CATEGORY', + InouTheme.label.copyWith(color: InouTheme.textSubtle), + '0.75rem / 600 / caps', + isUppercase: true, + ), + _TypographyRow( + 'Intro text — larger, lighter', + InouTheme.intro, + '1.15rem / 300', + ), + _TypographyRow( + 'Body light — long-form', + InouTheme.bodyLight, + '1rem / 300', + ), + _TypographyRow( + 'Body regular — UI labels', + InouTheme.body, + '1rem / 400', + ), + _TypographyRow( + 'Mono: 1,234,567.89', + InouTheme.mono, + 'SF Mono', + ), + ], + ), + ), + ); + } + + Widget _buildColorsSection() { + return InouCard( + title: 'Colors', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _ColorRow('Accent', InouTheme.accent, '#B45309'), + _ColorRow('Text', InouTheme.text, '#1C1917'), + _ColorRow('Text Muted', InouTheme.textMuted, '#78716C'), + _ColorRow('Background', InouTheme.bg, '#F8F7F6', hasBorder: true), + _ColorRow('Success', InouTheme.success, '#059669'), + _ColorRow('Danger', InouTheme.danger, '#DC2626'), + ], + ), + ), + ); + } + + Widget _buildButtonsSection() { + return InouCard( + title: 'Buttons', + indicatorColor: InouTheme.indicatorVitals, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + InouButton(text: 'Primary', onPressed: () {}), + InouButton( + text: 'Secondary', + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + InouButton( + text: 'Danger', + variant: ButtonVariant.danger, + onPressed: () {}, + ), + InouButton( + text: 'Small', + size: ButtonSize.small, + onPressed: () {}, + ), + ], + ), + ), + ); + } + + Widget _buildBadgesSection() { + return InouCard( + title: 'Badges', + indicatorColor: InouTheme.indicatorMedications, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + InouBadge(text: 'default'), + InouBadge(text: 'care', variant: BadgeVariant.care), + InouBadge(text: 'COMING SOON', variant: BadgeVariant.comingSoon), + InouBadge(text: 'processing', variant: BadgeVariant.processing), + ], + ), + ), + ); + } + + Widget _buildMessagesSection() { + return InouCard( + title: 'Messages', + indicatorColor: InouTheme.indicatorRecords, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: const [ + InouMessage( + message: 'Error message — something went wrong.', + type: MessageType.error, + ), + SizedBox(height: 12), + InouMessage( + message: "Info message — here's some useful information.", + type: MessageType.info, + ), + SizedBox(height: 12), + InouMessage( + message: 'Success message — operation completed.', + type: MessageType.success, + ), + ], + ), + ), + ); + } + + Widget _buildFormsSection() { + return InouCard( + title: 'Form Elements', + indicatorColor: InouTheme.indicatorJournal, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const InouTextField( + label: 'Text Input', + placeholder: 'Enter text...', + ), + const SizedBox(height: 16), + InouSelect( + label: 'Select', + value: _selectedOption, + options: const [ + InouSelectOption(value: 'Option 1', label: 'Option 1'), + InouSelectOption(value: 'Option 2', label: 'Option 2'), + InouSelectOption(value: 'Option 3', label: 'Option 3'), + ], + onChanged: (v) => setState(() => _selectedOption = v), + ), + const SizedBox(height: 16), + const InouTextField( + label: 'Code Input', + placeholder: '123456', + isCode: true, + maxLength: 6, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + InouRadioGroup( + value: _selectedSex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (v) => setState(() => _selectedSex = v ?? 'male'), + ), + const SizedBox(height: 16), + InouCheckbox( + value: _checkboxValue, + label: 'Can add data (supplements, notes, etc.)', + onChanged: (v) => setState(() => _checkboxValue = v ?? false), + ), + ], + ), + ), + ); + } + + Widget _buildSettingsSection() { + return InouCard( + title: 'Settings', + indicatorColor: InouTheme.indicatorPrivacy, + child: Column( + children: [ + // LLM Selector + _SettingsRow( + label: 'Primary AI Assistant', + description: 'Used for "Ask AI" prompts and analysis', + child: Column( + children: [ + _LLMOption( + icon: '🤖', + name: 'Claude (Anthropic)', + value: 'claude', + selected: _selectedLLM == 'claude', + onTap: () => setState(() => _selectedLLM = 'claude'), + ), + _LLMOption( + icon: '💬', + name: 'ChatGPT (OpenAI)', + value: 'chatgpt', + selected: _selectedLLM == 'chatgpt', + onTap: () => setState(() => _selectedLLM = 'chatgpt'), + ), + _LLMOption( + icon: '✖', + name: 'Grok (xAI)', + value: 'grok', + selected: _selectedLLM == 'grok', + onTap: () => setState(() => _selectedLLM = 'grok'), + ), + ], + ), + ), + const Divider(height: 1, color: InouTheme.border), + // Units Selector + _SettingsRow( + label: 'Units', + description: 'Measurement system for vitals', + child: InouSelect( + value: _selectedUnits, + options: const [ + InouSelectOption(value: 'metric', label: 'Metric (kg, cm, °C)'), + InouSelectOption(value: 'imperial', label: 'Imperial (lb, in, °F)'), + ], + onChanged: (v) => setState(() => _selectedUnits = v ?? 'metric'), + ), + ), + ], + ), + ); + } + + Widget _buildProfileCardsSection() { + return InouCard( + title: 'Profile Cards', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(24), + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 700 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Johan Jongsma', + role: 'you', + dob: '1985-03-15', + sex: 'Male', + stats: const [ + ProfileStat('📷', '3 studies'), + ProfileStat('🧪', '12 labs'), + ProfileStat('🧬', 'genome'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Sophia', + role: 'my role: Parent', + dob: '2017-01-01', + sex: 'Female', + isCare: true, + stats: const [ + ProfileStat('📷', '16 studies'), + ProfileStat('🧪', '0 labs'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouAddCard( + label: 'Add dossier', + onTap: () {}, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildDataCardsSection() { + return Column( + children: [ + // Imaging + InouCard( + title: 'Imaging', + subtitle: '16 studies · 4113 slices', + indicatorColor: InouTheme.indicatorImaging, + trailing: InouButton( + text: 'Open viewer', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'MRI BRAIN W/WO CONTRAST', + meta: '13 series', + date: '5/5/2022', + isExpandable: true, + children: [ + InouChildRow(label: 'AX T1', meta: '24 slices'), + InouChildRow(label: 'AX T2 FLAIR', meta: '24 slices'), + InouChildRow(label: 'SAG T1', meta: '20 slices'), + ], + ), + InouDataRow( + label: 'XR CHEST AP ONLY', + date: '5/6/2022', + trailing: GestureDetector( + onTap: () {}, + child: Text('→', style: TextStyle( + color: InouTheme.accent, + fontSize: 18, + fontWeight: FontWeight.w500, + )), + ), + ), + ], + ), + ), + + // Labs + InouCard( + title: 'Labs', + subtitle: '4 panels · 23 results', + indicatorColor: InouTheme.indicatorLabs, + child: Column( + children: [ + InouDataRow( + label: 'Complete Blood Count (CBC)', + meta: '8 tests', + date: '12/15/2024', + isExpandable: true, + initiallyExpanded: true, + children: const [ + InouChildRow( + label: 'Hemoglobin', + value: '14.2 g/dL', + meta: '12.0–16.0', + ), + InouChildRow( + label: 'White Blood Cells', + value: '7.8 K/µL', + meta: '4.5–11.0', + ), + InouChildRow( + label: 'Platelets', + value: '142 K/µL', + meta: '150–400', + valueColor: InouTheme.danger, + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildGeneticsSection() { + return InouCard( + title: 'Genetics', + subtitle: 'Medication Response · 47 variants', + indicatorColor: InouTheme.indicatorGenetics, + child: Column( + children: [ + InouDataRow( + label: 'Medication Response', + meta: '47 variants', + isExpandable: true, + initiallyExpanded: _showGeneDetails, + onExpandChanged: (v) => setState(() => _showGeneDetails = v), + children: [ + _GeneVariantRow( + gene: 'CYP2C19', + rsid: 'rs4244285', + allele: 'G;A', + status: 'intermediate', + summary: 'Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.', + onAskAI: () {}, + ), + ], + ), + // Show more link + InkWell( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Center( + child: Text( + 'Show all 47 variants in Medication Response →', + style: InouTheme.small.copyWith(color: InouTheme.accent), + ), + ), + ), + ), + InouDataRow( + label: 'Metabolism', + meta: '23 variants', + isExpandable: true, + ), + InouDataRow( + label: 'Cardiovascular', + meta: '18 variants', + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildVitalsSection() { + return InouCard( + title: 'Vitals', + subtitle: 'Self-reported measurements', + indicatorColor: InouTheme.indicatorVitals, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Temperature', + value: '37.2 °C', + meta: 'today', + leading: const InouNoteIcon(emoji: '🌡', color: InouTheme.danger), + isExpandable: true, + initiallyExpanded: _showVitalHistory, + onExpandChanged: (v) => setState(() => _showVitalHistory = v), + children: [ + _VitalHistoryRow(date: 'Today, 8:30 AM', value: '37.2 °C'), + _VitalHistoryRow(date: 'Yesterday, 8:15 AM', value: '36.8 °C'), + _VitalHistoryRow(date: 'Dec 24, 7:45 AM', value: '37.0 °C'), + ], + ), + InouDataRow( + label: 'Weight', + value: '72.4 kg', + meta: 'today', + leading: InouNoteIcon(emoji: '⚖', color: Colors.blue.shade600), + isExpandable: true, + ), + InouDataRow( + label: 'Blood Pressure', + value: '118/76', + meta: 'yesterday', + leading: InouNoteIcon(emoji: '❤', color: Colors.pink.shade600), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildNotesSection() { + return InouCard( + title: 'Notes', + subtitle: 'Health journal entries', + indicatorColor: InouTheme.indicatorJournal, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Knee injury', + meta: '3 photos', + date: 'Dec 20', + leading: const InouNoteIcon(emoji: '📷', color: Color(0xFF6366F1)), + trailing: _NoteCategoryBadge(text: 'injury'), + isExpandable: true, + initiallyExpanded: _showNoteDetails, + onExpandChanged: (v) => setState(() => _showNoteDetails = v), + children: [ + _NotePhotosRow(), + _NoteTimelineRow( + date: 'Dec 20, 3:45 PM', + text: 'Jim fell on his knee at soccer practice. Swelling visible, applied ice.', + ), + _NoteTimelineRow( + date: 'Dec 22, 10:20 AM', + text: 'Swelling reduced. Still some bruising. Can walk without pain.', + ), + _NoteTimelineRow( + date: 'Dec 26, 9:15 AM', + text: 'Almost fully healed. Light bruise remaining.', + ), + ], + ), + InouDataRow( + label: 'Mild headache after workout', + date: 'Dec 25', + leading: const InouNoteIcon(emoji: '📝', color: InouTheme.accent), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildSupplementsSection() { + return InouCard( + title: 'Supplements', + subtitle: 'Daily routine', + indicatorColor: InouTheme.indicatorMedications, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: const [ + _SupplementRow( + name: 'Vitamin D3', + dose: '1 capsule', + amount: '5000 IU', + timing: 'morning, with food', + ), + _SupplementRow( + name: 'Omega-3 Fish Oil', + dose: '2 capsules', + amount: '2000 mg EPA/DHA', + timing: 'morning, with food', + ), + _SupplementRow( + name: 'Magnesium Glycinate', + dose: '2 capsules', + amount: '400 mg', + timing: 'evening', + ), + _SupplementRow( + name: 'Liquid B12', + dose: '5 ml', + amount: '1000 mcg', + timing: 'morning', + ), + ], + ), + ); + } + + Widget _buildPeptidesSection() { + return InouCard( + title: 'Peptides', + subtitle: 'Therapeutic protocols', + indicatorColor: InouTheme.indicatorMedications, + child: Column( + children: const [ + _PeptideRow( + name: 'BPC-157', + dose: '250 mcg subQ · 2x daily', + endDate: 'until Jan 23, 2025', + status: 'active', + ), + _PeptideRow( + name: 'TB-500', + dose: '2.5 mg subQ · 2x weekly', + endDate: 'until Feb 5, 2025', + status: 'active', + ), + _PeptideRow( + name: 'BPC-157', + dose: '250 mcg subQ · 2x daily', + endDate: 'Aug 15 – Sep 7, 2025', + status: 'completed', + ), + ], + ), + ); + } + + Widget _buildUploadSection() { + return InouCard( + title: 'Upload Area', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 32, + color: InouTheme.accent, + ), + const SizedBox(height: 12), + Text( + 'Click or drag files here', + style: InouTheme.body.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + 'DICOM, PDF, CSV, VCF, and more', + style: InouTheme.small, + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyStateSection() { + return InouCard( + title: 'Empty State', + indicatorColor: InouTheme.indicatorRecords, + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Center( + child: Text( + 'No lab data', + style: InouTheme.body.copyWith(color: InouTheme.textMuted), + ), + ), + ), + ); + } +} + +// ============================================ +// Helper widgets +// ============================================ + +class _TypographyRow extends StatelessWidget { + final String text; + final TextStyle style; + final String spec; + final bool isUppercase; + + const _TypographyRow(this.text, this.style, this.spec, {this.isUppercase = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + isUppercase ? text.toUpperCase() : text, + style: style, + ), + ), + Text( + spec, + style: InouTheme.mono.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _ColorRow extends StatelessWidget { + final String name; + final Color color; + final String hex; + final bool hasBorder; + + const _ColorRow(this.name, this.color, this.hex, {this.hasBorder = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + border: hasBorder ? Border.all(color: InouTheme.border) : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + ), + Text(hex, style: InouTheme.mono.copyWith(color: InouTheme.textMuted)), + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + final String label; + final String description; + final Widget child; + + const _SettingsRow({ + required this.label, + required this.description, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text(description, style: InouTheme.small), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: child, + ), + ], + ), + ); + } +} + +class _LLMOption extends StatelessWidget { + final String icon; + final String name; + final String value; + final bool selected; + final VoidCallback onTap; + + const _LLMOption({ + required this.icon, + required this.name, + required this.value, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected ? InouTheme.accentLight : InouTheme.bgCard, + border: Border.all( + color: selected ? InouTheme.accent : InouTheme.border, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text(icon, style: const TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + Text( + name, + style: InouTheme.body.copyWith( + color: selected ? InouTheme.accent : InouTheme.text, + ), + ), + ], + ), + ), + ); + } +} + +class _GeneVariantRow extends StatelessWidget { + final String gene; + final String rsid; + final String allele; + final String status; + final String summary; + final VoidCallback onAskAI; + + const _GeneVariantRow({ + required this.gene, + required this.rsid, + required this.allele, + required this.status, + required this.summary, + required this.onAskAI, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(gene, style: InouTheme.body.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Text(rsid, style: InouTheme.mono.copyWith(fontSize: 12, color: InouTheme.textMuted)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text(allele, style: InouTheme.mono.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 8), + Text(status, style: InouTheme.small.copyWith(color: InouTheme.accent)), + ], + ), + const SizedBox(height: 8), + Text(summary, style: InouTheme.small.copyWith(height: 1.4)), + const SizedBox(height: 12), + GestureDetector( + onTap: onAskAI, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.accentLight, + border: Border.all(color: InouTheme.accent), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Ask AI', + style: InouTheme.small.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _VitalHistoryRow extends StatelessWidget { + final String date; + final String value; + + const _VitalHistoryRow({required this.date, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(date, style: InouTheme.small), + Text(value, style: InouTheme.mono), + ], + ), + ); + } +} + +class _NoteCategoryBadge extends StatelessWidget { + final String text; + + const _NoteCategoryBadge({required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + text, + style: InouTheme.small.copyWith( + fontSize: 11.25, + color: InouTheme.textSubtle, + ), + ), + ); + } +} + +class _NotePhotosRow extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: InouTheme.bg, + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _PhotoPlaceholder(label: 'Dec 20, 3:45 PM'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 22, 10:20 AM'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 26, 9:15 AM'), + const SizedBox(width: 12), + _AddPhotoPlaceholder(), + ], + ), + ], + ), + ); + } +} + +class _PhotoPlaceholder extends StatelessWidget { + final String label; + + const _PhotoPlaceholder({required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.border, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border), + ), + alignment: Alignment.center, + child: const Text('🦵', style: TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + SizedBox( + width: 64, + child: Text( + label.split(', ').first, + style: InouTheme.small.copyWith(fontSize: 10.5), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _AddPhotoPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border, style: BorderStyle.solid), + ), + alignment: Alignment.center, + child: Text('+', style: TextStyle(fontSize: 24, color: InouTheme.accent)), + ), + const SizedBox(height: 4), + Text( + 'Add photo', + style: InouTheme.small.copyWith(fontSize: 10.5, color: InouTheme.accent), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _NoteTimelineRow extends StatelessWidget { + final String date; + final String text; + + const _NoteTimelineRow({required this.date, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border, style: BorderStyle.solid)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: InouTheme.small), + const SizedBox(height: 4), + Text(text, style: InouTheme.body.copyWith(fontSize: 13.5)), + ], + ), + ); + } +} + +class _SupplementRow extends StatelessWidget { + final String name; + final String dose; + final String amount; + final String timing; + + const _SupplementRow({ + required this.name, + required this.dose, + required this.amount, + required this.timing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text( + '$dose · $amount', + style: InouTheme.small, + ), + ], + ), + ), + Text(timing, style: InouTheme.small.copyWith(color: InouTheme.textSubtle)), + ], + ), + ); + } +} + +class _PeptideRow extends StatelessWidget { + final String name; + final String dose; + final String endDate; + final String status; + + const _PeptideRow({ + required this.name, + required this.dose, + required this.endDate, + required this.status, + }); + + @override + Widget build(BuildContext context) { + final isActive = status == 'active'; + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Text(dose, style: InouTheme.small), + ], + ), + const SizedBox(height: 2), + Text(endDate, style: InouTheme.small.copyWith(color: InouTheme.textSubtle)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isActive ? InouTheme.successLight : InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status, + style: InouTheme.small.copyWith( + color: isActive ? InouTheme.success : InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/design/flutter/widgets/inou_badge.dart b/design/flutter/widgets/inou_badge.dart new file mode 100644 index 0000000..4c360c7 --- /dev/null +++ b/design/flutter/widgets/inou_badge.dart @@ -0,0 +1,71 @@ +// AUTO-GENERATED widget — matches web .badge +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum BadgeVariant { normal, care, comingSoon, processing } + +class InouBadge extends StatelessWidget { + final String text; + final BadgeVariant variant; + + const InouBadge({ + super.key, + required this.text, + this.variant = BadgeVariant.normal, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + final isUppercase = variant == BadgeVariant.comingSoon; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: style.background, + borderRadius: BorderRadius.circular(InouTheme.radiusSm), + ), + child: Text( + isUppercase ? text.toUpperCase() : text, + style: TextStyle( + fontSize: variant == BadgeVariant.comingSoon ? 10 : 13, + fontWeight: FontWeight.w500, + color: style.foreground, + letterSpacing: isUppercase ? 0.5 : 0, + ), + ), + ); + } + + _BadgeStyle _getStyle() { + switch (variant) { + case BadgeVariant.normal: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + case BadgeVariant.care: + return _BadgeStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + ); + case BadgeVariant.comingSoon: + return _BadgeStyle( + background: InouTheme.bg, + foreground: InouTheme.textMuted, + ); + case BadgeVariant.processing: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + } + } +} + +class _BadgeStyle { + final Color background; + final Color foreground; + + _BadgeStyle({required this.background, required this.foreground}); +} diff --git a/design/flutter/widgets/inou_button.dart b/design/flutter/widgets/inou_button.dart new file mode 100644 index 0000000..653e622 --- /dev/null +++ b/design/flutter/widgets/inou_button.dart @@ -0,0 +1,128 @@ +// AUTO-GENERATED widget — matches web .btn +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum ButtonVariant { primary, secondary, danger } +enum ButtonSize { regular, small } + +class InouButton extends StatelessWidget { + final String text; + final ButtonVariant variant; + final ButtonSize size; + final bool fullWidth; + final VoidCallback? onPressed; + final Widget? icon; + + const InouButton({ + super.key, + required this.text, + this.variant = ButtonVariant.primary, + this.size = ButtonSize.regular, + this.fullWidth = false, + this.onPressed, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final isSmall = size == ButtonSize.small; + final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); + + final style = _getStyle(); + + Widget button = TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + backgroundColor: style.background, + foregroundColor: style.foreground, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + side: style.border, + ), + textStyle: InouTheme.labelLarge.copyWith( + fontSize: isSmall ? 14 : 15, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: 6), + ], + Text(text), + ], + ), + ); + + if (fullWidth) { + button = SizedBox(width: double.infinity, child: button); + } + + return button; + } + + _ButtonStyle _getStyle() { + switch (variant) { + case ButtonVariant.primary: + return _ButtonStyle( + background: InouTheme.accent, + foreground: Colors.white, + border: BorderSide.none, + ); + case ButtonVariant.secondary: + return _ButtonStyle( + background: InouTheme.bgCard, + foreground: InouTheme.text, + border: const BorderSide(color: InouTheme.border), + ); + case ButtonVariant.danger: + return _ButtonStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: BorderSide(color: InouTheme.danger.withOpacity(0.3)), + ); + } + } +} + +class _ButtonStyle { + final Color background; + final Color foreground; + final BorderSide border; + + _ButtonStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} + +/// Icon button (matches .btn-icon) +class InouIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final Color? color; + + const InouIconButton({ + super.key, + required this.icon, + this.onPressed, + this.color, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + onPressed: onPressed, + color: color ?? InouTheme.textSubtle, + iconSize: 20, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(), + ); + } +} diff --git a/design/flutter/widgets/inou_card.dart b/design/flutter/widgets/inou_card.dart new file mode 100644 index 0000000..69188c3 --- /dev/null +++ b/design/flutter/widgets/inou_card.dart @@ -0,0 +1,312 @@ +// AUTO-GENERATED widget — matches web .data-card +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; +import 'inou_badge.dart'; +import 'inou_button.dart'; + +/// Data card with colored indicator bar +class InouCard extends StatelessWidget { + final String? title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + final Widget? child; + final VoidCallback? onTap; + + const InouCard({ + super.key, + this.title, + this.subtitle, + this.indicatorColor = InouTheme.accent, + this.trailing, + this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) + _Header( + title: title!, + subtitle: subtitle, + indicatorColor: indicatorColor, + trailing: trailing, + ), + if (child != null) child!, + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + + const _Header({ + required this.title, + this.subtitle, + required this.indicatorColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(InouTheme.spaceLg), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: InouTheme.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: InouTheme.labelSmall.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Simple card without indicator +class InouSimpleCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final VoidCallback? onTap; + + const InouSimpleCard({ + super.key, + required this.child, + this.padding, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final card = Container( + padding: padding ?? const EdgeInsets.all(InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: card, + ); + } + return card; + } +} + +/// Profile card for dashboard +class InouProfileCard extends StatelessWidget { + final String name; + final String? role; + final String? dob; + final String? sex; + final List stats; + final bool isCare; + final VoidCallback? onTap; + final VoidCallback? onEdit; + + const InouProfileCard({ + super.key, + required this.name, + this.role, + this.dob, + this.sex, + this.stats = const [], + this.isCare = false, + this.onTap, + this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(name, style: InouTheme.h3), + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text('✎', style: TextStyle(color: InouTheme.textMuted)), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + role ?? 'you', + style: InouTheme.bodySmall.copyWith(color: InouTheme.textSubtle), + ), + if (isCare) ...[ + const SizedBox(width: 8), + const InouBadge(text: 'care', variant: BadgeVariant.care), + ], + ], + ), + if (dob != null) ...[ + const SizedBox(height: 8), + Text( + 'Born: $dob${sex != null ? ' · $sex' : ''}', + style: InouTheme.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (stats.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: stats.map((s) => _StatChip(stat: s)).toList(), + ), + ], + const Spacer(), + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: onTap, + ), + ], + ), + ), + ); + } +} + +class ProfileStat { + final String emoji; + final String label; + + const ProfileStat(this.emoji, this.label); +} + +class _StatChip extends StatelessWidget { + final ProfileStat stat; + + const _StatChip({required this.stat}); + + @override + Widget build(BuildContext context) { + return Text( + '${stat.emoji} ${stat.label}', + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +/// Add card (dashed border) +class InouAddCard extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const InouAddCard({ + super.key, + required this.label, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: InouTheme.borderRadiusLg, + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, // Note: Flutter doesn't support dashed directly + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + color: InouTheme.accent, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 6), + Text( + label, + style: InouTheme.bodyMedium.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ); + } +} diff --git a/design/flutter/widgets/inou_data_row.dart b/design/flutter/widgets/inou_data_row.dart new file mode 100644 index 0000000..2941553 --- /dev/null +++ b/design/flutter/widgets/inou_data_row.dart @@ -0,0 +1,237 @@ +// AUTO-GENERATED widget — matches web .data-row +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +/// Expandable data row (for imaging, labs, etc.) +class InouDataRow extends StatefulWidget { + final String label; + final String? meta; + final String? date; + final String? value; + final bool isExpandable; + final List? children; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool initiallyExpanded; + final ValueChanged? onExpandChanged; + + const InouDataRow({ + super.key, + required this.label, + this.meta, + this.date, + this.value, + this.isExpandable = false, + this.children, + this.leading, + this.trailing, + this.onTap, + this.initiallyExpanded = false, + this.onExpandChanged, + }); + + @override + State createState() => _InouDataRowState(); +} + +class _InouDataRowState extends State { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + onTap: widget.isExpandable + ? () { + setState(() => _expanded = !_expanded); + widget.onExpandChanged?.call(_expanded); + } + : widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + if (widget.isExpandable) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: TextStyle( + color: InouTheme.textMuted, + fontSize: 14, + fontFamily: 'monospace', + ), + ), + ) + else if (widget.leading == null) + const SizedBox(width: 32), + if (widget.leading != null) ...[ + widget.leading!, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.label, + style: InouTheme.bodyMedium.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.value != null) + Text( + widget.value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: InouTheme.text, + ), + ), + if (widget.meta != null) ...[ + const SizedBox(width: 16), + Text( + widget.meta!, + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + if (widget.date != null) ...[ + const SizedBox(width: 16), + Text( + widget.date!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + if (widget.trailing != null) ...[ + const SizedBox(width: 8), + widget.trailing!, + ], + ], + ), + ), + ), + if (_expanded && widget.children != null) + Container( + color: InouTheme.bg, + child: Column(children: widget.children!), + ), + ], + ); + } +} + +/// Child row (indented) +class InouChildRow extends StatelessWidget { + final String label; + final String? value; + final String? meta; + final Widget? trailing; + final Color? valueColor; + + const InouChildRow({ + super.key, + required this.label, + this.value, + this.meta, + this.trailing, + this.valueColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 32), // indent + Expanded( + child: Text( + label, + style: InouTheme.bodyMedium, + ), + ), + if (value != null) + Text( + value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: valueColor ?? InouTheme.text, + ), + ), + if (meta != null) ...[ + const SizedBox(width: 16), + Text( + meta!, + style: InouTheme.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ); + } +} + +/// Icon for notes/vitals +class InouNoteIcon extends StatelessWidget { + final String emoji; + final Color color; + + const InouNoteIcon({ + super.key, + required this.emoji, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 16)), + ); + } +} diff --git a/design/flutter/widgets/inou_input.dart b/design/flutter/widgets/inou_input.dart new file mode 100644 index 0000000..4a3b313 --- /dev/null +++ b/design/flutter/widgets/inou_input.dart @@ -0,0 +1,205 @@ +// AUTO-GENERATED widget — matches web form elements +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +/// Text input field +class InouTextField extends StatelessWidget { + final String? label; + final String? placeholder; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final int? maxLength; + final bool isCode; + final ValueChanged? onChanged; + + const InouTextField({ + super.key, + this.label, + this.placeholder, + this.controller, + this.obscureText = false, + this.keyboardType, + this.maxLength, + this.isCode = false, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: InouTheme.labelLarge, + ), + const SizedBox(height: 4), + ], + TextField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + maxLength: maxLength, + textAlign: isCode ? TextAlign.center : TextAlign.start, + onChanged: onChanged, + style: isCode + ? TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: 8, + fontFamily: 'SF Mono', + ) + : InouTheme.bodyMedium, + decoration: InputDecoration( + hintText: placeholder, + counterText: '', + ), + ), + ], + ); + } +} + +/// Dropdown select +class InouSelect extends StatelessWidget { + final String? label; + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouSelect({ + super.key, + this.label, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouTheme.labelLarge), + const SizedBox(height: 4), + ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusMd, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: options + .map((o) => DropdownMenuItem( + value: o.value, + child: Text(o.label), + )) + .toList(), + onChanged: onChanged, + ), + ), + ), + ], + ); + } +} + +class InouSelectOption { + final T value; + final String label; + + const InouSelectOption({required this.value, required this.label}); +} + +/// Radio group +class InouRadioGroup extends StatelessWidget { + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouRadioGroup({ + super.key, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: options.map((option) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: InkWell( + onTap: () => onChanged?.call(option.value), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio( + value: option.value, + groupValue: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Text(option.label, style: InouTheme.bodyMedium), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class InouRadioOption { + final T value; + final String label; + + const InouRadioOption({required this.value, required this.label}); +} + +/// Checkbox +class InouCheckbox extends StatelessWidget { + final bool value; + final String label; + final ValueChanged? onChanged; + + const InouCheckbox({ + super.key, + required this.value, + required this.label, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged?.call(!value), + child: Row( + children: [ + Checkbox( + value: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Expanded( + child: Text( + label, + style: InouTheme.bodyMedium.copyWith(color: InouTheme.textMuted), + ), + ), + ], + ), + ); + } +} diff --git a/design/flutter/widgets/inou_message.dart b/design/flutter/widgets/inou_message.dart new file mode 100644 index 0000000..f455b5f --- /dev/null +++ b/design/flutter/widgets/inou_message.dart @@ -0,0 +1,69 @@ +// AUTO-GENERATED widget — matches web .error/.info/.success +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum MessageType { error, info, success } + +class InouMessage extends StatelessWidget { + final String message; + final MessageType type; + + const InouMessage({ + super.key, + required this.message, + this.type = MessageType.info, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: style.background, + border: Border.all(color: style.border), + borderRadius: BorderRadius.circular(InouTheme.radiusMd), + ), + child: Text( + message, + style: InouTheme.bodyMedium.copyWith(color: style.foreground), + ), + ); + } + + _MessageStyle _getStyle() { + switch (type) { + case MessageType.error: + return _MessageStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: const Color(0xFFFECACA), + ); + case MessageType.info: + return _MessageStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + border: const Color(0xFFFDE68A), + ); + case MessageType.success: + return _MessageStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + border: const Color(0xFFA7F3D0), + ); + } + } +} + +class _MessageStyle { + final Color background; + final Color foreground; + final Color border; + + _MessageStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} diff --git a/design/flutter/widgets/widgets.dart b/design/flutter/widgets/widgets.dart new file mode 100644 index 0000000..d8729e2 --- /dev/null +++ b/design/flutter/widgets/widgets.dart @@ -0,0 +1,7 @@ +// Barrel file for all inou widgets +export 'inou_card.dart'; +export 'inou_button.dart'; +export 'inou_badge.dart'; +export 'inou_message.dart'; +export 'inou_input.dart'; +export 'inou_data_row.dart'; diff --git a/design/generate.js b/design/generate.js new file mode 100644 index 0000000..02b1ef5 --- /dev/null +++ b/design/generate.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** + * Design Token Generator + * Single source of truth → CSS variables + Flutter theme + * + * Usage: node generate.js + * Outputs: + * - tokens.css (CSS custom properties) + * - flutter/inou_theme.dart (Flutter ThemeData) + */ + +const fs = require('fs'); +const path = require('path'); + +const tokens = JSON.parse(fs.readFileSync(path.join(__dirname, 'tokens.json'), 'utf8')); + +// ============================================ +// CSS GENERATOR +// ============================================ +function generateCSS(tokens) { + const lines = [ + '/* AUTO-GENERATED from tokens.json — do not edit directly */', + '/* Run: node design/generate.js */', + '', + ':root {' + ]; + + // Colors + for (const [key, value] of Object.entries(tokens.colors)) { + const cssVar = `--${camelToKebab(key)}`; + lines.push(` ${cssVar}: ${value};`); + } + + lines.push(''); + + // Spacing + for (const [key, value] of Object.entries(tokens.spacing.scale)) { + lines.push(` --space-${key}: ${value}px;`); + } + + lines.push(''); + + // Radii + for (const [key, value] of Object.entries(tokens.radii)) { + if (key !== 'full') { + lines.push(` --radius-${key}: ${value}px;`); + } else { + lines.push(` --radius-${key}: ${value}px;`); + } + } + + lines.push(''); + + // Layout + lines.push(` --max-width: ${tokens.layout.maxWidth}px;`); + lines.push(` --max-width-narrow: ${tokens.layout.maxWidthNarrow}px;`); + lines.push(` --max-width-form: ${tokens.layout.maxWidthForm}px;`); + + lines.push('}'); + + return lines.join('\n'); +} + +// ============================================ +// FLUTTER GENERATOR +// ============================================ +function generateFlutter(tokens) { + const lines = [ + '// AUTO-GENERATED from tokens.json — do not edit directly', + '// Run: node design/generate.js', + '', + "import 'package:flutter/material.dart';", + "import 'package:google_fonts/google_fonts.dart';", + '', + '/// inou Design System', + '/// Single source of truth: design/tokens.json', + 'class InouTheme {', + ' InouTheme._();', + '', + ' // ============================================', + ' // COLORS', + ' // ============================================', + ]; + + // Colors as static constants + for (const [key, value] of Object.entries(tokens.colors)) { + const hex = value.replace('#', ''); + const alpha = hex.length === 6 ? 'FF' : ''; + lines.push(` static const Color ${key} = Color(0x${alpha}${hex.toUpperCase()});`); + } + + lines.push(''); + lines.push(' // Indicator colors (data sections)'); + for (const [key, value] of Object.entries(tokens.indicators)) { + const hex = value.replace('#', ''); + lines.push(` static const Color indicator${capitalize(key)} = Color(0xFF${hex.toUpperCase()});`); + } + + // Spacing + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // SPACING'); + lines.push(' // ============================================'); + for (const [key, value] of Object.entries(tokens.spacing.scale)) { + lines.push(` static const double space${capitalize(key)} = ${value}.0;`); + } + + // Radii + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // BORDER RADIUS'); + lines.push(' // ============================================'); + for (const [key, value] of Object.entries(tokens.radii)) { + lines.push(` static const double radius${capitalize(key)} = ${value}.0;`); + } + lines.push(` static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);`); + lines.push(` static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);`); + lines.push(` static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);`); + + // Layout + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // LAYOUT'); + lines.push(' // ============================================'); + lines.push(` static const double maxWidth = ${tokens.layout.maxWidth}.0;`); + lines.push(` static const double maxWidthNarrow = ${tokens.layout.maxWidthNarrow}.0;`); + lines.push(` static const double maxWidthForm = ${tokens.layout.maxWidthForm}.0;`); + + // Typography helpers + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // TYPOGRAPHY'); + lines.push(' // ============================================'); + lines.push(` static String get fontFamily => '${tokens.typography.fontFamily}';`); + lines.push(''); + + // Text styles + const textStyles = { + h1: { size: 36, weight: 300, spacing: -0.5 }, + h1Large: { size: 40, weight: 700 }, + h2: { size: 24, weight: 300, spacing: -0.3 }, + h3: { size: 18, weight: 500 }, + bodyLarge: { size: 16, weight: 400 }, + bodyMedium: { size: 15, weight: 400 }, + bodySmall: { size: 13, weight: 400 }, + labelLarge: { size: 15, weight: 500 }, + labelSmall: { size: 12, weight: 500, spacing: 1.5 }, + }; + + for (const [name, style] of Object.entries(textStyles)) { + const weight = `FontWeight.w${style.weight}`; + const spacing = style.spacing ? `, letterSpacing: ${style.spacing}` : ''; + lines.push(` static TextStyle get ${name} => GoogleFonts.sora(`); + lines.push(` fontSize: ${style.size}.0,`); + lines.push(` fontWeight: ${weight},`); + lines.push(` color: text${spacing},`); + lines.push(` );`); + lines.push(''); + } + + // Color scheme + lines.push(' // ============================================'); + lines.push(' // THEME DATA'); + lines.push(' // ============================================'); + lines.push(' static ThemeData get light => ThemeData('); + lines.push(' useMaterial3: true,'); + lines.push(' brightness: Brightness.light,'); + lines.push(' scaffoldBackgroundColor: bg,'); + lines.push(' colorScheme: ColorScheme.light('); + lines.push(' primary: accent,'); + lines.push(' onPrimary: Colors.white,'); + lines.push(' secondary: accentLight,'); + lines.push(' onSecondary: accent,'); + lines.push(' surface: bgCard,'); + lines.push(' onSurface: text,'); + lines.push(' error: danger,'); + lines.push(' onError: Colors.white,'); + lines.push(' outline: border,'); + lines.push(' ),'); + lines.push(' textTheme: TextTheme('); + lines.push(' displayLarge: h1Large,'); + lines.push(' displayMedium: h1,'); + lines.push(' headlineMedium: h2,'); + lines.push(' headlineSmall: h3,'); + lines.push(' bodyLarge: bodyLarge,'); + lines.push(' bodyMedium: bodyMedium,'); + lines.push(' bodySmall: bodySmall,'); + lines.push(' labelLarge: labelLarge,'); + lines.push(' labelSmall: labelSmall,'); + lines.push(' ),'); + lines.push(' appBarTheme: AppBarTheme('); + lines.push(' backgroundColor: bg,'); + lines.push(' foregroundColor: text,'); + lines.push(' elevation: 0,'); + lines.push(' centerTitle: false,'); + lines.push(' ),'); + lines.push(' cardTheme: CardTheme('); + lines.push(' color: bgCard,'); + lines.push(' elevation: 0,'); + lines.push(' shape: RoundedRectangleBorder('); + lines.push(' borderRadius: borderRadiusLg,'); + lines.push(' side: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' elevatedButtonTheme: ElevatedButtonThemeData('); + lines.push(' style: ElevatedButton.styleFrom('); + lines.push(' backgroundColor: accent,'); + lines.push(' foregroundColor: Colors.white,'); + lines.push(' elevation: 0,'); + lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),'); + lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),'); + lines.push(' textStyle: labelLarge,'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' outlinedButtonTheme: OutlinedButtonThemeData('); + lines.push(' style: OutlinedButton.styleFrom('); + lines.push(' foregroundColor: text,'); + lines.push(' side: BorderSide(color: border),'); + lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),'); + lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),'); + lines.push(' textStyle: labelLarge,'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' inputDecorationTheme: InputDecorationTheme('); + lines.push(' filled: true,'); + lines.push(' fillColor: bgCard,'); + lines.push(' border: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' enabledBorder: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' focusedBorder: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: accent, width: 2),'); + lines.push(' ),'); + lines.push(' contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd),'); + lines.push(' ),'); + lines.push(' dividerTheme: DividerThemeData('); + lines.push(' color: border,'); + lines.push(' thickness: 1,'); + lines.push(' ),'); + lines.push(' );'); + + lines.push('}'); + + return lines.join('\n'); +} + +// ============================================ +// UTILITIES +// ============================================ +function camelToKebab(str) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +// ============================================ +// MAIN +// ============================================ +function main() { + // Generate CSS + const css = generateCSS(tokens); + const cssPath = path.join(__dirname, 'tokens.css'); + fs.writeFileSync(cssPath, css); + console.log(`✓ Generated ${cssPath}`); + + // Generate Flutter + const flutter = generateFlutter(tokens); + const flutterDir = path.join(__dirname, 'flutter'); + if (!fs.existsSync(flutterDir)) { + fs.mkdirSync(flutterDir, { recursive: true }); + } + const flutterPath = path.join(flutterDir, 'inou_theme.dart'); + fs.writeFileSync(flutterPath, flutter); + console.log(`✓ Generated ${flutterPath}`); + + console.log('\nDesign tokens synced. Both CSS and Flutter use the same values.'); +} + +main(); diff --git a/design/tokens.css b/design/tokens.css new file mode 100644 index 0000000..3e7955d --- /dev/null +++ b/design/tokens.css @@ -0,0 +1,37 @@ +/* AUTO-GENERATED from tokens.json — do not edit directly */ +/* Run: node design/generate.js */ + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; + + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --space-xxxl: 48px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; + + --max-width: 1200px; + --max-width-narrow: 800px; + --max-width-form: 360px; +} \ No newline at end of file diff --git a/design/tokens.json b/design/tokens.json new file mode 100644 index 0000000..c3e2c96 --- /dev/null +++ b/design/tokens.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://inou.com/design-tokens.schema.json", + "name": "inou", + "version": "1.0.0", + + "colors": { + "bg": "#F8F7F6", + "bgCard": "#FFFFFF", + "border": "#E5E2DE", + "borderHover": "#C4BFB8", + "text": "#1C1917", + "textMuted": "#78716C", + "textSubtle": "#A8A29E", + "accent": "#B45309", + "accentHover": "#92400E", + "accentLight": "#FEF3C7", + "danger": "#DC2626", + "dangerLight": "#FEF2F2", + "success": "#059669", + "successLight": "#ECFDF5" + }, + + "typography": { + "fontFamily": "Sora", + "fontFamilyMono": "SF Mono, Monaco, Consolas, monospace", + "fontFamilyFallback": "-apple-system, BlinkMacSystemFont, sans-serif", + "baseFontSize": 15, + "lineHeight": 1.5, + "weights": { + "light": 300, + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "scale": { + "h1": { "size": "2.25rem", "weight": 300, "letterSpacing": "-0.03em" }, + "h1Large": { "size": "2.5rem", "weight": 700 }, + "h2": { "size": "1.5rem", "weight": 300, "letterSpacing": "-0.02em" }, + "h3": { "size": "1.125rem", "weight": 500 }, + "body": { "size": "1rem", "weight": 400 }, + "small": { "size": "0.85rem", "weight": 400 }, + "tiny": { "size": "0.75rem", "weight": 500, "letterSpacing": "0.1em", "transform": "uppercase" }, + "code": { "size": "0.85rem", "family": "mono" } + } + }, + + "spacing": { + "unit": 4, + "scale": { + "xs": 4, + "sm": 8, + "md": 12, + "lg": 16, + "xl": 24, + "xxl": 32, + "xxxl": 48 + } + }, + + "radii": { + "sm": 4, + "md": 6, + "lg": 8, + "xl": 12, + "full": 9999 + }, + + "shadows": { + "dropdown": "0 4px 12px rgba(0,0,0,0.1)", + "modal": "0 20px 25px -5px rgba(0,0,0,0.15)", + "card": "0 0 0 1px var(--accent)" + }, + + "layout": { + "maxWidth": 1200, + "maxWidthNarrow": 800, + "maxWidthForm": 360, + "navPadding": { "y": 12, "x": 24 }, + "containerPadding": { "y": 48, "x": 24 } + }, + + "components": { + "button": { + "padding": { "y": 10, "x": 18 }, + "paddingSmall": { "y": 6, "x": 12 }, + "fontSize": "1rem", + "fontWeight": 500, + "borderRadius": "md" + }, + "card": { + "padding": 16, + "borderRadius": "lg", + "borderWidth": 1 + }, + "input": { + "padding": { "y": 10, "x": 12 }, + "fontSize": "1rem", + "borderRadius": "md" + }, + "badge": { + "padding": { "y": 2, "x": 8 }, + "fontSize": "1rem", + "fontWeight": 500, + "borderRadius": "sm" + } + }, + + "indicators": { + "imaging": "#B45309", + "labs": "#059669", + "uploads": "#6366f1", + "vitals": "#ec4899", + "medications": "#8b5cf6", + "records": "#06b6d4", + "journal": "#f59e0b", + "privacy": "#64748b", + "genetics": "#10b981" + } +} diff --git a/docs/._.DS_Store b/docs/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/docs/._.DS_Store differ diff --git a/docs/soc2/._data-retention-policy.md b/docs/soc2/._data-retention-policy.md new file mode 100644 index 0000000..8b8912b Binary files /dev/null and b/docs/soc2/._data-retention-policy.md differ diff --git a/docs/soc2/._disaster-recovery-plan.md b/docs/soc2/._disaster-recovery-plan.md new file mode 100644 index 0000000..501595b Binary files /dev/null and b/docs/soc2/._disaster-recovery-plan.md differ diff --git a/docs/soc2/._incident-response-plan.md b/docs/soc2/._incident-response-plan.md new file mode 100644 index 0000000..a54397c Binary files /dev/null and b/docs/soc2/._incident-response-plan.md differ diff --git a/docs/soc2/._risk-assessment.md b/docs/soc2/._risk-assessment.md new file mode 100644 index 0000000..6ae2293 Binary files /dev/null and b/docs/soc2/._risk-assessment.md differ diff --git a/docs/soc2/._security-policy.md b/docs/soc2/._security-policy.md new file mode 100644 index 0000000..dad8132 Binary files /dev/null and b/docs/soc2/._security-policy.md differ diff --git a/docs/soc2/._soc2-self-assessment-2026.md b/docs/soc2/._soc2-self-assessment-2026.md new file mode 100644 index 0000000..50d9e9a Binary files /dev/null and b/docs/soc2/._soc2-self-assessment-2026.md differ diff --git a/docs/soc2/incident-response-plan.md b/docs/soc2/incident-response-plan.md index e8b5cb4..ab01fa3 100644 --- a/docs/soc2/incident-response-plan.md +++ b/docs/soc2/incident-response-plan.md @@ -39,7 +39,8 @@ All inou systems: | Role | Name | Email | Phone | |------|------|-------|-------| -| Incident Commander | Johan Jongsma | security@inou.com | Available on request | +| Incident Commander | Johan Jongsma | security@inou.com | Signal: +1 727-225-2475 | +| AI Operations | James ⚡ | Via OpenClaw | 24/7 automated | ### External Contacts @@ -56,22 +57,33 @@ All inou systems: | Security incidents | security@inou.com | | User support | support@inou.com | +### Alert Flow + +``` +Uptime Kuma (Zurich) → Webhook → OpenClaw Gateway → James AI → Signal (Johan) +Nuclei scan → James AI reviews → Signal alert (if critical/high) +``` + --- ## 5. Detection ### Automated Detection +- **Uptime Kuma (Zurich):** 24/7 availability monitoring, 60-second intervals +- **Nuclei scans:** Weekly + monthly vulnerability scanning from Zurich - **404 monitoring:** Alerts on suspicious path probes - **Tarpit triggers:** Logs known attack patterns (PHP probes, config access attempts) - **Rate limiting:** Flags excessive requests per IP - **Log analysis:** HTTP access logs reviewed for anomalies +- **James AI:** Receives alerts, triages, escalates critical issues ### Manual Detection - User reports of unauthorized access - Unexpected system behavior - External notification (security researcher, vendor) +- James AI analysis of scan results ### Indicators of Compromise diff --git a/docs/soc2/scans/2026-01/inou-com-20260131.txt b/docs/soc2/scans/2026-01/inou-com-20260131.txt new file mode 100644 index 0000000..3b86cbb --- /dev/null +++ b/docs/soc2/scans/2026-01/inou-com-20260131.txt @@ -0,0 +1,34 @@ +[missing-sri] [http] [info] https://inou.com ["https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap"] +[tls-version] [ssl] [info] inou.com:443 ["tls12"] +[tls-version] [ssl] [info] inou.com:443 ["tls13"] +[http-missing-security-headers:strict-transport-security] [http] [info] https://inou.com +[http-missing-security-headers:x-frame-options] [http] [info] https://inou.com +[http-missing-security-headers:referrer-policy] [http] [info] https://inou.com +[http-missing-security-headers:clear-site-data] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-opener-policy] [http] [info] https://inou.com +[http-missing-security-headers:content-security-policy] [http] [info] https://inou.com +[http-missing-security-headers:permissions-policy] [http] [info] https://inou.com +[http-missing-security-headers:x-content-type-options] [http] [info] https://inou.com +[http-missing-security-headers:x-permitted-cross-domain-policies] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-embedder-policy] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-resource-policy] [http] [info] https://inou.com +[oauth-authorization-server-exposure] [http] [info] https://inou.com/.well-known/oauth-authorization-server +[rdap-whois:registrationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2001-06-29T10:49:20Z"] +[rdap-whois:lastChangeDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2025-07-24T06:29:31Z"] +[rdap-whois:expirationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2026-06-29T10:49:20Z"] +[rdap-whois:nameServers] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["NS3.OPENPROVIDER.EU","NS1.OPENPROVIDER.NL","NS2.OPENPROVIDER.BE"] +[rdap-whois:secureDNS] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["false"] +[rdap-whois:status] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["client transfer prohibited"] +[tech-detect:caddy] [http] [info] https://inou.com +[tech-detect:google-font-api] [http] [info] https://inou.com +[robots-txt] [http] [info] https://inou.com/robots.txt +[robots-txt-endpoint:endpoints] [http] [info] https://inou.com/robots.txt ["/dashboard","/onboard","/verify","/start","/set-lang","/api/","/connect","/share","/invite","/login","/privacy-policy","/dossier"] +[spf-record-detect] [dns] [info] inou.com ["v=spf1 include:_spf.protonmail.ch mx ~all""] +[txt-fingerprint] [dns] [info] inou.com [""v=spf1 include:_spf.protonmail.ch mx ~all"",""protonmail-verification=da8cc10ce04b8fdf2ac85303e3283a537cd30f52"",""google-site-verification=d3PKH4M7jVH88dGfGfsqGM71xsEyvgOspxZPEevGrlc""] +[nameserver-fingerprint] [dns] [info] inou.com ["ns3.openprovider.eu.","ns2.openprovider.be.","ns1.openprovider.nl."] +[mx-fingerprint] [dns] [info] inou.com ["20 mailsec.protonmail.ch.","10 mail.protonmail.ch."] +[mx-service-detector:ProtonMail] [dns] [info] inou.com +[caa-fingerprint] [dns] [info] inou.com +[dmarc-detect] [dns] [info] _dmarc.inou.com [""v=DMARC1; p=reject;""] +[ssl-issuer] [ssl] [info] inou.com:443 ["ZeroSSL"] +[ssl-dns-names] [ssl] [info] inou.com:443 ["inou.com"] diff --git a/docs/soc2/scans/2026-01/report.md b/docs/soc2/scans/2026-01/report.md new file mode 100644 index 0000000..385cae7 --- /dev/null +++ b/docs/soc2/scans/2026-01/report.md @@ -0,0 +1,95 @@ +# Vulnerability Scan Report — January 2026 + +**Scan Date:** January 31, 2026 +**Target:** https://inou.com +**Scanner:** Nuclei (ProjectDiscovery) +**Scanner Location:** zurich.inou.com (Zürich, Switzerland) + +--- + +## Executive Summary + +| Severity | Count | +|----------|------:| +| 🔴 Critical | 0 | +| 🟠 High | 0 | +| 🟡 Medium | 0 | +| 🔵 Low | 0 | +| ⚪ Informational | 34 | + +**Result:** No exploitable vulnerabilities detected. All findings are informational. + +--- + +## Findings & Remediation + +### HTTP Security Headers (11 findings) + +| Header | Status | Date | +|--------|:------:|------| +| Strict-Transport-Security | ✅ Remediated | Feb 1, 2026 | +| X-Content-Type-Options | ✅ Remediated | Feb 1, 2026 | +| X-Frame-Options | ✅ Remediated | Feb 1, 2026 | +| Referrer-Policy | ✅ Remediated | Feb 1, 2026 | +| Permissions-Policy | ✅ Remediated | Feb 1, 2026 | +| Cross-Origin-Opener-Policy | ✅ Remediated | Feb 1, 2026 | +| Cross-Origin-Resource-Policy | ✅ Remediated | Feb 1, 2026 | +| X-Permitted-Cross-Domain-Policies | ✅ Remediated | Feb 1, 2026 | +| Content-Security-Policy | ⏸️ Deferred | Requires app tuning | +| Cross-Origin-Embedder-Policy | ⏸️ Skipped | Breaks Google Fonts | +| Clear-Site-Data | ⏸️ N/A | Logout only | + +**Remediation:** Added headers to Caddy reverse proxy (192.168.0.2). + +### TLS/SSL (3 findings) + +| Finding | Status | +|---------|:------:| +| TLS 1.2 supported | ✅ Expected | +| TLS 1.3 supported | ✅ Expected | +| ZeroSSL certificate | ✅ Expected | + +### DNS Configuration (10 findings) + +| Finding | Status | +|---------|:------:| +| SPF configured | ✅ Good | +| DMARC (p=reject) | ✅ Good | +| ProtonMail MX | ✅ Expected | +| DNSSEC not enabled | ⏸️ Low priority | + +### Other Informational (10 findings) + +- Technology detection (Caddy, Google Fonts) — expected +- robots.txt endpoints — expected +- OAuth discovery endpoint — expected +- Domain WHOIS metadata — informational + +--- + +## Actions Taken + +| Date | Action | +|------|--------| +| Jan 31, 2026 | Initial baseline scan from Zurich | +| Feb 1, 2026 | Added 8 HTTP security headers to Caddy | +| Feb 1, 2026 | Verified headers via curl | +| Feb 1, 2026 | Set up automated weekly/monthly scans | + +--- + +## Next Steps + +1. **P2:** Implement Content-Security-Policy (requires app testing) +2. **P3:** Enable DNSSEC via Openprovider +3. **Continue:** Weekly and monthly automated scans + +--- + +## Raw Output + +See: [inou-com-20260131.txt](inou-com-20260131.txt) + +--- + +*Report generated by James ⚡ (AI Operations)* diff --git a/docs/soc2/security-policy.md b/docs/soc2/security-policy.md index 3e7d06a..fb1d0c1 100644 --- a/docs/soc2/security-policy.md +++ b/docs/soc2/security-policy.md @@ -265,6 +265,40 @@ See: [Disaster Recovery Plan](disaster-recovery-plan.md) | Suspicious 404s | System notification | | Tarpit triggers | Logged | | Failed logins | Fail2ban action | +| Service outage | Uptime Kuma → James AI → Signal | +| Critical vulnerability | Nuclei → James AI → Signal | + +### External Monitoring (Zurich) + +| Service | Location | Purpose | +|---------|----------|---------| +| Uptime Kuma | zurich.inou.com:3001 | 24/7 availability monitoring | +| Nuclei | zurich.inou.com | Vulnerability scanning | + +--- + +## 13a. Vulnerability Management + +### Scanning Program + +| Schedule | Type | Tool | Action | +|----------|------|------|--------| +| Monthly (1st, 9am ET) | Full scan | Nuclei | Report + remediate | +| Weekly (Sun, 10am ET) | Critical/High/Medium | Nuclei | Alert if found | +| Pre-release | Full scan | Nuclei | Gate deployment | + +### Remediation SLAs + +| Severity | Response | Resolution | +|----------|----------|------------| +| Critical | 4 hours | 24 hours | +| High | 24 hours | 7 days | +| Medium | 7 days | 30 days | +| Low | 30 days | 90 days | + +### Scan Results + +Results stored in: `docs/soc2/scans/YYYY-MM/` --- diff --git a/docs/soc2/soc2-self-assessment-2026.md b/docs/soc2/soc2-self-assessment-2026.md index 0726916..fac7087 100644 --- a/docs/soc2/soc2-self-assessment-2026.md +++ b/docs/soc2/soc2-self-assessment-2026.md @@ -1,10 +1,10 @@ # SOC 2 Type II Self-Assessment Report **Organization:** inou -**Report Period:** January 1, 2026 to January 25, 2026 -**Assessment Date:** January 25, 2026 +**Report Period:** January 1, 2026 - Ongoing +**Assessment Date:** January 25, 2026 (Updated February 1, 2026) **Prepared By:** Johan Jongsma, Founder & Owner -**Report Version:** 1.0 +**Report Version:** 1.1 --- @@ -57,9 +57,18 @@ inou is a medical imaging platform with AI-powered health data exploration. This | Control | Status | Evidence | |---------|--------|----------| -| CC4.1 Ongoing monitoring | Implemented | HTTP logs, 404 alerts, rate limiting | +| CC4.1 Ongoing monitoring | Implemented | HTTP logs, 404 alerts, rate limiting, Uptime Kuma (Zurich), Nuclei scans | | CC4.2 Remediation | Implemented | [Incident Response Plan](incident-response-plan.md) | +#### External Monitoring (Added February 2026) + +| Tool | Location | Purpose | Frequency | +|------|----------|---------|-----------| +| Uptime Kuma | zurich.inou.com:3001 | Availability monitoring | Continuous (60s) | +| Nuclei | zurich.inou.com | Vulnerability scanning | Weekly + Monthly | + +**Why Zurich?** External monitoring from Switzerland provides geographic independence and simulates external attacker perspective for vulnerability assessment. + ### CC5: Control Activities | Control | Status | Evidence | @@ -85,11 +94,32 @@ inou is a medical imaging platform with AI-powered health data exploration. This | Control | Status | Evidence | |---------|--------|----------| -| CC7.1 Anomaly detection | Implemented | Tarpit, 404 monitoring, rate limiting | -| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications | +| CC7.1 Anomaly detection | Implemented | Tarpit, 404 monitoring, rate limiting, Uptime Kuma alerts | +| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications, Uptime Kuma webhook → James AI | | CC7.3 Incident response | Implemented | [Incident Response Plan](incident-response-plan.md) | | CC7.4 Recovery | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) | +#### Vulnerability Scanning Program (Added February 2026) + +| Schedule | Scan Type | Tool | Targets | +|----------|-----------|------|---------| +| Monthly (1st) | Full vulnerability scan | Nuclei | inou.com | +| Weekly (Sunday) | Critical/High/Medium | Nuclei | inou.com | +| Ad-hoc | Pre-release | Nuclei | inou.com, dev.inou.com | + +**Baseline Scan (January 31, 2026):** +- 34 findings, all informational +- No critical, high, or medium vulnerabilities +- 11 missing HTTP security headers → 8 remediated (February 1, 2026) + +#### AI Operations Assistant (Added February 2026) + +James (AI assistant via OpenClaw) provides 24/7 operational support: +- Receives Uptime Kuma alerts via webhook +- Runs and reviews vulnerability scans +- Applies security remediations +- Escalates to owner via Signal for critical issues + ### CC8: Change Management | Control | Status | Evidence | @@ -284,7 +314,20 @@ Failed or decommissioned storage media is physically destroyed, rendering data u | Automatic updates | Enabled | | Firewall | UFW active, default deny incoming | | SSH | Password auth disabled, rate limited | -| TLS | Automatic HTTPS via Let's Encrypt, TLS 1.2+ | +| TLS | Automatic HTTPS via ZeroSSL, TLS 1.2+ | + +#### HTTP Security Headers (Added February 1, 2026) + +| Header | Value | +|--------|-------| +| Strict-Transport-Security | `max-age=31536000; includeSubDomains; preload` | +| X-Content-Type-Options | `nosniff` | +| X-Frame-Options | `SAMEORIGIN` | +| Referrer-Policy | `strict-origin-when-cross-origin` | +| Permissions-Policy | `geolocation=(), microphone=(), camera=()` | +| Cross-Origin-Opener-Policy | `same-origin-allow-popups` | +| Cross-Origin-Resource-Policy | `same-origin` | +| X-Permitted-Cross-Domain-Policies | `none` | ### Network Security (UDM-Pro) @@ -350,7 +393,7 @@ Failed or decommissioned storage media is physically destroyed, rendering data u ## 10. Action Items -### Completed This Assessment +### Completed This Assessment (January 2026) | Item | Status | |------|--------| @@ -363,6 +406,17 @@ Failed or decommissioned storage media is physically destroyed, rendering data u | Vendor assessment | Documented | | OS hardening documentation | Documented | +### Completed (February 2026) + +| Item | Status | Date | +|------|--------|------| +| External vulnerability scanning | Nuclei from Zurich, automated | Feb 1, 2026 | +| HTTP security headers | 8 headers added to Caddy | Feb 1, 2026 | +| External availability monitoring | Uptime Kuma from Zurich | Feb 1, 2026 | +| Automated alerting | Webhook → James AI → Signal | Feb 1, 2026 | +| Weekly vulnerability scan | Cron job (Sundays 10am ET) | Feb 1, 2026 | +| Monthly vulnerability scan | Cron job (1st, 9am ET) | Feb 1, 2026 | + ### Recommended Actions | Item | Priority | Target Date | @@ -370,7 +424,8 @@ Failed or decommissioned storage media is physically destroyed, rendering data u | Perform backup restore test | P1 | Q1 2026 | | Complete audit logging in `lib/v2.go` | P2 | Q1 2026 | | Implement key rotation procedure | P2 | Q2 2026 | -| Schedule penetration test | P2 | Q2 2026 | +| Add Content-Security-Policy header | P2 | Q1 2026 | +| Enable DNSSEC on inou.com | P3 | Q2 2026 | | Evaluate cyber liability insurance | P3 | Q2 2026 | --- @@ -424,6 +479,14 @@ Failed or decommissioned storage media is physically destroyed, rendering data u | Access review | Quarterly | January 2026 | April 2026 | | Penetration test | Annually | Not yet | Q2 2026 | +### Automated Security Testing + +| Test | Frequency | Last Run | Next Run | +|------|-----------|----------|----------| +| Nuclei full scan | Monthly (1st) | Jan 31, 2026 | Feb 1, 2026 | +| Nuclei light scan | Weekly (Sun) | Feb 1, 2026 | Feb 2, 2026 | +| Uptime monitoring | Continuous | Live | Live | + --- ## 13. Conclusion diff --git a/inou.db b/inou.db new file mode 100644 index 0000000..788fb97 Binary files /dev/null and b/inou.db differ diff --git a/inou.mcpb b/inou.mcpb new file mode 100644 index 0000000..8a1397e Binary files /dev/null and b/inou.mcpb differ diff --git a/lang/da.yaml b/lang/da.yaml new file mode 100644 index 0000000..aa22b64 --- /dev/null +++ b/lang/da.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Dine sundhedsdata." +headline_2: "Din AI." +headline_3: "Dine svar." +intro: "Upload billeddiagnostik, laboratorieresultater og mere. Forbind din AI for at hjælpe dig med at forstå, hvad du ser på." +email: "E-mail" +get_started: "Kom i gang" +data_yours: "Dine data forbliver dine" +never_training: "Bruges aldrig til træning" +never_training_desc: "Dine billeder bruges aldrig til at træne AI-modeller." +never_shared: "Deles aldrig" +never_shared_desc: "Vi deler aldrig dine data med nogen." +encrypted: "Krypteret lagring" +encrypted_desc: "Alle data krypteret i hvile." +delete: "Slet når som helst" +delete_desc: "Dine data, din kontrol." + +# Verify +check_email: "Tjek din e-mail" +code_sent_to: "Vi har sendt en 6-cifret kode til" +verification_code: "Bekræftelseskode" +verify: "Bekræft" +use_different_email: "Brug en anden e-mail" +invalid_code: "Ugyldig eller udløbet kode. Prøv igen." + +# Onboard +create_dossier: "Opret din dosje" +create_profile_intro: "Fortæl os om dig selv for at komme i gang." +name: "Navn" +name_placeholder: "Dit navn" +date_of_birth: "Fødselsdato" +sex_at_birth: "Køn ved fødslen" +female: "Kvinde" +male: "Mand" +create_my_dossier: "Opret min dosje" + +# Minor error +must_be_18: "Du skal være 18 for at oprette en konto" +minor_explanation: "Hvis du opretter dette for en anden, start med din egen profil først. Dette sikrer, at kun du kan få adgang til deres sundhedsdata." +minor_next_steps: "Efter at have oprettet din dosje kan du tilføje andre." +use_different_dob: "Brug en anden fødselsdato" + +# Minor login block +minor_login_blocked: "Du skal være 18 for at logge ind" +minor_ask_guardian: "Bed %s om adgang til din dosje." +minor_ask_guardian_generic: "Bed en forælder eller værge om adgang til din dosje." + +# Dashboard +dossiers: "Dosjer" +dossiers_intro: "Administrer sundhedsdata for dig selv eller andre" +you: "dig" +view: "Vis" +save: "Gem" +cancel: "Annuller" +add_dossier: "Tilføj dosje" +edit_dossier: "Rediger dosje" +care: "pleje" +logout: "Log ud" + +# Profile detail +back_to_dossiers: "Tilbage til dosjer" +born: "Født" +no_access_yet: "Kun du har adgang." +people_with_access: "Personer med adgang" +share_access: "Del adgang" +can_edit: "kan tilføje data" +remove: "Fjern" +confirm_revoke: "Fjern adgang?" + +# Dossier sections +section_imaging: "Billeddiagnostik" +section_labs: "Lab" +section_uploads: "Uploads" +section_vitals: "Vitale tegn" +section_medications: "Medicin" +section_records: "Journaler" +section_journal: "Dagbog" +section_genetics: "Genetik" +section_privacy: "Privatliv" + +# Section summaries +imaging_summary: "%d undersøgelser · %d snit" +no_imaging: "Ingen billeddata" +no_lab_data: "Ingen labdata" +no_genetics: "Ingen genetiske data" +no_files: "Ingen filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtryk, puls, SpO₂, vægt, blodsukker" +medications_desc: "Recepter og kosttilskud" +records_desc: "Kliniske noter og journaler" +journal_desc: "Symptomer, smerte og observationer" + +# Buttons and actions +open_viewer: "Åbn visning" +manage: "Administrer" +show_all_studies: "Vis alle %d undersøgelser..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Upload sundhedsdata" +upload_files_intro: "Upload medicinsk billeddiagnostik, laboratorieresultater, genomfiler eller sundhedsrelaterede dokumenter." +upload_hint_broad: "DICOM, PDF, CSV, VCF og mere" +uploading: "Uploader..." +files_uploaded: "filer uploadet" +upload_scans: "Upload scanninger" +upload_scans_intro: "Upload en mappe med DICOM-filer fra din billedundersøgelse." +upload_drop: "Klik eller træk en mappe hertil" +upload_hint: "Kun DICOM-mapper" + +# Add profile +add_dossier_intro: "Tilføj nogen, hvis sundhedsdata du vil administrere." +email_optional: "E-mail (valgfrit)" +email_optional_hint: "Hvis de er 18, kan de logge ind selv" +your_relation: "Dit forhold til dem" +select_relation: "Vælg..." +i_provide_care: "Jeg yder pleje til denne person" +i_am_their: "Jeg er deres..." + +# Share access +share_access_intro: "Inviter nogen til at få adgang" +their_relation: "Deres forhold til denne person" +can_add_data: "Kan tilføje data (kosttilskud, noter, osv.)" +send_invitation: "Send invitation" +back_to_dossier: "Tilbage til dosje" + +# Relations +my_role: "min rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s tilføjede dig til inou" +invite_email_body: "%s tilføjede din sundhedsdosje til inou, så du kan se og administrere dine medicinske data." +invite_email_cta: "Log ind for at se" +continue: "Fortsæt" + +# Access management +people_with_access_count: "personer med adgang" +view_audit_log: "Vis aktivitetslog" +export_data: "Download my data" +relation_with: "Forhold til" +audit_log: "Aktivitetslog" +audit_log_intro: "Aktivitetshistorik for" +audit_log_desc: "Spor hvem der har haft adgang til eller ændret denne dosje" + +# Install / Connect +install_title: "Forbind til Claude" +install_intro: "Opsæt inou-broen for at lade Claude analysere dine sundhedsdata" +install_step1: "Trin 1: Download" +install_step1_desc: "Hent broen til din platform" +install_download_intro: "Download inou-broen til dit operativsystem:" +install_step2: "Trin 2: Konfigurer" +install_step2_desc: "Tilføj til Claude Desktop-konfigurationen" +install_config_intro: "Tilføj dette til din Claude Desktop-konfigurationsfil:" +install_step3: "Trin 3: Test" +install_step3_desc: "Bekræft forbindelsen" +install_test_intro: "Genstart Claude Desktop og spørg: 'Vis mig mine inou-profiler'" +nav_install: "Forbind til Claude" +nav_home: "Hjem" + +# Status +pending: "afventer" +rate_limit_exceeded: "For mange tilmeldingsforsøg fra din placering. Prøv igen i morgen." + +# Sex display +sex_male: "mand" +sex_female: "kvinde" +sex_na: "andet" + +# Friend invite email +friend_invite_subject: "Tjek dette ud — %s" +friend_invite_p1: "Jeg bruger inou, den sikre måde at opbevare sundhedsdata og udforske dem med AI. Det holder al min families sundhedsinformation ét sted — billedstudier, laboratorieresultater, journaler — og jeg tænkte, det måske også kunne være nyttigt for dig." +friend_invite_p2: "Den virkelige styrke ligger i at kunne bruge AI til at forstå det hele: forstå hvad en rapport faktisk betyder, opdage tendenser over tid, eller bare stille spørgsmål på almindeligt dansk og få klare svar." +friend_invite_btn: "Opdag inou" +friend_invite_dear: "Hej %s," +rel_0: "du" +rel_1: "Forælder" +rel_2: "Barn" +rel_3: "Ægtefælle" +rel_4: "Søskende" +rel_5: "Værge" +rel_6: "Omsorgsgiver" +rel_7: "Coach" +rel_8: "Læge" +rel_9: "Ven" +rel_10: "Andet" +rel_99: "Demo" +select_relation: "Vælg relation..." + +# Kategorier +category000: Billeddiagnostik +category001: Dokument +category002: Laboratorieresultat +category003: Genom +category004: Upload +category005: Konsultation +category006: Diagnose +category007: Billedresultat +category008: EEG-resultat +category009: Vitalværdi +category010: Motion +category011: Medicin +category012: Tilskud +category013: Ernæring +category014: Fertilitet +category015: Symptom +category016: Note +category017: Sygehistorie +category018: Familiehistorie +category019: Kirurgi +category020: Hospitalsindlæggelse +category021: Fødselsdata +category022: Medicinsk udstyr +category023: Terapi +category024: Vurdering +category025: Sundhedsudbyder +category026: Spørgsmål + +# Genome +genome_english_only: "Al genetisk information er på engelsk. Brug Claude til at diskutere det på dansk." +genome_variants: "varianter" +genome_hidden: "skjulte" +genome_show_all_categories: "Vis alle %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/de.yaml b/lang/de.yaml new file mode 100644 index 0000000..aa9a447 --- /dev/null +++ b/lang/de.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Ihre Gesundheitsdaten." +headline_2: "Ihre KI." +headline_3: "Ihre Antworten." +intro: "Laden Sie Bildgebung, Laborergebnisse und mehr hoch. Verbinden Sie Ihre KI, um zu verstehen, was Sie sehen." +email: "E-Mail" +get_started: "Loslegen" +data_yours: "Ihre Daten bleiben Ihre" +never_training: "Nie für Training verwendet" +never_training_desc: "Ihre Bilder werden nie zum Trainieren von KI-Modellen verwendet." +never_shared: "Nie geteilt" +never_shared_desc: "Wir teilen Ihre Daten mit niemandem." +encrypted: "Verschlüsselte Speicherung" +encrypted_desc: "Alle Daten werden verschlüsselt gespeichert." +delete: "Jederzeit löschen" +delete_desc: "Ihre Daten, Ihre Kontrolle." + +# Verify +check_email: "Überprüfen Sie Ihre E-Mail" +code_sent_to: "Wir haben einen 6-stelligen Code gesendet an" +verification_code: "Bestätigungscode" +verify: "Bestätigen" +use_different_email: "Andere E-Mail verwenden" +invalid_code: "Ungültiger oder abgelaufener Code. Bitte versuchen Sie es erneut." + +# Onboard +create_dossier: "Erstellen Sie Ihr Dossier" +create_profile_intro: "Erzählen Sie uns von sich, um loszulegen." +name: "Name" +name_placeholder: "Ihr Name" +date_of_birth: "Geburtsdatum" +sex_at_birth: "Geschlecht bei Geburt" +female: "Weiblich" +male: "Männlich" +create_my_dossier: "Mein Dossier erstellen" + +# Minor error +must_be_18: "Sie müssen 18 sein, um ein Konto zu erstellen" +minor_explanation: "Wenn Sie dies für jemand anderen einrichten, beginnen Sie zuerst mit Ihrem eigenen Profil. So stellen Sie sicher, dass nur Sie auf deren Gesundheitsdaten zugreifen können." +minor_next_steps: "Nach der Erstellung Ihres Dossiers können Sie weitere hinzufügen." +use_different_dob: "Anderes Geburtsdatum verwenden" + +# Minor login block +minor_login_blocked: "Sie müssen 18 sein, um sich anzumelden" +minor_ask_guardian: "Bitten Sie %s, auf Ihr Dossier zuzugreifen." +minor_ask_guardian_generic: "Bitten Sie einen Elternteil oder Vormund, auf Ihr Dossier zuzugreifen." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Verwalten Sie Gesundheitsdaten für sich selbst oder andere" +you: "Sie" +view: "Ansehen" +save: "Speichern" +cancel: "Abbrechen" +add_dossier: "Dossier hinzufügen" +edit_dossier: "Dossier bearbeiten" +care: "Pflege" +logout: "Abmelden" + +# Profile detail +back_to_dossiers: "Zurück zu Dossiers" +born: "Geboren" +no_access_yet: "Nur Sie haben Zugriff." +people_with_access: "Personen mit Zugriff" +share_access: "Zugriff teilen" +can_edit: "kann Daten hinzufügen" +remove: "Entfernen" +confirm_revoke: "Zugriff entfernen?" + +# Dossier sections +section_imaging: "Bildgebung" +section_labs: "Labor" +section_uploads: "Uploads" +section_vitals: "Vitalwerte" +section_medications: "Medikamente" +section_records: "Unterlagen" +section_journal: "Tagebuch" +section_genetics: "Genetik" +section_privacy: "Datenschutz" + +# Section summaries +imaging_summary: "%d Studien · %d Schichten" +no_imaging: "Keine Bildgebungsdaten" +no_lab_data: "Keine Labordaten" +no_genetics: "Keine genetischen Daten" +no_files: "Keine Dateien" +no_upload_access: "You don't have permission to upload" +files_summary: "%d Dateien (%s)" +series_count: "%d Serien" +vitals_desc: "Blutdruck, Herzfrequenz, SpO₂, Gewicht, Glukose" +medications_desc: "Rezepte und Nahrungsergänzungsmittel" +records_desc: "Klinische Notizen und Krankenakten" +journal_desc: "Symptome, Schmerzen und Beobachtungen" + +# Buttons and actions +open_viewer: "Viewer öffnen" +manage: "Verwalten" +show_all_studies: "Alle %d Studien anzeigen..." +coming_soon: "Demnächst" + +# Upload page +upload_files: "Gesundheitsdaten hochladen" +upload_files_intro: "Laden Sie medizinische Bildgebung, Laborergebnisse, Genomdateien oder andere gesundheitsbezogene Dokumente hoch." +upload_hint_broad: "DICOM, PDF, CSV, VCF und mehr" +uploading: "Wird hochgeladen..." +files_uploaded: "Dateien hochgeladen" +upload_scans: "Scans hochladen" +upload_scans_intro: "Laden Sie einen Ordner mit DICOM-Dateien aus Ihrer Bildgebungsstudie hoch." +upload_drop: "Klicken oder Ordner hierher ziehen" +upload_hint: "Nur DICOM-Ordner" + +# Add profile +add_dossier_intro: "Fügen Sie jemanden hinzu, dessen Gesundheitsdaten Sie verwalten möchten." +email_optional: "E-Mail (optional)" +email_optional_hint: "Wenn sie 18 sind, können sie sich selbst anmelden" +your_relation: "Ihre Beziehung zu dieser Person" +select_relation: "Auswählen..." +i_provide_care: "Ich pflege diese Person" +i_am_their: "Ich bin deren..." + +# Share access +share_access_intro: "Jemanden zum Zugriff einladen" +their_relation: "Deren Beziehung zu dieser Person" +can_add_data: "Kann Daten hinzufügen (Nahrungsergänzungsmittel, Notizen, usw.)" +send_invitation: "Einladung senden" +back_to_dossier: "Zurück zum Dossier" + +# Relations +my_role: "meine Rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s hat Sie zu inou hinzugefügt" +invite_email_body: "%s hat Ihr Gesundheitsdossier zu inou hinzugefügt, damit Sie Ihre medizinischen Daten einsehen und verwalten können." +invite_email_cta: "Anmelden zum Ansehen" +continue: "Weiter" + +# Access management +people_with_access_count: "Personen mit Zugriff" +view_audit_log: "Aktivitätsprotokoll ansehen" +export_data: "Download my data" +relation_with: "Beziehung zu" +audit_log: "Aktivitätsprotokoll" +audit_log_intro: "Aktivitätsverlauf für" +audit_log_desc: "Verfolgen Sie, wer auf dieses Dossier zugegriffen oder es geändert hat" + +# Install / Connect +install_title: "Mit Claude verbinden" +install_intro: "Richten Sie die inou-Bridge ein, damit Claude Ihre Gesundheitsdaten analysieren kann" +install_step1: "Schritt 1: Herunterladen" +install_step1_desc: "Laden Sie die Bridge für Ihre Plattform herunter" +install_download_intro: "Laden Sie die inou-Bridge für Ihr Betriebssystem herunter:" +install_step2: "Schritt 2: Konfigurieren" +install_step2_desc: "Zur Claude Desktop-Konfiguration hinzufügen" +install_config_intro: "Fügen Sie dies zu Ihrer Claude Desktop-Konfigurationsdatei hinzu:" +install_step3: "Schritt 3: Testen" +install_step3_desc: "Verbindung überprüfen" +install_test_intro: "Starten Sie Claude Desktop neu und fragen Sie: 'Zeige mir meine inou-Profile'" +nav_install: "Mit Claude verbinden" +nav_home: "Startseite" + +# Status +pending: "ausstehend" +rate_limit_exceeded: "Zu viele Anmeldeversuche von Ihrem Standort. Bitte versuchen Sie es morgen erneut." + +# Sex display +sex_male: "männlich" +sex_female: "weiblich" +sex_na: "andere" + +# Friend invite email +friend_invite_subject: "Schau dir das an — %s" +friend_invite_p1: "Ich nutze inou, die sichere Art, Gesundheitsdaten zu speichern und mit KI zu erkunden. Es hält alle Gesundheitsinformationen meiner Familie an einem Ort — Bildgebung, Laborergebnisse, Krankenakten — und ich dachte, es könnte auch für dich nützlich sein." +friend_invite_p2: "Die wahre Stärke liegt darin, KI nutzen zu können, um alles zu verstehen: zu verstehen, was ein Bericht wirklich bedeutet, Trends über die Zeit zu erkennen, oder einfach Fragen in normaler Sprache zu stellen und klare Antworten zu bekommen." +friend_invite_btn: "Entdecke inou" +friend_invite_dear: "Liebe/r %s," +rel_0: "du" +rel_1: "Elternteil" +rel_2: "Kind" +rel_3: "Ehepartner" +rel_4: "Geschwister" +rel_5: "Vormund" +rel_6: "Betreuer" +rel_7: "Coach" +rel_8: "Arzt" +rel_9: "Freund" +rel_10: "Andere" +rel_99: "Demo" +select_relation: "Beziehung auswählen..." + +# Kategorien +category000: Bildgebung +category001: Dokument +category002: Laborergebnis +category003: Genom +category004: Upload +category005: Konsultation +category006: Diagnose +category007: Bildgebungsergebnis +category008: EEG-Ergebnis +category009: Vitalwert +category010: Bewegung +category011: Medikament +category012: Nahrungsergänzung +category013: Ernährung +category014: Fruchtbarkeit +category015: Symptom +category016: Notiz +category017: Krankengeschichte +category018: Familienanamnese +category019: Operation +category020: Krankenhausaufenthalt +category021: Geburtsdaten +category022: Medizinisches Gerät +category023: Therapie +category024: Bewertung +category025: Gesundheitsdienstleister +category026: Frage + +# Genome +genome_english_only: "Alle genetischen Informationen sind auf Englisch. Verwenden Sie Claude, um sie auf Deutsch zu besprechen." +genome_variants: "Varianten" +genome_hidden: "verborgen" +genome_show_all_categories: "Alle %d Kategorien anzeigen" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/en.yaml b/lang/en.yaml new file mode 100644 index 0000000..9d5e38a --- /dev/null +++ b/lang/en.yaml @@ -0,0 +1,269 @@ +# Landing +headline_1: "Your health data." +headline_2: "Your AI." +headline_3: "Your answers." +intro: "Upload imaging, labs, and more. Connect your AI to help you understand what you're looking at." +email: "Email" +get_started: "Get started" +data_yours: "Your data stays yours" +never_training: "Never used for training" +never_training_desc: "Your images are never used to train AI models." +never_shared: "Never shared" +never_shared_desc: "We never share your data with anyone." +encrypted: "Military-grade encryption" +encrypted_desc: "At rest and in transit. Your data never travels unprotected." +delete: "Delete anytime" +delete_desc: "Your data, your control." + +# Verify +check_email: "Check your email" +code_sent_to: "We sent a 6-digit code to" +verification_code: "Verification code" +verify: "Verify" +use_different_email: "Use a different email" +invalid_code: "Invalid or expired code. Please try again." + +# Onboard +create_dossier: "Create your dossier" +create_profile_intro: "Tell us about yourself to get started." +name: "Name" +name_placeholder: "Your name" +date_of_birth: "Date of birth" +sex_at_birth: "Sex at birth" +female: "Female" +male: "Male" +create_my_dossier: "Create my dossier" + +# Minor error +must_be_18: "You must be 18 to create an account" +minor_explanation: "If you're setting this up for someone else, start with your own profile first. This ensures only you can access their health data." +minor_next_steps: "After creating your dossier, you can add others." +use_different_dob: "Use a different date of birth" + +# Minor login block +minor_login_blocked: "You must be 18 to log in" +minor_ask_guardian: "Ask %s to access your dossier." +minor_ask_guardian_generic: "Ask a parent or guardian to access your dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Manage health data for yourself or others" +you: "you" +view: "View" +save: "Save" +cancel: "Cancel" +add_dossier: "Add dossier" +edit_dossier: "Edit dossier" +care: "care" +logout: "Sign out" + +# Profile detail +back_to_dossiers: "Back to dossiers" +born: "Born" +no_access_yet: "Only you have access." +people_with_access: "People with access" +share_access: "Share access" +manage_permissions: "Manage permissions" +can_edit: "can add data" +remove: "Remove" +confirm_revoke: "Remove access?" + +# Dossier sections +section_imaging: "Imaging" +section_labs: "Labs" +section_uploads: "Uploads" +section_vitals: "Vitals" +section_medications: "Medications" +section_records: "Records" +section_journal: "Journal" + +# Section summaries +imaging_summary: "%d studies · %d slices" +no_imaging: "No imaging data" +no_lab_data: "No lab data" +no_files: "No files" +no_upload_access: "You don't have permission to upload" +files_summary: "%d files (%s)" +series_count: "%d series" +vitals_desc: "Blood pressure, heart rate, SpO₂, weight, glucose" +medications_desc: "Prescriptions and supplements" +records_desc: "Clinical notes and medical records" +journal_desc: "Symptoms, pain, and observations" + +# Buttons and actions +open_viewer: "Open viewer" +manage: "Manage" +show_all_studies: "Show all %d studies..." +coming_soon: "Coming soon" + +# Upload page +upload_files: "Upload health data" +upload_files_intro: "Upload medical imaging, lab results, genome files, or any health-related documents." +upload_hint_broad: "DICOM, PDF, CSV, VCF, and more" +uploading: "Uploading..." +files_uploaded: "files uploaded" +upload_scans: "Upload scans" +upload_scans_intro: "Upload a folder containing DICOM files from your imaging study." +upload_drop: "Click or drag a folder here" +upload_hint: "DICOM folders only" + +# Add profile +add_dossier_intro: "Add someone whose health data you want to manage." +email_optional: "Email (optional)" +email_optional_hint: "If they're 18+, they can log in themselves" +your_relation: "Your relationship to them" +select_relation: "Select..." +i_provide_care: "I provide care for this person" + +# Share access +share_access_intro: "Invite someone to access" +their_relation: "Their relationship to this person" +can_add_data: "Can add data (supplements, notes, etc.)" +send_invitation: "Send invitation" +back_to_dossier: "Back to dossier" + +# Relations + +# Invitation email +invite_email_subject: "%s added you to inou" +invite_email_body: "%s added your health dossier to inou so you can view and manage your medical data." +invite_email_cta: "Sign in to view" +continue: "Continue" +i_am_their: "I am their..." + +# Simple relation names (for display) +my_role: "my role" +role: "role" +section_privacy: "Privacy" +people_with_access_count: "people with access" +view_audit_log: "View audit log" +export_data: "Download my data" +relation_with: "Relation with" +audit_log: "Audit log" +audit_log_intro: "Activity history for" +audit_log_desc: "Track who accessed or modified this dossier" + +# Permissions (RBAC) +permissions_title: "Permissions" +permissions_subtitle: "Control who can access this dossier and what they can do" +current_access: "Current access" +grant_access: "Grant access" +no_grantees: "No one else has access to this dossier." +person_email: "Email address" +person_email_hint: "If they don't have an account, they'll be invited to create one." +person_name: "Name" +select_role: "Select a role..." +custom_role: "Custom permissions" +permissions: "Permissions" +op_read: "Read" +op_write: "Write" +op_delete: "Delete" +op_manage: "Manage" +grant: "Grant access" +revoke: "Revoke" +role_descriptions: "Role descriptions" +ops_legend: "Permission legend" +op_read_desc: "View data" +op_write_desc: "Add/edit data" +op_delete_desc: "Remove data" +op_manage_desc: "Manage who has access" +permissions_updated: "Permissions updated successfully." +back: "Back" +can_add_data: "Can add data" +install_title: "Connect to Claude" +install_intro: "Set up the inou bridge to let Claude analyze your health data" +install_step1: "Step 1: Download" +install_step1_desc: "Get the bridge for your platform" +install_download_intro: "Download the inou bridge for your operating system:" +install_step2: "Step 2: Configure" +install_step2_desc: "Add to Claude Desktop config" +install_config_intro: "Add this to your Claude Desktop configuration file:" +install_step3: "Step 3: Test" +install_step3_desc: "Verify the connection" +install_test_intro: "Restart Claude Desktop and ask: 'Show me my inou profiles'" +nav_install: "Connect to Claude" +nav_home: "Home" +pending: "pending" +rate_limit_exceeded: "Too many sign-up attempts from your location. Please try again tomorrow." +section_genetics: Genetics +no_genetics: No genetic data + +sex_male: "male" +sex_female: "female" +sex_na: "other" + +# Friend invite email +friend_invite_subject: "Check this out — %s" +friend_invite_p1: "I've been using inou, the secure way to store health data and explore it with AI. It keeps all my family's health information in one place — imaging studies, lab results, medical records — and I thought you might find it useful too." +friend_invite_p2: "The real power is being able to use AI to make sense of it all: understand what a report actually means, spot trends over time, or just ask questions in plain language and get clear answers." +friend_invite_btn: "Check out inou" +friend_invite_dear: "Dear %s," +rel_0: "you" +rel_1: "Parent" +rel_2: "Child" +rel_3: "Spouse" +rel_4: "Sibling" +rel_5: "Guardian" +rel_6: "Caregiver" +rel_7: "Coach" +rel_8: "Doctor" +rel_9: "Friend" +rel_10: "Other" +rel_99: "Demo" +select_relation: "Select relationship..." +audit_dossier_added: "A new dossier for %s created by %s" +audit_dossier_edited: "Dossier %s edited by %s" +audit_access_granted: "Access to %s granted to %s" +audit_dossier_created: Account created by %s +audit_access_revoked: Access for %s to %s revoked +audit_file_upload: File %s uploaded by %s +audit_file_delete: File %s deleted by %s +audit_file_category_change: File %s category changed by %s +audit_genome_import: %s genetic variants imported + +# Categories (category000 = imaging, etc.) +category000: Imaging +category001: Document +category002: Lab result +category003: Genome +category004: Upload +category005: Consultation +category006: Diagnosis +category007: Imaging finding +category008: EEG finding +category009: Vital sign +category010: Exercise +category011: Medication +category012: Supplement +category013: Nutrition +category014: Fertility +category015: Symptom +category016: Note +category017: Medical history +category018: Family history +category019: Surgery +category020: Hospitalization +category021: Birth record +category022: Medical device +category023: Therapy +category024: Assessment +category025: Provider +category026: Question + +# Genome +genome_english_only: "" +genome_variants: "variants" +genome_hidden: "hidden" +genome_show_all_categories: "Show all %d categories" + +# API +api_token: "API Token" +api_token_use: "Use this token to authenticate API requests:" +api_token_warning: "Keep this private. Anyone with this token can access your health data." +api_token_none: "Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/es.yaml b/lang/es.yaml new file mode 100644 index 0000000..f5e0f89 --- /dev/null +++ b/lang/es.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Tus datos de salud." +headline_2: "Tu IA." +headline_3: "Tus respuestas." +intro: "Sube imágenes médicas, análisis y más. Conecta tu IA para ayudarte a entender lo que estás viendo." +email: "Correo electrónico" +get_started: "Comenzar" +data_yours: "Tus datos son tuyos" +never_training: "Nunca usados para entrenamiento" +never_training_desc: "Tus imágenes nunca se usan para entrenar modelos de IA." +never_shared: "Nunca compartidos" +never_shared_desc: "Nunca compartimos tus datos con nadie." +encrypted: "Almacenamiento cifrado" +encrypted_desc: "Todos los datos cifrados en reposo." +delete: "Eliminar en cualquier momento" +delete_desc: "Tus datos, tu control." + +# Verify +check_email: "Revisa tu correo" +code_sent_to: "Enviamos un código de 6 dígitos a" +verification_code: "Código de verificación" +verify: "Verificar" +use_different_email: "Usar otro correo" +invalid_code: "Código inválido o expirado. Por favor, inténtalo de nuevo." + +# Onboard +create_dossier: "Crea tu expediente" +create_profile_intro: "Cuéntanos sobre ti para comenzar." +name: "Nombre" +name_placeholder: "Tu nombre" +date_of_birth: "Fecha de nacimiento" +sex_at_birth: "Sexo al nacer" +female: "Femenino" +male: "Masculino" +create_my_dossier: "Crear mi expediente" + +# Minor error +must_be_18: "Debes tener 18 años para crear una cuenta" +minor_explanation: "Si estás configurando esto para otra persona, comienza primero con tu propio perfil. Esto asegura que solo tú puedas acceder a sus datos de salud." +minor_next_steps: "Después de crear tu expediente, puedes agregar otros." +use_different_dob: "Usar otra fecha de nacimiento" + +# Minor login block +minor_login_blocked: "Debes tener 18 años para iniciar sesión" +minor_ask_guardian: "Pide a %s que acceda a tu expediente." +minor_ask_guardian_generic: "Pide a un padre o tutor que acceda a tu expediente." + +# Dashboard +dossiers: "Expedientes" +dossiers_intro: "Gestiona datos de salud para ti o para otros" +you: "tú" +view: "Ver" +save: "Guardar" +cancel: "Cancelar" +add_dossier: "Agregar expediente" +edit_dossier: "Editar expediente" +care: "cuidado" +logout: "Cerrar sesión" + +# Profile detail +back_to_dossiers: "Volver a expedientes" +born: "Nacido/a" +no_access_yet: "Solo tú tienes acceso." +people_with_access: "Personas con acceso" +share_access: "Compartir acceso" +can_edit: "puede agregar datos" +remove: "Eliminar" +confirm_revoke: "¿Eliminar acceso?" + +# Dossier sections +section_imaging: "Imágenes" +section_labs: "Laboratorio" +section_uploads: "Archivos" +section_vitals: "Signos vitales" +section_medications: "Medicamentos" +section_records: "Registros" +section_journal: "Diario" +section_genetics: "Genética" +section_privacy: "Privacidad" + +# Section summaries +imaging_summary: "%d estudios · %d cortes" +no_imaging: "Sin datos de imágenes" +no_lab_data: "Sin datos de laboratorio" +no_genetics: "Sin datos genéticos" +no_files: "Sin archivos" +no_upload_access: "You don't have permission to upload" +files_summary: "%d archivos (%s)" +series_count: "%d series" +vitals_desc: "Presión arterial, frecuencia cardíaca, SpO₂, peso, glucosa" +medications_desc: "Recetas y suplementos" +records_desc: "Notas clínicas e historiales médicos" +journal_desc: "Síntomas, dolor y observaciones" + +# Buttons and actions +open_viewer: "Abrir visor" +manage: "Gestionar" +show_all_studies: "Mostrar los %d estudios..." +coming_soon: "Próximamente" + +# Upload page +upload_files: "Subir datos de salud" +upload_files_intro: "Sube imágenes médicas, resultados de laboratorio, archivos genómicos o cualquier documento relacionado con la salud." +upload_hint_broad: "DICOM, PDF, CSV, VCF y más" +uploading: "Subiendo..." +files_uploaded: "archivos subidos" +upload_scans: "Subir estudios" +upload_scans_intro: "Sube una carpeta con archivos DICOM de tu estudio de imágenes." +upload_drop: "Haz clic o arrastra una carpeta aquí" +upload_hint: "Solo carpetas DICOM" + +# Add profile +add_dossier_intro: "Agrega a alguien cuyos datos de salud quieras gestionar." +email_optional: "Correo (opcional)" +email_optional_hint: "Si tienen 18, pueden iniciar sesión ellos mismos" +your_relation: "Tu relación con esta persona" +select_relation: "Seleccionar..." +i_provide_care: "Proporciono cuidado a esta persona" +i_am_their: "Soy su..." + +# Share access +share_access_intro: "Invitar a alguien a acceder" +their_relation: "Su relación con esta persona" +can_add_data: "Puede agregar datos (suplementos, notas, etc.)" +send_invitation: "Enviar invitación" +back_to_dossier: "Volver al expediente" + +# Relations +my_role: "mi rol" +role: "role" + +# Invitation email +invite_email_subject: "%s te agregó a inou" +invite_email_body: "%s agregó tu expediente de salud a inou para que puedas ver y gestionar tus datos médicos." +invite_email_cta: "Iniciar sesión para ver" +continue: "Continuar" + +# Access management +people_with_access_count: "personas con acceso" +view_audit_log: "Ver registro de actividad" +export_data: "Download my data" +relation_with: "Relación con" +audit_log: "Registro de actividad" +audit_log_intro: "Historial de actividad para" +audit_log_desc: "Rastrea quién accedió o modificó este expediente" + +# Install / Connect +install_title: "Conectar con Claude" +install_intro: "Configura el puente inou para que Claude analice tus datos de salud" +install_step1: "Paso 1: Descargar" +install_step1_desc: "Obtén el puente para tu plataforma" +install_download_intro: "Descarga el puente inou para tu sistema operativo:" +install_step2: "Paso 2: Configurar" +install_step2_desc: "Agregar a la configuración de Claude Desktop" +install_config_intro: "Agrega esto a tu archivo de configuración de Claude Desktop:" +install_step3: "Paso 3: Probar" +install_step3_desc: "Verificar la conexión" +install_test_intro: "Reinicia Claude Desktop y pregunta: 'Muéstrame mis perfiles de inou'" +nav_install: "Conectar con Claude" +nav_home: "Inicio" + +# Status +pending: "pendiente" +rate_limit_exceeded: "Demasiados intentos de registro desde tu ubicación. Por favor, inténtalo mañana." + +# Sex display +sex_male: "masculino" +sex_female: "femenino" +sex_na: "otro" + +# Friend invite email +friend_invite_subject: "Mira esto — %s" +friend_invite_p1: "Estoy usando inou, la forma segura de guardar datos de salud y explorarlos con IA. Mantiene toda la información de salud de mi familia en un solo lugar — estudios de imagen, resultados de laboratorio, historiales médicos — y pensé que también te podría ser útil." +friend_invite_p2: "El verdadero poder está en poder usar IA para entenderlo todo: comprender qué significa realmente un informe, detectar tendencias a lo largo del tiempo, o simplemente hacer preguntas en lenguaje sencillo y obtener respuestas claras." +friend_invite_btn: "Descubre inou" +friend_invite_dear: "Querido/a %s," +rel_0: "tú" +rel_1: "Padre/Madre" +rel_2: "Hijo/a" +rel_3: "Cónyuge" +rel_4: "Hermano/a" +rel_5: "Tutor" +rel_6: "Cuidador" +rel_7: "Coach" +rel_8: "Médico" +rel_9: "Amigo" +rel_10: "Otro" +rel_99: "Demo" +select_relation: "Seleccionar relación..." + +# Categorías +category000: Imagen médica +category001: Documento +category002: Resultado de laboratorio +category003: Genoma +category004: Carga +category005: Consulta +category006: Diagnóstico +category007: Resultado de imagen +category008: Resultado de EEG +category009: Signo vital +category010: Ejercicio +category011: Medicamento +category012: Suplemento +category013: Nutrición +category014: Fertilidad +category015: Síntoma +category016: Nota +category017: Historial médico +category018: Antecedentes familiares +category019: Cirugía +category020: Hospitalización +category021: Datos de nacimiento +category022: Dispositivo médico +category023: Terapia +category024: Evaluación +category025: Proveedor de salud +category026: Pregunta + +# Genome +genome_english_only: "Toda la información genética está en inglés. Usa Claude para discutirla en español." +genome_variants: "variantes" +genome_hidden: "ocultas" +genome_show_all_categories: "Mostrar las %d categorías" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/fi.yaml b/lang/fi.yaml new file mode 100644 index 0000000..87c6b0c --- /dev/null +++ b/lang/fi.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Sinun terveystietosi." +headline_2: "Sinun tekoälysi." +headline_3: "Sinun vastauksesi." +intro: "Lataa kuvantamista, laboratoriotuloksia ja muuta. Yhdistä tekoäly auttamaan ymmärtämään, mitä näet." +email: "Sähköposti" +get_started: "Aloita" +data_yours: "Tietosi pysyvät sinun" +never_training: "Ei koskaan käytetä koulutukseen" +never_training_desc: "Kuviasi ei koskaan käytetä tekoälymallien koulutukseen." +never_shared: "Ei koskaan jaeta" +never_shared_desc: "Emme koskaan jaa tietojasi kenellekään." +encrypted: "Salattu tallennus" +encrypted_desc: "Kaikki tiedot salattu levossa." +delete: "Poista milloin tahansa" +delete_desc: "Sinun tietosi, sinun hallintasi." + +# Verify +check_email: "Tarkista sähköpostisi" +code_sent_to: "Lähetimme 6-numeroisen koodin osoitteeseen" +verification_code: "Vahvistuskoodi" +verify: "Vahvista" +use_different_email: "Käytä toista sähköpostia" +invalid_code: "Virheellinen tai vanhentunut koodi. Yritä uudelleen." + +# Onboard +create_dossier: "Luo kansiosi" +create_profile_intro: "Kerro meille itsestäsi aloittaaksesi." +name: "Nimi" +name_placeholder: "Nimesi" +date_of_birth: "Syntymäaika" +sex_at_birth: "Sukupuoli syntymähetkellä" +female: "Nainen" +male: "Mies" +create_my_dossier: "Luo kansioni" + +# Minor error +must_be_18: "Sinun täytyy olla 18 luodaksesi tilin" +minor_explanation: "Jos luot tämän jollekin toiselle, aloita omasta profiilistasi ensin. Tämä varmistaa, että vain sinä voit käyttää heidän terveystietojaan." +minor_next_steps: "Kansion luomisen jälkeen voit lisätä muita." +use_different_dob: "Käytä toista syntymäaikaa" + +# Minor login block +minor_login_blocked: "Sinun täytyy olla 18 kirjautuaksesi sisään" +minor_ask_guardian: "Pyydä %s pääsyä kansioosi." +minor_ask_guardian_generic: "Pyydä vanhempaa tai huoltajaa pääsyä kansioosi." + +# Dashboard +dossiers: "Kansiot" +dossiers_intro: "Hallitse terveystietoja itsellesi tai muille" +you: "sinä" +view: "Näytä" +save: "Tallenna" +cancel: "Peruuta" +add_dossier: "Lisää kansio" +edit_dossier: "Muokkaa kansiota" +care: "hoito" +logout: "Kirjaudu ulos" + +# Profile detail +back_to_dossiers: "Takaisin kansioihin" +born: "Syntynyt" +no_access_yet: "Vain sinulla on pääsy." +people_with_access: "Henkilöt, joilla on pääsy" +share_access: "Jaa pääsy" +can_edit: "voi lisätä tietoja" +remove: "Poista" +confirm_revoke: "Poista pääsy?" + +# Dossier sections +section_imaging: "Kuvantaminen" +section_labs: "Laboratorio" +section_uploads: "Lataukset" +section_vitals: "Elintoiminnot" +section_medications: "Lääkkeet" +section_records: "Asiakirjat" +section_journal: "Päiväkirja" +section_genetics: "Genetiikka" +section_privacy: "Yksityisyys" + +# Section summaries +imaging_summary: "%d tutkimusta · %d leikettä" +no_imaging: "Ei kuvatietoja" +no_lab_data: "Ei laboratoriotietoja" +no_genetics: "Ei geneettisiä tietoja" +no_files: "Ei tiedostoja" +no_upload_access: "You don't have permission to upload" +files_summary: "%d tiedostoa (%s)" +series_count: "%d sarjaa" +vitals_desc: "Verenpaine, syke, SpO₂, paino, verensokeri" +medications_desc: "Reseptit ja ravintolisät" +records_desc: "Kliiniset muistiinpanot ja potilasasiakirjat" +journal_desc: "Oireet, kipu ja havainnot" + +# Buttons and actions +open_viewer: "Avaa katselin" +manage: "Hallitse" +show_all_studies: "Näytä kaikki %d tutkimusta..." +coming_soon: "Tulossa pian" + +# Upload page +upload_files: "Lataa terveystietoja" +upload_files_intro: "Lataa lääketieteellistä kuvantamista, laboratoriotuloksia, genomitiedostoja tai terveyteen liittyviä asiakirjoja." +upload_hint_broad: "DICOM, PDF, CSV, VCF ja muut" +uploading: "Ladataan..." +files_uploaded: "tiedostoa ladattu" +upload_scans: "Lataa kuvauksia" +upload_scans_intro: "Lataa kansio, joka sisältää DICOM-tiedostoja kuvantamistutkimuksestasi." +upload_drop: "Napsauta tai vedä kansio tähän" +upload_hint: "Vain DICOM-kansiot" + +# Add profile +add_dossier_intro: "Lisää henkilö, jonka terveystietoja haluat hallita." +email_optional: "Sähköposti (valinnainen)" +email_optional_hint: "Jos he ovat 18, he voivat kirjautua itse" +your_relation: "Suhteesi heihin" +select_relation: "Valitse..." +i_provide_care: "Hoidan tätä henkilöä" +i_am_their: "Olen heidän..." + +# Share access +share_access_intro: "Kutsu joku käyttämään" +their_relation: "Heidän suhteensa tähän henkilöön" +can_add_data: "Voi lisätä tietoja (ravintolisät, muistiinpanot, jne.)" +send_invitation: "Lähetä kutsu" +back_to_dossier: "Takaisin kansioon" + +# Relations +my_role: "roolini" +role: "role" + +# Invitation email +invite_email_subject: "%s lisäsi sinut inouun" +invite_email_body: "%s lisäsi terveyskansiosi inouun, jotta voit katsella ja hallita lääketieteellisiä tietojasi." +invite_email_cta: "Kirjaudu sisään nähdäksesi" +continue: "Jatka" + +# Access management +people_with_access_count: "henkilöä, joilla on pääsy" +view_audit_log: "Näytä tapahtumaloki" +export_data: "Download my data" +relation_with: "Suhde henkilöön" +audit_log: "Tapahtumaloki" +audit_log_intro: "Toimintahistoria" +audit_log_desc: "Seuraa, kuka on käyttänyt tai muokannut tätä kansiota" + +# Install / Connect +install_title: "Yhdistä Claudeen" +install_intro: "Määritä inou-silta, jotta Claude voi analysoida terveystietojasi" +install_step1: "Vaihe 1: Lataa" +install_step1_desc: "Hanki silta alustallesi" +install_download_intro: "Lataa inou-silta käyttöjärjestelmällesi:" +install_step2: "Vaihe 2: Määritä" +install_step2_desc: "Lisää Claude Desktop -asetuksiin" +install_config_intro: "Lisää tämä Claude Desktop -asetustiedostoosi:" +install_step3: "Vaihe 3: Testaa" +install_step3_desc: "Vahvista yhteys" +install_test_intro: "Käynnistä Claude Desktop uudelleen ja kysy: 'Näytä inou-profiilini'" +nav_install: "Yhdistä Claudeen" +nav_home: "Koti" + +# Status +pending: "odottaa" +rate_limit_exceeded: "Liian monta rekisteröitymisyritystä sijainnistasi. Yritä uudelleen huomenna." + +# Sex display +sex_male: "mies" +sex_female: "nainen" +sex_na: "muu" + +# Friend invite email +friend_invite_subject: "Katso tämä — %s" +friend_invite_p1: "Olen käyttänyt inoua, turvallista tapaa tallentaa terveystietoja ja tutkia niitä tekoälyn avulla. Se pitää kaikki perheeni terveystiedot yhdessä paikassa — kuvantamistutkimukset, laboratoriotulokset, potilaskertomukset — ja ajattelin, että siitä voisi olla hyötyä sinullekin." +friend_invite_p2: "Todellinen voima on siinä, että voit käyttää tekoälyä ymmärtääksesi kaiken: ymmärtää mitä raportti todella tarkoittaa, havaita trendejä ajan myötä, tai vain esittää kysymyksiä tavallisella suomella ja saada selkeitä vastauksia." +friend_invite_btn: "Tutustu inouun" +friend_invite_dear: "Hei %s," +rel_0: "sinä" +rel_1: "Vanhempi" +rel_2: "Lapsi" +rel_3: "Puoliso" +rel_4: "Sisarus" +rel_5: "Huoltaja" +rel_6: "Hoitaja" +rel_7: "Valmentaja" +rel_8: "Lääkäri" +rel_9: "Ystävä" +rel_10: "Muu" +rel_99: "Demo" +select_relation: "Valitse suhde..." + +# Kategoriat +category000: Kuvantaminen +category001: Asiakirja +category002: Laboratoriotulos +category003: Genomi +category004: Lataus +category005: Konsultaatio +category006: Diagnoosi +category007: Kuvantamistulos +category008: EEG-tulos +category009: Elintoiminto +category010: Liikunta +category011: Lääke +category012: Lisäravinne +category013: Ravitsemus +category014: Hedelmällisyys +category015: Oire +category016: Muistiinpano +category017: Sairaushistoria +category018: Sukuhistoria +category019: Leikkaus +category020: Sairaalahoito +category021: Syntymätiedot +category022: Lääkinnällinen laite +category023: Terapia +category024: Arviointi +category025: Terveydenhuollon tarjoaja +category026: Kysymys + +# Genome +genome_english_only: "Kaikki geneettinen tieto on englanniksi. Käytä Claudea keskustellaksesi siitä suomeksi." +genome_variants: "varianttia" +genome_hidden: "piilotettua" +genome_show_all_categories: "Näytä kaikki %d kategoriaa" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/fr.yaml b/lang/fr.yaml new file mode 100644 index 0000000..451e198 --- /dev/null +++ b/lang/fr.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Vos données de santé." +headline_2: "Votre IA." +headline_3: "Vos réponses." +intro: "Téléchargez imagerie, analyses et plus encore. Connectez votre IA pour vous aider à comprendre ce que vous regardez." +email: "E-mail" +get_started: "Commencer" +data_yours: "Vos données restent les vôtres" +never_training: "Jamais utilisées pour l'entraînement" +never_training_desc: "Vos images ne sont jamais utilisées pour entraîner des modèles d'IA." +never_shared: "Jamais partagées" +never_shared_desc: "Nous ne partageons jamais vos données avec personne." +encrypted: "Stockage chiffré" +encrypted_desc: "Toutes les données sont chiffrées au repos." +delete: "Supprimer à tout moment" +delete_desc: "Vos données, votre contrôle." + +# Verify +check_email: "Vérifiez votre e-mail" +code_sent_to: "Nous avons envoyé un code à 6 chiffres à" +verification_code: "Code de vérification" +verify: "Vérifier" +use_different_email: "Utiliser un autre e-mail" +invalid_code: "Code invalide ou expiré. Veuillez réessayer." + +# Onboard +create_dossier: "Créez votre dossier" +create_profile_intro: "Parlez-nous de vous pour commencer." +name: "Nom" +name_placeholder: "Votre nom" +date_of_birth: "Date de naissance" +sex_at_birth: "Sexe à la naissance" +female: "Féminin" +male: "Masculin" +create_my_dossier: "Créer mon dossier" + +# Minor error +must_be_18: "Vous devez avoir 18 ans pour créer un compte" +minor_explanation: "Si vous configurez ceci pour quelqu'un d'autre, commencez d'abord par votre propre profil. Cela garantit que vous seul pouvez accéder à leurs données de santé." +minor_next_steps: "Après avoir créé votre dossier, vous pouvez en ajouter d'autres." +use_different_dob: "Utiliser une autre date de naissance" + +# Minor login block +minor_login_blocked: "Vous devez avoir 18 ans pour vous connecter" +minor_ask_guardian: "Demandez à %s d'accéder à votre dossier." +minor_ask_guardian_generic: "Demandez à un parent ou tuteur d'accéder à votre dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Gérez les données de santé pour vous-même ou pour d'autres" +you: "vous" +view: "Voir" +save: "Enregistrer" +cancel: "Annuler" +add_dossier: "Ajouter un dossier" +edit_dossier: "Modifier le dossier" +care: "soins" +logout: "Se déconnecter" + +# Profile detail +back_to_dossiers: "Retour aux dossiers" +born: "Né(e)" +no_access_yet: "Vous seul avez accès." +people_with_access: "Personnes ayant accès" +share_access: "Partager l'accès" +can_edit: "peut ajouter des données" +remove: "Supprimer" +confirm_revoke: "Supprimer l'accès ?" + +# Dossier sections +section_imaging: "Imagerie" +section_labs: "Analyses" +section_uploads: "Fichiers" +section_vitals: "Signes vitaux" +section_medications: "Médicaments" +section_records: "Dossiers" +section_journal: "Journal" +section_genetics: "Génétique" +section_privacy: "Confidentialité" + +# Section summaries +imaging_summary: "%d études · %d coupes" +no_imaging: "Aucune donnée d'imagerie" +no_lab_data: "Aucune donnée de laboratoire" +no_genetics: "Aucune donnée génétique" +no_files: "Aucun fichier" +no_upload_access: "You don't have permission to upload" +files_summary: "%d fichiers (%s)" +series_count: "%d séries" +vitals_desc: "Tension artérielle, fréquence cardiaque, SpO₂, poids, glycémie" +medications_desc: "Ordonnances et compléments alimentaires" +records_desc: "Notes cliniques et dossiers médicaux" +journal_desc: "Symptômes, douleurs et observations" + +# Buttons and actions +open_viewer: "Ouvrir le visualiseur" +manage: "Gérer" +show_all_studies: "Afficher les %d études..." +coming_soon: "Bientôt disponible" + +# Upload page +upload_files: "Télécharger des données de santé" +upload_files_intro: "Téléchargez imagerie médicale, résultats d'analyses, fichiers génomiques ou tout document lié à la santé." +upload_hint_broad: "DICOM, PDF, CSV, VCF et plus" +uploading: "Téléchargement..." +files_uploaded: "fichiers téléchargés" +upload_scans: "Télécharger des examens" +upload_scans_intro: "Téléchargez un dossier contenant des fichiers DICOM de votre étude d'imagerie." +upload_drop: "Cliquez ou glissez un dossier ici" +upload_hint: "Dossiers DICOM uniquement" + +# Add profile +add_dossier_intro: "Ajoutez quelqu'un dont vous souhaitez gérer les données de santé." +email_optional: "E-mail (optionnel)" +email_optional_hint: "S'ils ont 18 ans, ils peuvent se connecter eux-mêmes" +your_relation: "Votre relation avec cette personne" +select_relation: "Sélectionner..." +i_provide_care: "Je m'occupe de cette personne" +i_am_their: "Je suis son/sa..." + +# Share access +share_access_intro: "Inviter quelqu'un à accéder" +their_relation: "Leur relation avec cette personne" +can_add_data: "Peut ajouter des données (compléments, notes, etc.)" +send_invitation: "Envoyer l'invitation" +back_to_dossier: "Retour au dossier" + +# Relations +my_role: "mon rôle" +role: "role" + +# Invitation email +invite_email_subject: "%s vous a ajouté à inou" +invite_email_body: "%s a ajouté votre dossier de santé à inou afin que vous puissiez consulter et gérer vos données médicales." +invite_email_cta: "Se connecter pour voir" +continue: "Continuer" + +# Access management +people_with_access_count: "personnes ayant accès" +view_audit_log: "Voir le journal d'activité" +export_data: "Download my data" +relation_with: "Relation avec" +audit_log: "Journal d'activité" +audit_log_intro: "Historique d'activité pour" +audit_log_desc: "Suivez qui a accédé ou modifié ce dossier" + +# Install / Connect +install_title: "Connecter à Claude" +install_intro: "Configurez le pont inou pour permettre à Claude d'analyser vos données de santé" +install_step1: "Étape 1 : Télécharger" +install_step1_desc: "Obtenez le pont pour votre plateforme" +install_download_intro: "Téléchargez le pont inou pour votre système d'exploitation :" +install_step2: "Étape 2 : Configurer" +install_step2_desc: "Ajouter à la configuration de Claude Desktop" +install_config_intro: "Ajoutez ceci à votre fichier de configuration Claude Desktop :" +install_step3: "Étape 3 : Tester" +install_step3_desc: "Vérifier la connexion" +install_test_intro: "Redémarrez Claude Desktop et demandez : 'Montre-moi mes profils inou'" +nav_install: "Connecter à Claude" +nav_home: "Accueil" + +# Status +pending: "en attente" +rate_limit_exceeded: "Trop de tentatives d'inscription depuis votre emplacement. Veuillez réessayer demain." + +# Sex display +sex_male: "masculin" +sex_female: "féminin" +sex_na: "autre" + +# Friend invite email +friend_invite_subject: "Regarde ça — %s" +friend_invite_p1: "J'utilise inou, la façon sécurisée de stocker des données de santé et de les explorer avec l'IA. Ça garde toutes les informations de santé de ma famille au même endroit — imagerie, résultats de labo, dossiers médicaux — et je me suis dit que ça pourrait t'être utile aussi." +friend_invite_p2: "La vraie puissance, c'est de pouvoir utiliser l'IA pour tout comprendre : comprendre ce qu'un rapport signifie vraiment, repérer les tendances dans le temps, ou simplement poser des questions en langage courant et obtenir des réponses claires." +friend_invite_btn: "Découvrir inou" +friend_invite_dear: "Cher/Chère %s," +rel_0: "toi" +rel_1: "Parent" +rel_2: "Enfant" +rel_3: "Conjoint" +rel_4: "Frère/Sœur" +rel_5: "Tuteur" +rel_6: "Aidant" +rel_7: "Coach" +rel_8: "Médecin" +rel_9: "Ami" +rel_10: "Autre" +rel_99: "Demo" +select_relation: "Sélectionner la relation..." + +# Catégories +category000: Imagerie +category001: Document +category002: Résultat de laboratoire +category003: Génome +category004: Téléchargement +category005: Consultation +category006: Diagnostic +category007: Résultat d'imagerie +category008: Résultat EEG +category009: Signe vital +category010: Exercice +category011: Médicament +category012: Supplément +category013: Nutrition +category014: Fertilité +category015: Symptôme +category016: Note +category017: Antécédents médicaux +category018: Antécédents familiaux +category019: Chirurgie +category020: Hospitalisation +category021: Données de naissance +category022: Dispositif médical +category023: Thérapie +category024: Évaluation +category025: Prestataire de soins +category026: Question + +# Genome +genome_english_only: "Toutes les informations génétiques sont en anglais. Utilisez Claude pour en discuter en français." +genome_variants: "variantes" +genome_hidden: "masquées" +genome_show_all_categories: "Afficher les %d catégories" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/it.yaml b/lang/it.yaml new file mode 100644 index 0000000..7e77ef2 --- /dev/null +++ b/lang/it.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "I tuoi dati sanitari." +headline_2: "La tua IA." +headline_3: "Le tue risposte." +intro: "Carica immagini diagnostiche, analisi e altro. Connetti la tua IA per aiutarti a capire cosa stai guardando." +email: "Email" +get_started: "Inizia" +data_yours: "I tuoi dati restano tuoi" +never_training: "Mai usati per l'addestramento" +never_training_desc: "Le tue immagini non vengono mai usate per addestrare modelli IA." +never_shared: "Mai condivisi" +never_shared_desc: "Non condividiamo mai i tuoi dati con nessuno." +encrypted: "Archiviazione crittografata" +encrypted_desc: "Tutti i dati crittografati a riposo." +delete: "Elimina quando vuoi" +delete_desc: "I tuoi dati, il tuo controllo." + +# Verify +check_email: "Controlla la tua email" +code_sent_to: "Abbiamo inviato un codice a 6 cifre a" +verification_code: "Codice di verifica" +verify: "Verifica" +use_different_email: "Usa un'altra email" +invalid_code: "Codice non valido o scaduto. Riprova." + +# Onboard +create_dossier: "Crea il tuo dossier" +create_profile_intro: "Parlaci di te per iniziare." +name: "Nome" +name_placeholder: "Il tuo nome" +date_of_birth: "Data di nascita" +sex_at_birth: "Sesso alla nascita" +female: "Femmina" +male: "Maschio" +create_my_dossier: "Crea il mio dossier" + +# Minor error +must_be_18: "Devi avere 18 anni per creare un account" +minor_explanation: "Se stai configurando questo per qualcun altro, inizia prima con il tuo profilo. Questo assicura che solo tu possa accedere ai loro dati sanitari." +minor_next_steps: "Dopo aver creato il tuo dossier, puoi aggiungerne altri." +use_different_dob: "Usa un'altra data di nascita" + +# Minor login block +minor_login_blocked: "Devi avere 18 anni per accedere" +minor_ask_guardian: "Chiedi a %s di accedere al tuo dossier." +minor_ask_guardian_generic: "Chiedi a un genitore o tutore di accedere al tuo dossier." + +# Dashboard +dossiers: "Dossier" +dossiers_intro: "Gestisci i dati sanitari per te o altri" +you: "tu" +view: "Visualizza" +save: "Salva" +cancel: "Annulla" +add_dossier: "Aggiungi dossier" +edit_dossier: "Modifica dossier" +care: "assistenza" +logout: "Esci" + +# Profile detail +back_to_dossiers: "Torna ai dossier" +born: "Nato/a" +no_access_yet: "Solo tu hai accesso." +people_with_access: "Persone con accesso" +share_access: "Condividi accesso" +can_edit: "può aggiungere dati" +remove: "Rimuovi" +confirm_revoke: "Rimuovere l'accesso?" + +# Dossier sections +section_imaging: "Immagini" +section_labs: "Analisi" +section_uploads: "File" +section_vitals: "Parametri vitali" +section_medications: "Farmaci" +section_records: "Cartelle" +section_journal: "Diario" +section_genetics: "Genetica" +section_privacy: "Privacy" + +# Section summaries +imaging_summary: "%d studi · %d sezioni" +no_imaging: "Nessun dato di imaging" +no_lab_data: "Nessun dato di laboratorio" +no_genetics: "Nessun dato genetico" +no_files: "Nessun file" +no_upload_access: "You don't have permission to upload" +files_summary: "%d file (%s)" +series_count: "%d serie" +vitals_desc: "Pressione sanguigna, frequenza cardiaca, SpO₂, peso, glicemia" +medications_desc: "Prescrizioni e integratori" +records_desc: "Note cliniche e cartelle mediche" +journal_desc: "Sintomi, dolore e osservazioni" + +# Buttons and actions +open_viewer: "Apri visualizzatore" +manage: "Gestisci" +show_all_studies: "Mostra tutti i %d studi..." +coming_soon: "Prossimamente" + +# Upload page +upload_files: "Carica dati sanitari" +upload_files_intro: "Carica immagini mediche, risultati di laboratorio, file genomici o documenti sanitari." +upload_hint_broad: "DICOM, PDF, CSV, VCF e altro" +uploading: "Caricamento..." +files_uploaded: "file caricati" +upload_scans: "Carica scansioni" +upload_scans_intro: "Carica una cartella contenente file DICOM dal tuo studio di imaging." +upload_drop: "Clicca o trascina una cartella qui" +upload_hint: "Solo cartelle DICOM" + +# Add profile +add_dossier_intro: "Aggiungi qualcuno di cui vuoi gestire i dati sanitari." +email_optional: "Email (opzionale)" +email_optional_hint: "Se ha 18 anni, può accedere autonomamente" +your_relation: "La tua relazione con questa persona" +select_relation: "Seleziona..." +i_provide_care: "Mi prendo cura di questa persona" +i_am_their: "Sono il/la loro..." + +# Share access +share_access_intro: "Invita qualcuno ad accedere" +their_relation: "La loro relazione con questa persona" +can_add_data: "Può aggiungere dati (integratori, note, ecc.)" +send_invitation: "Invia invito" +back_to_dossier: "Torna al dossier" + +# Relations +my_role: "il mio ruolo" +role: "role" + +# Invitation email +invite_email_subject: "%s ti ha aggiunto a inou" +invite_email_body: "%s ha aggiunto il tuo dossier sanitario a inou così puoi visualizzare e gestire i tuoi dati medici." +invite_email_cta: "Accedi per visualizzare" +continue: "Continua" + +# Access management +people_with_access_count: "persone con accesso" +view_audit_log: "Visualizza registro attività" +export_data: "Download my data" +relation_with: "Relazione con" +audit_log: "Registro attività" +audit_log_intro: "Cronologia attività per" +audit_log_desc: "Traccia chi ha acceduto o modificato questo dossier" + +# Install / Connect +install_title: "Connetti a Claude" +install_intro: "Configura il bridge inou per permettere a Claude di analizzare i tuoi dati sanitari" +install_step1: "Passo 1: Scarica" +install_step1_desc: "Ottieni il bridge per la tua piattaforma" +install_download_intro: "Scarica il bridge inou per il tuo sistema operativo:" +install_step2: "Passo 2: Configura" +install_step2_desc: "Aggiungi alla configurazione di Claude Desktop" +install_config_intro: "Aggiungi questo al file di configurazione di Claude Desktop:" +install_step3: "Passo 3: Testa" +install_step3_desc: "Verifica la connessione" +install_test_intro: "Riavvia Claude Desktop e chiedi: 'Mostrami i miei profili inou'" +nav_install: "Connetti a Claude" +nav_home: "Home" + +# Status +pending: "in attesa" +rate_limit_exceeded: "Troppi tentativi di registrazione dalla tua posizione. Riprova domani." + +# Sex display +sex_male: "maschio" +sex_female: "femmina" +sex_na: "altro" + +# Friend invite email +friend_invite_subject: "Dai un'occhiata — %s" +friend_invite_p1: "Sto usando inou, il modo sicuro per archiviare dati sanitari ed esplorarli con l'IA. Tiene tutte le informazioni sulla salute della mia famiglia in un unico posto — esami di imaging, risultati di laboratorio, cartelle cliniche — e ho pensato che potrebbe essere utile anche a te." +friend_invite_p2: "Il vero potere sta nel poter usare l'IA per dare senso a tutto: capire cosa significa veramente un referto, individuare tendenze nel tempo, o semplicemente fare domande in linguaggio semplice e ottenere risposte chiare." +friend_invite_btn: "Scopri inou" +friend_invite_dear: "Caro/a %s," +rel_0: "tu" +rel_1: "Genitore" +rel_2: "Figlio/a" +rel_3: "Coniuge" +rel_4: "Fratello/Sorella" +rel_5: "Tutore" +rel_6: "Caregiver" +rel_7: "Coach" +rel_8: "Medico" +rel_9: "Amico" +rel_10: "Altro" +rel_99: "Demo" +select_relation: "Seleziona relazione..." + +# Categorie +category000: Diagnostica per immagini +category001: Documento +category002: Risultato di laboratorio +category003: Genoma +category004: Caricamento +category005: Consultazione +category006: Diagnosi +category007: Risultato di imaging +category008: Risultato EEG +category009: Segno vitale +category010: Esercizio +category011: Farmaco +category012: Integratore +category013: Nutrizione +category014: Fertilità +category015: Sintomo +category016: Nota +category017: Storia medica +category018: Storia familiare +category019: Chirurgia +category020: Ospedalizzazione +category021: Dati di nascita +category022: Dispositivo medico +category023: Terapia +category024: Valutazione +category025: Fornitore sanitario +category026: Domanda + +# Genome +genome_english_only: "Tutte le informazioni genetiche sono in inglese. Usa Claude per discuterne in italiano." +genome_variants: "varianti" +genome_hidden: "nascoste" +genome_show_all_categories: "Mostra tutte le %d categorie" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ja.yaml b/lang/ja.yaml new file mode 100644 index 0000000..9294b5e --- /dev/null +++ b/lang/ja.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "あなたの健康データ。" +headline_2: "あなたのAI。" +headline_3: "あなたの答え。" +intro: "画像診断、検査結果などをアップロード。AIに接続して、見ているものを理解する手助けをしてもらいましょう。" +email: "メールアドレス" +get_started: "始める" +data_yours: "あなたのデータはあなたのもの" +never_training: "トレーニングには使用されません" +never_training_desc: "あなたの画像はAIモデルのトレーニングには使用されません。" +never_shared: "共有されません" +never_shared_desc: "あなたのデータは誰とも共有しません。" +encrypted: "暗号化ストレージ" +encrypted_desc: "すべてのデータは保存時に暗号化されます。" +delete: "いつでも削除可能" +delete_desc: "あなたのデータ、あなたの管理。" + +# Verify +check_email: "メールを確認してください" +code_sent_to: "6桁のコードを送信しました:" +verification_code: "確認コード" +verify: "確認" +use_different_email: "別のメールアドレスを使用" +invalid_code: "無効または期限切れのコードです。もう一度お試しください。" + +# Onboard +create_dossier: "ドシエを作成" +create_profile_intro: "始めるにあたって、あなたについて教えてください。" +name: "名前" +name_placeholder: "あなたの名前" +date_of_birth: "生年月日" +sex_at_birth: "出生時の性別" +female: "女性" +male: "男性" +create_my_dossier: "ドシエを作成" + +# Minor error +must_be_18: "アカウントを作成するには18歳以上である必要があります" +minor_explanation: "他の人のために設定する場合は、まず自分のプロフィールから始めてください。これにより、あなただけが彼らの健康データにアクセスできるようになります。" +minor_next_steps: "ドシエを作成した後、他の人を追加できます。" +use_different_dob: "別の生年月日を使用" + +# Minor login block +minor_login_blocked: "ログインするには18歳以上である必要があります" +minor_ask_guardian: "%sにドシエへのアクセスを依頼してください。" +minor_ask_guardian_generic: "親または保護者にドシエへのアクセスを依頼してください。" + +# Dashboard +dossiers: "ドシエ" +dossiers_intro: "自分や他の人の健康データを管理" +you: "あなた" +view: "表示" +save: "保存" +cancel: "キャンセル" +add_dossier: "ドシエを追加" +edit_dossier: "ドシエを編集" +care: "ケア" +logout: "ログアウト" + +# Profile detail +back_to_dossiers: "ドシエに戻る" +born: "生年月日" +no_access_yet: "アクセスできるのはあなただけです。" +people_with_access: "アクセス権のある人" +share_access: "アクセスを共有" +can_edit: "データを追加可能" +remove: "削除" +confirm_revoke: "アクセスを削除しますか?" + +# Dossier sections +section_imaging: "画像診断" +section_labs: "検査結果" +section_uploads: "アップロード" +section_vitals: "バイタル" +section_medications: "薬" +section_records: "記録" +section_journal: "日記" +section_genetics: "遺伝子" +section_privacy: "プライバシー" + +# Section summaries +imaging_summary: "%d件の検査 · %dスライス" +no_imaging: "画像データなし" +no_lab_data: "検査データなし" +no_genetics: "遺伝子データなし" +no_files: "ファイルなし" +no_upload_access: "You don't have permission to upload" +files_summary: "%dファイル(%s)" +series_count: "%dシリーズ" +vitals_desc: "血圧、心拍数、SpO₂、体重、血糖値" +medications_desc: "処方薬とサプリメント" +records_desc: "診療記録と医療記録" +journal_desc: "症状、痛み、観察" + +# Buttons and actions +open_viewer: "ビューアを開く" +manage: "管理" +show_all_studies: "すべての%d件の検査を表示..." +coming_soon: "近日公開" + +# Upload page +upload_files: "健康データをアップロード" +upload_files_intro: "医療画像、検査結果、ゲノムファイル、または健康関連の文書をアップロードしてください。" +upload_hint_broad: "DICOM、PDF、CSV、VCFなど" +uploading: "アップロード中..." +files_uploaded: "ファイルがアップロードされました" +upload_scans: "スキャンをアップロード" +upload_scans_intro: "画像検査のDICOMファイルを含むフォルダをアップロードしてください。" +upload_drop: "クリックまたはフォルダをここにドラッグ" +upload_hint: "DICOMフォルダのみ" + +# Add profile +add_dossier_intro: "健康データを管理したい人を追加してください。" +email_optional: "メール(任意)" +email_optional_hint: "18歳以上なら、本人がログインできます" +your_relation: "この人とのあなたの関係" +select_relation: "選択..." +i_provide_care: "この人のケアを提供しています" +i_am_their: "私は彼らの..." + +# Share access +share_access_intro: "アクセスする人を招待" +their_relation: "この人との関係" +can_add_data: "データを追加可能(サプリメント、メモなど)" +send_invitation: "招待を送信" +back_to_dossier: "ドシエに戻る" + +# Relations +my_role: "私の役割" +role: "role" + +# Invitation email +invite_email_subject: "%sがあなたをinouに追加しました" +invite_email_body: "%sがあなたの健康ドシエをinouに追加しました。医療データを表示・管理できます。" +invite_email_cta: "ログインして表示" +continue: "続ける" + +# Access management +people_with_access_count: "アクセス権のある人" +view_audit_log: "監査ログを表示" +export_data: "Download my data" +relation_with: "との関係" +audit_log: "監査ログ" +audit_log_intro: "のアクティビティ履歴" +audit_log_desc: "このドシエにアクセスまたは変更した人を追跡" + +# Install / Connect +install_title: "Claudeに接続" +install_intro: "inouブリッジを設定して、Claudeがあなたの健康データを分析できるようにします" +install_step1: "ステップ1:ダウンロード" +install_step1_desc: "お使いのプラットフォーム用のブリッジを取得" +install_download_intro: "お使いのオペレーティングシステム用のinouブリッジをダウンロード:" +install_step2: "ステップ2:設定" +install_step2_desc: "Claude Desktopの設定に追加" +install_config_intro: "これをClaude Desktopの設定ファイルに追加してください:" +install_step3: "ステップ3:テスト" +install_step3_desc: "接続を確認" +install_test_intro: "Claude Desktopを再起動して、「inouプロフィールを表示」と尋ねてください" +nav_install: "Claudeに接続" +nav_home: "ホーム" + +# Status +pending: "保留中" +rate_limit_exceeded: "お住まいの地域からの登録試行が多すぎます。明日もう一度お試しください。" + +# Sex display +sex_male: "男性" +sex_female: "女性" +sex_na: "その他" + +# Friend invite email +friend_invite_subject: "これ見て — %s" +friend_invite_p1: "inouを使っています。健康データを安全に保存してAIで分析できるサービスです。家族の健康情報をすべて一か所に保管できます — 画像検査、検査結果、医療記録など。あなたにも役立つかもしれないと思いました。" +friend_invite_p2: "本当の力は、AIを使ってすべてを理解できることです:レポートが実際に何を意味するのか理解したり、時間の経過に伴う傾向を見つけたり、普通の言葉で質問して明確な回答を得たりできます。" +friend_invite_btn: "inouを見る" +friend_invite_dear: "%sさん、" +rel_0: "あなた" +rel_1: "親" +rel_2: "子供" +rel_3: "配偶者" +rel_4: "兄弟姉妹" +rel_5: "後見人" +rel_6: "介護者" +rel_7: "コーチ" +rel_8: "医師" +rel_9: "友人" +rel_10: "その他" +rel_99: "Demo" +select_relation: "関係を選択..." + +# カテゴリー +category000: 画像診断 +category001: 文書 +category002: 検査結果 +category003: ゲノム +category004: アップロード +category005: 相談 +category006: 診断 +category007: 画像検査結果 +category008: 脳波検査結果 +category009: バイタルサイン +category010: 運動 +category011: 薬 +category012: サプリメント +category013: 栄養 +category014: 妊娠・出産 +category015: 症状 +category016: メモ +category017: 病歴 +category018: 家族歴 +category019: 手術 +category020: 入院 +category021: 出生データ +category022: 医療機器 +category023: 治療 +category024: 評価 +category025: 医療提供者 +category026: 質問 + +# Genome +genome_english_only: "遺伝子情報はすべて英語です。Claudeを使って日本語で相談できます。" +genome_variants: "バリアント" +genome_hidden: "非表示" +genome_show_all_categories: "全%dカテゴリを表示" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ko.yaml b/lang/ko.yaml new file mode 100644 index 0000000..d0677ac --- /dev/null +++ b/lang/ko.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "당신의 건강 데이터." +headline_2: "당신의 AI." +headline_3: "당신의 답변." +intro: "영상, 검사 결과 등을 업로드하세요. AI를 연결하여 보고 있는 내용을 이해하는 데 도움을 받으세요." +email: "이메일" +get_started: "시작하기" +data_yours: "당신의 데이터는 당신의 것" +never_training: "훈련에 사용되지 않음" +never_training_desc: "당신의 이미지는 AI 모델 훈련에 사용되지 않습니다." +never_shared: "공유되지 않음" +never_shared_desc: "당신의 데이터를 누구와도 공유하지 않습니다." +encrypted: "암호화 저장" +encrypted_desc: "모든 데이터가 저장 시 암호화됩니다." +delete: "언제든 삭제 가능" +delete_desc: "당신의 데이터, 당신의 통제." + +# Verify +check_email: "이메일을 확인하세요" +code_sent_to: "6자리 코드를 보냈습니다:" +verification_code: "인증 코드" +verify: "인증" +use_different_email: "다른 이메일 사용" +invalid_code: "잘못되었거나 만료된 코드입니다. 다시 시도하세요." + +# Onboard +create_dossier: "서류철 만들기" +create_profile_intro: "시작하려면 자신에 대해 알려주세요." +name: "이름" +name_placeholder: "이름" +date_of_birth: "생년월일" +sex_at_birth: "출생 시 성별" +female: "여성" +male: "남성" +create_my_dossier: "내 서류철 만들기" + +# Minor error +must_be_18: "계정을 만들려면 18세 이상이어야 합니다" +minor_explanation: "다른 사람을 위해 설정하는 경우, 먼저 본인의 프로필부터 시작하세요. 이렇게 하면 본인만 그들의 건강 데이터에 접근할 수 있습니다." +minor_next_steps: "서류철을 만든 후 다른 사람을 추가할 수 있습니다." +use_different_dob: "다른 생년월일 사용" + +# Minor login block +minor_login_blocked: "로그인하려면 18세 이상이어야 합니다" +minor_ask_guardian: "%s에게 서류철 접근을 요청하세요." +minor_ask_guardian_generic: "부모님이나 보호자에게 서류철 접근을 요청하세요." + +# Dashboard +dossiers: "서류철" +dossiers_intro: "본인 또는 다른 사람의 건강 데이터 관리" +you: "나" +view: "보기" +save: "저장" +cancel: "취소" +add_dossier: "서류철 추가" +edit_dossier: "서류철 편집" +care: "돌봄" +logout: "로그아웃" + +# Profile detail +back_to_dossiers: "서류철로 돌아가기" +born: "출생" +no_access_yet: "본인만 접근할 수 있습니다." +people_with_access: "접근 권한이 있는 사람" +share_access: "접근 권한 공유" +can_edit: "데이터 추가 가능" +remove: "제거" +confirm_revoke: "접근 권한을 제거하시겠습니까?" + +# Dossier sections +section_imaging: "영상" +section_labs: "검사" +section_uploads: "업로드" +section_vitals: "활력징후" +section_medications: "약물" +section_records: "기록" +section_journal: "일지" +section_genetics: "유전" +section_privacy: "개인정보" + +# Section summaries +imaging_summary: "%d건의 검사 · %d장의 슬라이스" +no_imaging: "영상 데이터 없음" +no_lab_data: "검사 데이터 없음" +no_genetics: "유전 데이터 없음" +no_files: "파일 없음" +no_upload_access: "You don't have permission to upload" +files_summary: "%d개 파일 (%s)" +series_count: "%d개 시리즈" +vitals_desc: "혈압, 심박수, 산소포화도, 체중, 혈당" +medications_desc: "처방약 및 보충제" +records_desc: "임상 기록 및 의무 기록" +journal_desc: "증상, 통증 및 관찰" + +# Buttons and actions +open_viewer: "뷰어 열기" +manage: "관리" +show_all_studies: "모든 %d건의 검사 보기..." +coming_soon: "곧 출시" + +# Upload page +upload_files: "건강 데이터 업로드" +upload_files_intro: "의료 영상, 검사 결과, 유전체 파일 또는 건강 관련 문서를 업로드하세요." +upload_hint_broad: "DICOM, PDF, CSV, VCF 등" +uploading: "업로드 중..." +files_uploaded: "파일이 업로드됨" +upload_scans: "스캔 업로드" +upload_scans_intro: "영상 검사의 DICOM 파일이 포함된 폴더를 업로드하세요." +upload_drop: "클릭하거나 폴더를 여기로 드래그하세요" +upload_hint: "DICOM 폴더만" + +# Add profile +add_dossier_intro: "건강 데이터를 관리하고 싶은 사람을 추가하세요." +email_optional: "이메일 (선택사항)" +email_optional_hint: "18세 이상이면 직접 로그인할 수 있습니다" +your_relation: "이 사람과의 관계" +select_relation: "선택..." +i_provide_care: "이 사람을 돌봅니다" +i_am_their: "나는 그들의..." + +# Share access +share_access_intro: "접근할 사람 초대" +their_relation: "이 사람과의 관계" +can_add_data: "데이터 추가 가능 (보충제, 메모 등)" +send_invitation: "초대 보내기" +back_to_dossier: "서류철로 돌아가기" + +# Relations +my_role: "내 역할" +role: "role" + +# Invitation email +invite_email_subject: "%s님이 inou에 당신을 추가했습니다" +invite_email_body: "%s님이 당신의 건강 서류철을 inou에 추가하여 의료 데이터를 보고 관리할 수 있습니다." +invite_email_cta: "로그인하여 보기" +continue: "계속" + +# Access management +people_with_access_count: "접근 권한이 있는 사람" +view_audit_log: "감사 로그 보기" +export_data: "Download my data" +relation_with: "와의 관계" +audit_log: "감사 로그" +audit_log_intro: "활동 기록" +audit_log_desc: "이 서류철에 접근하거나 수정한 사람 추적" + +# Install / Connect +install_title: "Claude에 연결" +install_intro: "inou 브릿지를 설정하여 Claude가 건강 데이터를 분석할 수 있게 합니다" +install_step1: "1단계: 다운로드" +install_step1_desc: "플랫폼용 브릿지 받기" +install_download_intro: "운영 체제용 inou 브릿지를 다운로드하세요:" +install_step2: "2단계: 구성" +install_step2_desc: "Claude Desktop 설정에 추가" +install_config_intro: "Claude Desktop 설정 파일에 다음을 추가하세요:" +install_step3: "3단계: 테스트" +install_step3_desc: "연결 확인" +install_test_intro: "Claude Desktop을 재시작하고 'inou 프로필 보여줘'라고 물어보세요" +nav_install: "Claude에 연결" +nav_home: "홈" + +# Status +pending: "대기 중" +rate_limit_exceeded: "현재 위치에서 가입 시도가 너무 많습니다. 내일 다시 시도하세요." + +# Sex display +sex_male: "남성" +sex_female: "여성" +sex_na: "기타" + +# Friend invite email +friend_invite_subject: "이것 좀 봐 — %s" +friend_invite_p1: "inou를 사용하고 있어요. 건강 데이터를 안전하게 저장하고 AI로 분석할 수 있는 서비스예요. 우리 가족의 모든 건강 정보를 한 곳에 보관할 수 있어요 — 영상 검사, 검사 결과, 의료 기록 등. 당신에게도 유용할 것 같아서 알려드려요." +friend_invite_p2: "진정한 힘은 AI를 사용해서 모든 것을 이해할 수 있다는 거예요: 보고서가 실제로 무엇을 의미하는지 이해하고, 시간에 따른 추세를 파악하거나, 일상적인 언어로 질문하고 명확한 답변을 얻을 수 있어요." +friend_invite_btn: "inou 알아보기" +friend_invite_dear: "%s님," +rel_0: "당신" +rel_1: "부모" +rel_2: "자녀" +rel_3: "배우자" +rel_4: "형제자매" +rel_5: "후견인" +rel_6: "간병인" +rel_7: "코치" +rel_8: "의사" +rel_9: "친구" +rel_10: "기타" +rel_99: "Demo" +select_relation: "관계 선택..." + +# 카테고리 +category000: 영상 +category001: 문서 +category002: 검사 결과 +category003: 게놈 +category004: 업로드 +category005: 상담 +category006: 진단 +category007: 영상 검사 결과 +category008: 뇌파 검사 결과 +category009: 활력 징후 +category010: 운동 +category011: 약물 +category012: 보충제 +category013: 영양 +category014: 생식력 +category015: 증상 +category016: 메모 +category017: 병력 +category018: 가족력 +category019: 수술 +category020: 입원 +category021: 출생 데이터 +category022: 의료 기기 +category023: 치료 +category024: 평가 +category025: 의료 제공자 +category026: 질문 + +# Genome +genome_english_only: "모든 유전자 정보는 영어로 되어 있습니다. Claude를 사용하여 한국어로 상담하세요." +genome_variants: "변이" +genome_hidden: "숨김" +genome_show_all_categories: "전체 %d개 카테고리 표시" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/nl.yaml b/lang/nl.yaml new file mode 100644 index 0000000..933081a --- /dev/null +++ b/lang/nl.yaml @@ -0,0 +1,240 @@ +# Landing +headline_1: "Jouw gezondheidsdata." +headline_2: "Jouw AI." +headline_3: "Jouw antwoorden." +intro: "Upload beeldvorming, labresultaten en meer. Verbind je AI om te begrijpen wat je ziet." +email: "E-mail" +get_started: "Aan de slag" +data_yours: "Jouw data blijft van jou" +never_training: "Nooit gebruikt voor training" +never_training_desc: "Je beelden worden nooit gebruikt om AI-modellen te trainen." +never_shared: "Nooit gedeeld" +never_shared_desc: "We delen je data nooit met anderen." +encrypted: "Versleutelde opslag" +encrypted_desc: "Alle data versleuteld opgeslagen." +delete: "Altijd verwijderen" +delete_desc: "Jouw data, jouw controle." + +# Verify +check_email: "Controleer je e-mail" +code_sent_to: "We hebben een 6-cijferige code gestuurd naar" +verification_code: "Verificatiecode" +verify: "Verifiëren" +use_different_email: "Ander e-mailadres gebruiken" +invalid_code: "Ongeldige of verlopen code. Probeer opnieuw." + +# Onboard +create_dossier: "Maak je dossier aan" +create_profile_intro: "Vertel ons over jezelf om te beginnen." +name: "Naam" +name_placeholder: "Je naam" +date_of_birth: "Geboortedatum" +sex_at_birth: "Geslacht bij geboorte" +female: "Vrouw" +male: "Man" +create_my_dossier: "Mijn dossier aanmaken" + +# Minor error +must_be_18: "Je moet 18 zijn om een account aan te maken" +minor_explanation: "Als je dit voor iemand anders instelt, begin dan eerst met je eigen profiel. Zo heb alleen jij toegang tot hun gezondheidsgegevens." +minor_next_steps: "Na het aanmaken van je dossier kun je anderen toevoegen." +use_different_dob: "Andere geboortedatum gebruiken" + +# Minor login block +minor_login_blocked: "Je moet 18 zijn om in te loggen" +minor_ask_guardian: "Vraag %s om toegang tot je dossier." +minor_ask_guardian_generic: "Vraag een ouder of voogd om toegang tot je dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Beheer de gezondheidsgegevens van jezelf of voor anderen" +you: "jij" +view: "Bekijken" +save: "Opslaan" +cancel: "Annuleren" +add_dossier: "Dossier toevoegen" +edit_dossier: "Dossier bewerken" +care: "zorg" +logout: "Uitloggen" + +# Profile detail +back_to_dossiers: "Terug naar dossiers" +born: "Geboren" +no_access_yet: "Alleen jij hebt toegang." +people_with_access: "Personen met toegang" +share_access: "Toegang delen" +can_edit: "kan gegevens toevoegen" +remove: "Verwijderen" +confirm_revoke: "Toegang intrekken?" + +# Dossier sections +section_imaging: "Radiologie" +section_labs: "Labresultaten" +section_uploads: "Uploads" +section_vitals: "Vitale functies" +section_medications: "Medicatie" +section_records: "Dossiers" +section_journal: "Dagboek" + +# Section summaries +imaging_summary: "%d onderzoeken · %d beelden" +no_imaging: "Geen beeldvorming" +no_lab_data: "Geen labresultaten" +no_files: "Geen bestanden" +no_upload_access: "You don't have permission to upload" +files_summary: "%d bestanden (%s)" +series_count: "%d series" +vitals_desc: "Bloeddruk, hartslag, SpO₂, gewicht, glucose" +medications_desc: "Recepten en supplementen" +records_desc: "Klinische notities en medische dossiers" +journal_desc: "Symptomen, pijn en observaties" + +# Buttons and actions +open_viewer: "Viewer openen" +manage: "Beheren" +show_all_studies: "Toon alle %d onderzoeken..." +coming_soon: "Binnenkort beschikbaar" + +# Upload page +upload_files: "Gezondheidsgegevens uploaden" +upload_files_intro: "Upload medische beeldvorming, labresultaten, genoombestanden of andere gezondheidsgerelateerde documenten." +upload_hint_broad: "DICOM, PDF, CSV, VCF en meer" +uploading: "Uploaden..." +files_uploaded: "bestanden geüpload" +upload_scans: "Scans uploaden" +upload_scans_intro: "Upload een map met DICOM-bestanden van je beeldvormend onderzoek." +upload_drop: "Klik of sleep een map hierheen" +upload_hint: "Alleen DICOM-mappen" + +# Add profile +add_dossier_intro: "Voeg iemand toe wiens gezondheidsgegevens je wilt beheren." +email_optional: "E-mail (optioneel)" +email_optional_hint: "Als ze 18+ zijn, kunnen ze zelf inloggen" +your_relation: "Jouw relatie met hen" +select_relation: "Selecteer..." +i_provide_care: "Ik zorg voor deze persoon" + +# Share access +share_access_intro: "Nodig iemand uit voor toegang tot" +their_relation: "Hun relatie met deze persoon" +can_add_data: "Kan gegevens toevoegen (supplementen, notities, etc.)" +send_invitation: "Uitnodiging versturen" +back_to_dossier: "Terug naar dossier" + +# Relations + +# Invitation email +invite_email_subject: "%s heeft je toegevoegd aan inou" +invite_email_body: "%s heeft je gezondheidsdossier toegevoegd aan inou zodat je je medische gegevens kunt bekijken en beheren." +invite_email_cta: "Inloggen om te bekijken" +continue: "Doorgaan" +i_am_their: "Ik ben hun..." + +# Simple relation names (for display) +my_role: "mijn rol" +role: "role" +section_privacy: "Privacy" +people_with_access_count: "personen met toegang" +view_audit_log: "Bekijk auditlog" +export_data: "Download my data" +relation_with: "Relatie met" +audit_log: "Auditlog" +audit_log_intro: "Activiteitengeschiedenis voor" +audit_log_desc: "Bekijk wie dit dossier heeft bekeken of gewijzigd" +install_title: "Verbind met Claude" +install_intro: "Stel de inou-bridge in zodat Claude je gezondheidsgegevens kan analyseren" +install_step1: "Stap 1: Download" +install_step1_desc: "Download de bridge voor jouw platform" +install_download_intro: "Download de inou-bridge voor jouw besturingssysteem:" +install_step2: "Stap 2: Configureer" +install_step2_desc: "Voeg toe aan Claude Desktop configuratie" +install_config_intro: "Voeg dit toe aan je Claude Desktop configuratiebestand:" +install_step3: "Stap 3: Test" +install_step3_desc: "Controleer de verbinding" +install_test_intro: "Herstart Claude Desktop en vraag: 'Toon mijn inou profielen'" +nav_install: "Verbind met Claude" +nav_home: "Home" +pending: "in afwachting" +rate_limit_exceeded: "Te veel aanmeldpogingen vanaf uw locatie. Probeer het morgen opnieuw." +section_genetics: Genetica +no_genetics: Geen genetische gegevens + +sex_male: "mannelijk" +sex_female: "vrouwelijk" +sex_na: "anders" + +# Friend invite email +friend_invite_subject: "Kijk hier eens naar — %s" +friend_invite_p1: "Ik gebruik inou, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. Het houdt alle gezondheidsinformatie van mijn familie op één plek — beeldvorming, labresultaten, medische dossiers — en ik dacht dat jij het misschien ook handig zou vinden." +friend_invite_p2: "De echte kracht is dat je AI kunt gebruiken om alles te begrijpen: begrijpen wat een rapport echt betekent, trends in de tijd ontdekken, of gewoon vragen stellen in gewone taal en duidelijke antwoorden krijgen." +friend_invite_btn: "Bekijk inou" +friend_invite_dear: "Beste %s," +rel_0: "jij" +rel_1: "Ouder" +rel_2: "Kind" +rel_3: "Partner" +rel_4: "Broer/Zus" +rel_5: "Voogd" +rel_6: "Verzorger" +rel_7: "Coach" +rel_8: "Arts" +rel_9: "Vriend" +rel_10: "Anders" +rel_99: "Demo" +select_relation: "Selecteer relatie..." +audit_dossier_added: "Nieuw dossier voor %s aangemaakt door %s" +audit_dossier_edited: "Dossier %s bewerkt door %s" +audit_access_granted: "Toegang tot %s verleend aan %s" +audit_dossier_created: Account aangemaakt door %s +audit_access_revoked: Toegang voor %s tot %s ingetrokken +audit_file_upload: Bestand %s geüpload door %s +audit_file_delete: Bestand %s verwijderd door %s +audit_file_category_change: Bestandscategorie %s gewijzigd door %s +audit_genome_import: %s genetische varianten geïmporteerd + +# Categorieën +category000: Beeldvorming +category001: Document +category002: Labuitslag +category003: Genoom +category004: Upload +category005: Consult +category006: Diagnose +category007: Beeldvormingsresultaat +category008: EEG-resultaat +category009: Vitale waarde +category010: Beweging +category011: Medicatie +category012: Supplement +category013: Voeding +category014: Vruchtbaarheid +category015: Symptoom +category016: Notitie +category017: Medische geschiedenis +category018: Familiegeschiedenis +category019: Operatie +category020: Ziekenhuisopname +category021: Geboortegegevens +category022: Medisch hulpmiddel +category023: Therapie +category024: Beoordeling +category025: Zorgverlener +category026: Vraag + +# Genome +genome_english_only: "Alle genetische informatie is in het Engels. Gebruik Claude om het in het Nederlands te bespreken." +genome_variants: "varianten" +genome_hidden: "verborgen" +genome_show_all_categories: "Toon alle %d categorieën" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/no.yaml b/lang/no.yaml new file mode 100644 index 0000000..c1eb88a --- /dev/null +++ b/lang/no.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Dine helsedata." +headline_2: "Din AI." +headline_3: "Dine svar." +intro: "Last opp bildediagnostikk, labresultater og mer. Koble til din AI for å hjelpe deg forstå hva du ser på." +email: "E-post" +get_started: "Kom i gang" +data_yours: "Dine data forblir dine" +never_training: "Brukes aldri til trening" +never_training_desc: "Bildene dine brukes aldri til å trene AI-modeller." +never_shared: "Deles aldri" +never_shared_desc: "Vi deler aldri dataene dine med noen." +encrypted: "Kryptert lagring" +encrypted_desc: "Alle data kryptert i hvile." +delete: "Slett når som helst" +delete_desc: "Dine data, din kontroll." + +# Verify +check_email: "Sjekk e-posten din" +code_sent_to: "Vi sendte en 6-sifret kode til" +verification_code: "Verifiseringskode" +verify: "Verifiser" +use_different_email: "Bruk en annen e-post" +invalid_code: "Ugyldig eller utløpt kode. Prøv igjen." + +# Onboard +create_dossier: "Opprett din dosje" +create_profile_intro: "Fortell oss om deg selv for å komme i gang." +name: "Navn" +name_placeholder: "Ditt navn" +date_of_birth: "Fødselsdato" +sex_at_birth: "Kjønn ved fødsel" +female: "Kvinne" +male: "Mann" +create_my_dossier: "Opprett min dosje" + +# Minor error +must_be_18: "Du må være 18 for å opprette en konto" +minor_explanation: "Hvis du setter dette opp for noen andre, start med din egen profil først. Dette sikrer at bare du kan få tilgang til deres helsedata." +minor_next_steps: "Etter at du har opprettet din dosje, kan du legge til andre." +use_different_dob: "Bruk en annen fødselsdato" + +# Minor login block +minor_login_blocked: "Du må være 18 for å logge inn" +minor_ask_guardian: "Be %s om tilgang til din dosje." +minor_ask_guardian_generic: "Be en forelder eller foresatt om tilgang til din dosje." + +# Dashboard +dossiers: "Dosjer" +dossiers_intro: "Administrer helsedata for deg selv eller andre" +you: "deg" +view: "Vis" +save: "Lagre" +cancel: "Avbryt" +add_dossier: "Legg til dosje" +edit_dossier: "Rediger dosje" +care: "omsorg" +logout: "Logg ut" + +# Profile detail +back_to_dossiers: "Tilbake til dosjer" +born: "Født" +no_access_yet: "Bare du har tilgang." +people_with_access: "Personer med tilgang" +share_access: "Del tilgang" +can_edit: "kan legge til data" +remove: "Fjern" +confirm_revoke: "Fjerne tilgang?" + +# Dossier sections +section_imaging: "Bildediagnostikk" +section_labs: "Lab" +section_uploads: "Opplastinger" +section_vitals: "Vitaltegn" +section_medications: "Medisiner" +section_records: "Journaler" +section_journal: "Dagbok" +section_genetics: "Genetikk" +section_privacy: "Personvern" + +# Section summaries +imaging_summary: "%d undersøkelser · %d snitt" +no_imaging: "Ingen bildedata" +no_lab_data: "Ingen labdata" +no_genetics: "Ingen genetiske data" +no_files: "Ingen filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtrykk, puls, SpO₂, vekt, blodsukker" +medications_desc: "Resepter og kosttilskudd" +records_desc: "Kliniske notater og journaler" +journal_desc: "Symptomer, smerte og observasjoner" + +# Buttons and actions +open_viewer: "Åpne visning" +manage: "Administrer" +show_all_studies: "Vis alle %d undersøkelser..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Last opp helsedata" +upload_files_intro: "Last opp medisinsk bildediagnostikk, labresultater, genomfiler eller helserelaterte dokumenter." +upload_hint_broad: "DICOM, PDF, CSV, VCF og mer" +uploading: "Laster opp..." +files_uploaded: "filer lastet opp" +upload_scans: "Last opp skanninger" +upload_scans_intro: "Last opp en mappe med DICOM-filer fra din bildeundersøkelse." +upload_drop: "Klikk eller dra en mappe hit" +upload_hint: "Kun DICOM-mapper" + +# Add profile +add_dossier_intro: "Legg til noen hvis helsedata du vil administrere." +email_optional: "E-post (valgfritt)" +email_optional_hint: "Hvis de er 18, kan de logge inn selv" +your_relation: "Ditt forhold til dem" +select_relation: "Velg..." +i_provide_care: "Jeg gir omsorg til denne personen" +i_am_their: "Jeg er deres..." + +# Share access +share_access_intro: "Inviter noen til å få tilgang" +their_relation: "Deres forhold til denne personen" +can_add_data: "Kan legge til data (kosttilskudd, notater, etc.)" +send_invitation: "Send invitasjon" +back_to_dossier: "Tilbake til dosje" + +# Relations +my_role: "min rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s la deg til i inou" +invite_email_body: "%s la til din helsedosje i inou slik at du kan se og administrere dine medisinske data." +invite_email_cta: "Logg inn for å se" +continue: "Fortsett" + +# Access management +people_with_access_count: "personer med tilgang" +view_audit_log: "Vis aktivitetslogg" +export_data: "Download my data" +relation_with: "Forhold til" +audit_log: "Aktivitetslogg" +audit_log_intro: "Aktivitetshistorikk for" +audit_log_desc: "Spor hvem som har hatt tilgang til eller endret denne dosjen" + +# Install / Connect +install_title: "Koble til Claude" +install_intro: "Sett opp inou-broen for å la Claude analysere helsedataene dine" +install_step1: "Steg 1: Last ned" +install_step1_desc: "Hent broen for din plattform" +install_download_intro: "Last ned inou-broen for ditt operativsystem:" +install_step2: "Steg 2: Konfigurer" +install_step2_desc: "Legg til i Claude Desktop-konfigurasjonen" +install_config_intro: "Legg til dette i Claude Desktop-konfigurasjonsfilen din:" +install_step3: "Steg 3: Test" +install_step3_desc: "Verifiser tilkoblingen" +install_test_intro: "Start Claude Desktop på nytt og spør: 'Vis meg mine inou-profiler'" +nav_install: "Koble til Claude" +nav_home: "Hjem" + +# Status +pending: "venter" +rate_limit_exceeded: "For mange registreringsforsøk fra din lokasjon. Prøv igjen i morgen." + +# Sex display +sex_male: "mann" +sex_female: "kvinne" +sex_na: "annet" + +# Friend invite email +friend_invite_subject: "Sjekk dette — %s" +friend_invite_p1: "Jeg bruker inou, den sikre måten å lagre helsedata og utforske dem med AI. Det holder all helseinformasjonen til familien min på ett sted — bildestudier, labresultater, journaler — og jeg tenkte det kanskje kunne være nyttig for deg også." +friend_invite_p2: "Den virkelige kraften ligger i å kunne bruke AI til å forstå alt: forstå hva en rapport faktisk betyr, oppdage trender over tid, eller bare stille spørsmål på vanlig norsk og få klare svar." +friend_invite_btn: "Oppdag inou" +friend_invite_dear: "Hei %s," +rel_0: "du" +rel_1: "Forelder" +rel_2: "Barn" +rel_3: "Ektefelle" +rel_4: "Søsken" +rel_5: "Verge" +rel_6: "Omsorgsgiver" +rel_7: "Coach" +rel_8: "Lege" +rel_9: "Venn" +rel_10: "Annet" +rel_99: "Demo" +select_relation: "Velg relasjon..." + +# Kategorier +category000: Bildediagnostikk +category001: Dokument +category002: Labresultat +category003: Genom +category004: Opplasting +category005: Konsultasjon +category006: Diagnose +category007: Bilderesultat +category008: EEG-resultat +category009: Vitalverdi +category010: Trening +category011: Medisin +category012: Tilskudd +category013: Ernæring +category014: Fertilitet +category015: Symptom +category016: Notat +category017: Sykehistorie +category018: Familiehistorie +category019: Kirurgi +category020: Sykehusopphold +category021: Fødselsdata +category022: Medisinsk utstyr +category023: Terapi +category024: Vurdering +category025: Helsepersonell +category026: Spørsmål + +# Genome +genome_english_only: "All genetisk informasjon er på engelsk. Bruk Claude for å diskutere det på norsk." +genome_variants: "varianter" +genome_hidden: "skjult" +genome_show_all_categories: "Vis alle %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/pt.yaml b/lang/pt.yaml new file mode 100644 index 0000000..2417885 --- /dev/null +++ b/lang/pt.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Seus dados de saúde." +headline_2: "Sua IA." +headline_3: "Suas respostas." +intro: "Carregue exames de imagem, laboratórios e mais. Conecte sua IA para ajudar a entender o que você está vendo." +email: "E-mail" +get_started: "Começar" +data_yours: "Seus dados permanecem seus" +never_training: "Nunca usado para treinamento" +never_training_desc: "Suas imagens nunca são usadas para treinar modelos de IA." +never_shared: "Nunca compartilhado" +never_shared_desc: "Nunca compartilhamos seus dados com ninguém." +encrypted: "Armazenamento criptografado" +encrypted_desc: "Todos os dados criptografados em repouso." +delete: "Exclua a qualquer momento" +delete_desc: "Seus dados, seu controle." + +# Verify +check_email: "Verifique seu e-mail" +code_sent_to: "Enviamos um código de 6 dígitos para" +verification_code: "Código de verificação" +verify: "Verificar" +use_different_email: "Usar outro e-mail" +invalid_code: "Código inválido ou expirado. Tente novamente." + +# Onboard +create_dossier: "Crie seu dossiê" +create_profile_intro: "Conte-nos sobre você para começar." +name: "Nome" +name_placeholder: "Seu nome" +date_of_birth: "Data de nascimento" +sex_at_birth: "Sexo ao nascer" +female: "Feminino" +male: "Masculino" +create_my_dossier: "Criar meu dossiê" + +# Minor error +must_be_18: "Você deve ter 18 anos para criar uma conta" +minor_explanation: "Se você está configurando isso para outra pessoa, comece com seu próprio perfil primeiro. Isso garante que só você possa acessar os dados de saúde dela." +minor_next_steps: "Após criar seu dossiê, você pode adicionar outros." +use_different_dob: "Usar outra data de nascimento" + +# Minor login block +minor_login_blocked: "Você deve ter 18 anos para entrar" +minor_ask_guardian: "Peça a %s para acessar seu dossiê." +minor_ask_guardian_generic: "Peça a um pai ou responsável para acessar seu dossiê." + +# Dashboard +dossiers: "Dossiês" +dossiers_intro: "Gerencie dados de saúde para você ou outros" +you: "você" +view: "Ver" +save: "Salvar" +cancel: "Cancelar" +add_dossier: "Adicionar dossiê" +edit_dossier: "Editar dossiê" +care: "cuidado" +logout: "Sair" + +# Profile detail +back_to_dossiers: "Voltar aos dossiês" +born: "Nascido" +no_access_yet: "Apenas você tem acesso." +people_with_access: "Pessoas com acesso" +share_access: "Compartilhar acesso" +can_edit: "pode adicionar dados" +remove: "Remover" +confirm_revoke: "Remover acesso?" + +# Dossier sections +section_imaging: "Imagens" +section_labs: "Laboratório" +section_uploads: "Arquivos" +section_vitals: "Sinais vitais" +section_medications: "Medicamentos" +section_records: "Prontuários" +section_journal: "Diário" +section_genetics: "Genética" +section_privacy: "Privacidade" + +# Section summaries +imaging_summary: "%d estudos · %d cortes" +no_imaging: "Sem dados de imagem" +no_lab_data: "Sem dados de laboratório" +no_genetics: "Sem dados genéticos" +no_files: "Sem arquivos" +no_upload_access: "You don't have permission to upload" +files_summary: "%d arquivos (%s)" +series_count: "%d séries" +vitals_desc: "Pressão arterial, frequência cardíaca, SpO₂, peso, glicose" +medications_desc: "Prescrições e suplementos" +records_desc: "Notas clínicas e prontuários médicos" +journal_desc: "Sintomas, dor e observações" + +# Buttons and actions +open_viewer: "Abrir visualizador" +manage: "Gerenciar" +show_all_studies: "Mostrar todos os %d estudos..." +coming_soon: "Em breve" + +# Upload page +upload_files: "Carregar dados de saúde" +upload_files_intro: "Carregue imagens médicas, resultados de laboratório, arquivos genômicos ou quaisquer documentos de saúde." +upload_hint_broad: "DICOM, PDF, CSV, VCF e mais" +uploading: "Carregando..." +files_uploaded: "arquivos carregados" +upload_scans: "Carregar exames" +upload_scans_intro: "Carregue uma pasta contendo arquivos DICOM do seu estudo de imagem." +upload_drop: "Clique ou arraste uma pasta aqui" +upload_hint: "Apenas pastas DICOM" + +# Add profile +add_dossier_intro: "Adicione alguém cujos dados de saúde você deseja gerenciar." +email_optional: "E-mail (opcional)" +email_optional_hint: "Se tiver 18 anos, pode entrar sozinho" +your_relation: "Seu parentesco com esta pessoa" +select_relation: "Selecione..." +i_provide_care: "Eu cuido desta pessoa" +i_am_their: "Eu sou..." + +# Share access +share_access_intro: "Convidar alguém para acessar" +their_relation: "Parentesco desta pessoa com o dossiê" +can_add_data: "Pode adicionar dados (suplementos, notas, etc.)" +send_invitation: "Enviar convite" +back_to_dossier: "Voltar ao dossiê" + +# Relations +my_role: "meu papel" +role: "role" + +# Invitation email +invite_email_subject: "%s adicionou você ao inou" +invite_email_body: "%s adicionou seu dossiê de saúde ao inou para que você possa visualizar e gerenciar seus dados médicos." +invite_email_cta: "Entrar para ver" +continue: "Continuar" + +# Access management +people_with_access_count: "pessoas com acesso" +view_audit_log: "Ver registro de atividades" +export_data: "Download my data" +relation_with: "Relação com" +audit_log: "Registro de atividades" +audit_log_intro: "Histórico de atividades para" +audit_log_desc: "Acompanhe quem acessou ou modificou este dossiê" + +# Install / Connect +install_title: "Conectar ao Claude" +install_intro: "Configure a ponte inou para permitir que o Claude analise seus dados de saúde" +install_step1: "Passo 1: Baixar" +install_step1_desc: "Obtenha a ponte para sua plataforma" +install_download_intro: "Baixe a ponte inou para seu sistema operacional:" +install_step2: "Passo 2: Configurar" +install_step2_desc: "Adicione à configuração do Claude Desktop" +install_config_intro: "Adicione isso ao seu arquivo de configuração do Claude Desktop:" +install_step3: "Passo 3: Testar" +install_step3_desc: "Verifique a conexão" +install_test_intro: "Reinicie o Claude Desktop e pergunte: 'Mostre meus perfis inou'" +nav_install: "Conectar ao Claude" +nav_home: "Início" + +# Status +pending: "pendente" +rate_limit_exceeded: "Muitas tentativas de cadastro da sua localização. Tente novamente amanhã." + +# Sex display +sex_male: "masculino" +sex_female: "feminino" +sex_na: "outro" + +# Friend invite email +friend_invite_subject: "Dá uma olhada — %s" +friend_invite_p1: "Tenho usado o inou, a forma segura de armazenar dados de saúde e explorá-los com IA. Ele mantém todas as informações de saúde da minha família num só lugar — exames de imagem, resultados de laboratório, prontuários médicos — e achei que também poderia ser útil para você." +friend_invite_p2: "O verdadeiro poder está em usar IA para entender tudo: compreender o que um relatório realmente significa, identificar tendências ao longo do tempo, ou simplesmente fazer perguntas em linguagem simples e obter respostas claras." +friend_invite_btn: "Conhecer o inou" +friend_invite_dear: "Olá %s," +rel_0: "você" +rel_1: "Pai/Mãe" +rel_2: "Filho/a" +rel_3: "Cônjuge" +rel_4: "Irmão/ã" +rel_5: "Tutor" +rel_6: "Cuidador" +rel_7: "Coach" +rel_8: "Médico" +rel_9: "Amigo" +rel_10: "Outro" +rel_99: "Demo" +select_relation: "Selecionar relação..." + +# Categorias +category000: Imagem médica +category001: Documento +category002: Resultado de laboratório +category003: Genoma +category004: Upload +category005: Consulta +category006: Diagnóstico +category007: Resultado de imagem +category008: Resultado de EEG +category009: Sinal vital +category010: Exercício +category011: Medicamento +category012: Suplemento +category013: Nutrição +category014: Fertilidade +category015: Sintoma +category016: Nota +category017: Histórico médico +category018: Histórico familiar +category019: Cirurgia +category020: Hospitalização +category021: Dados de nascimento +category022: Dispositivo médico +category023: Terapia +category024: Avaliação +category025: Prestador de saúde +category026: Pergunta + +# Genome +genome_english_only: "Todas as informações genéticas estão em inglês. Use o Claude para discuti-las em português." +genome_variants: "variantes" +genome_hidden: "ocultas" +genome_show_all_categories: "Mostrar todas as %d categorias" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ru.yaml b/lang/ru.yaml new file mode 100644 index 0000000..eb32697 --- /dev/null +++ b/lang/ru.yaml @@ -0,0 +1,226 @@ +# Landing +headline_1: "Ваши медицинские данные." +headline_2: "Ваш ИИ." +headline_3: "Ваши ответы." +intro: "Загружайте снимки, анализы и многое другое. Подключите ИИ, чтобы понять, что вы видите." +email: "Электронная почта" +get_started: "Начать" +data_yours: "Ваши данные остаются вашими" +never_training: "Никогда не используются для обучения" +never_training_desc: "Ваши снимки никогда не используются для обучения моделей ИИ." +never_shared: "Никогда не передаются" +never_shared_desc: "Мы никогда не делимся вашими данными." +encrypted: "Зашифрованное хранилище" +encrypted_desc: "Все данные зашифрованы." +delete: "Удалить в любое время" +delete_desc: "Ваши данные, ваш контроль." + +# Verify +check_email: "Проверьте почту" +code_sent_to: "Мы отправили 6-значный код на" +verification_code: "Код подтверждения" +verify: "Подтвердить" +use_different_email: "Использовать другой адрес" +invalid_code: "Неверный или просроченный код. Попробуйте снова." + +# Onboard +create_dossier: "Создайте досье" +create_profile_intro: "Расскажите о себе, чтобы начать." +name: "Имя" +name_placeholder: "Ваше имя" +date_of_birth: "Дата рождения" +sex_at_birth: "Пол при рождении" +female: "Женский" +male: "Мужской" +create_my_dossier: "Создать моё досье" + +# Minor error +must_be_18: "Вам должно быть 18 лет для создания аккаунта" +minor_explanation: "Если вы настраиваете это для другого человека, сначала создайте свой профиль. Это гарантирует, что только вы сможете получить доступ к их медицинским данным." +minor_next_steps: "После создания досье вы сможете добавить других." +use_different_dob: "Использовать другую дату рождения" + +# Minor login block +minor_login_blocked: "Вам должно быть 18 лет для входа" +minor_ask_guardian: "Попросите %s открыть доступ к вашему досье." +minor_ask_guardian_generic: "Попросите родителя или опекуна открыть доступ к вашему досье." + +# Dashboard +dossiers: "Досье" +dossiers_intro: "Управляйте медицинскими данными для себя или других" +you: "вы" +view: "Просмотр" +add_dossier: "Добавить досье" +edit_dossier: "Редактировать досье" +care: "забота" +logout: "Выйти" + +# Profile detail +back_to_dossiers: "Назад к досье" +born: "Родился" +no_access_yet: "Только у вас есть доступ." +people_with_access: "Люди с доступом" +share_access: "Поделиться доступом" +can_edit: "может добавлять данные" +remove: "Удалить" +confirm_revoke: "Отозвать доступ?" + +# Dossier sections +section_imaging: "Снимки" +section_labs: "Анализы" +section_uploads: "Загрузки" +section_vitals: "Показатели" +section_medications: "Лекарства" +section_records: "Записи" +section_journal: "Дневник" + +# Section summaries +imaging_summary: "%d исследований · %d снимков" +no_imaging: "Нет снимков" +no_lab_data: "Нет анализов" +no_files: "Нет файлов" +no_upload_access: "You don't have permission to upload" +files_summary: "%d файлов (%s)" +series_count: "%d серий" +vitals_desc: "Давление, пульс, SpO₂, вес, глюкоза" +medications_desc: "Рецепты и добавки" +records_desc: "Клинические записи и медицинские документы" +journal_desc: "Симптомы, боль и наблюдения" + +# Buttons and actions +open_viewer: "Открыть просмотрщик" +manage: "Управление" +show_all_studies: "Показать все %d исследований..." +coming_soon: "Скоро" + +# Upload page +upload_files: "Загрузить медицинские данные" +upload_files_intro: "Загружайте медицинские снимки, анализы, геномные файлы или другие документы о здоровье." +upload_hint_broad: "DICOM, PDF, CSV, VCF и другие" +uploading: "Загрузка..." +files_uploaded: "файлов загружено" +upload_scans: "Загрузить снимки" +upload_scans_intro: "Загрузите папку с DICOM-файлами вашего исследования." +upload_drop: "Нажмите или перетащите папку сюда" +upload_hint: "Только папки DICOM" + +# Add profile +add_dossier_intro: "Добавьте человека, чьими медицинскими данными хотите управлять." +email_optional: "Email (необязательно)" +email_optional_hint: "Если им 18+, они смогут войти сами" +your_relation: "Ваши отношения с ними" +select_relation: "Выберите..." +i_provide_care: "Я забочусь об этом человеке" + +# Share access +share_access_intro: "Пригласите кого-то для доступа к" +their_relation: "Их отношения с этим человеком" +can_add_data: "Может добавлять данные (добавки, заметки и т.д.)" +send_invitation: "Отправить приглашение" +back_to_dossier: "Назад к досье" + +# Relations + +# Invitation email +invite_email_subject: "%s добавил вас в inou" +invite_email_body: "%s добавил ваше медицинское досье в inou, чтобы вы могли просматривать и управлять своими медицинскими данными." +invite_email_cta: "Войти для просмотра" +continue: "Продолжить" +i_am_their: "Я их..." + +# Simple relation names (for display) +my_role: "моя роль" +role: "role" +section_privacy: "Конфиденциальность" +people_with_access_count: "с доступом" +view_audit_log: "Журнал действий" +export_data: "Download my data" +relation_with: "Отношение с" +audit_log: "Журнал действий" +audit_log_intro: "История активности для" +audit_log_desc: "Отслеживание доступа и изменений в досье" +install_title: "Подключить к Claude" +install_intro: "Настройте мост inou, чтобы Claude мог анализировать ваши медицинские данные" +install_step1: "Шаг 1: Скачать" +install_step1_desc: "Получите мост для вашей платформы" +install_download_intro: "Скачайте мост inou для вашей операционной системы:" +install_step2: "Шаг 2: Настроить" +install_step2_desc: "Добавьте в конфигурацию Claude Desktop" +install_config_intro: "Добавьте это в файл конфигурации Claude Desktop:" +install_step3: "Шаг 3: Проверить" +install_step3_desc: "Проверьте соединение" +install_test_intro: "Перезапустите Claude Desktop и спросите: 'Покажи мои профили inou'" +nav_install: "Подключить к Claude" +nav_home: "Главная" +rate_limit_exceeded: "Слишком много попыток регистрации с вашего местоположения. Пожалуйста, попробуйте завтра." + +sex_male: "мужской" +sex_female: "женский" +sex_na: "другой" + +# Friend invite email +friend_invite_subject: "Посмотри — %s" +friend_invite_p1: "Я использую inou — безопасный способ хранить медицинские данные и анализировать их с помощью ИИ. Там хранится вся медицинская информация моей семьи — снимки, результаты анализов, медицинские записи — и я подумал, что тебе тоже может пригодиться." +friend_invite_p2: "Настоящая сила в том, что можно использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение, отследить тенденции или просто задать вопросы простым языком и получить понятные ответы." +friend_invite_btn: "Посмотреть inou" +friend_invite_dear: "Привет, %s!" +rel_0: "ты" +rel_1: "Родитель" +rel_2: "Ребёнок" +rel_3: "Супруг" +rel_4: "Брат/Сестра" +rel_5: "Опекун" +rel_6: "Сиделка" +rel_7: "Тренер" +rel_8: "Врач" +rel_9: "Друг" +rel_10: "Другое" +rel_99: "Demo" +select_relation: "Выберите отношение..." + +# Категории +category000: Визуализация +category001: Документ +category002: Анализ +category003: Геном +category004: Загрузка +category005: Консультация +category006: Диагноз +category007: Результат визуализации +category008: Результат ЭЭГ +category009: Показатель здоровья +category010: Физическая активность +category011: Лекарство +category012: Добавка +category013: Питание +category014: Фертильность +category015: Симптом +category016: Заметка +category017: История болезни +category018: Семейный анамнез +category019: Операция +category020: Госпитализация +category021: Данные о рождении +category022: Медицинское устройство +category023: Терапия +category024: Оценка +category025: Медицинский работник +category026: Вопрос + +# Genome +genome_english_only: "Вся генетическая информация на английском языке. Используйте Claude, чтобы обсудить её на русском." +genome_variants: "вариантов" +genome_hidden: "скрыто" +genome_show_all_categories: "Показать все %d категорий" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/sv.yaml b/lang/sv.yaml new file mode 100644 index 0000000..c4be91d --- /dev/null +++ b/lang/sv.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Din hälsodata." +headline_2: "Din AI." +headline_3: "Dina svar." +intro: "Ladda upp bilddiagnostik, labbresultat och mer. Anslut din AI för att hjälpa dig förstå vad du tittar på." +email: "E-post" +get_started: "Kom igång" +data_yours: "Din data förblir din" +never_training: "Används aldrig för träning" +never_training_desc: "Dina bilder används aldrig för att träna AI-modeller." +never_shared: "Delas aldrig" +never_shared_desc: "Vi delar aldrig din data med någon." +encrypted: "Krypterad lagring" +encrypted_desc: "All data krypterad i vila." +delete: "Radera när som helst" +delete_desc: "Din data, din kontroll." + +# Verify +check_email: "Kolla din e-post" +code_sent_to: "Vi skickade en 6-siffrig kod till" +verification_code: "Verifieringskod" +verify: "Verifiera" +use_different_email: "Använd en annan e-post" +invalid_code: "Ogiltig eller utgången kod. Försök igen." + +# Onboard +create_dossier: "Skapa din dossier" +create_profile_intro: "Berätta om dig själv för att komma igång." +name: "Namn" +name_placeholder: "Ditt namn" +date_of_birth: "Födelsedatum" +sex_at_birth: "Kön vid födseln" +female: "Kvinna" +male: "Man" +create_my_dossier: "Skapa min dossier" + +# Minor error +must_be_18: "Du måste vara 18 för att skapa ett konto" +minor_explanation: "Om du skapar detta för någon annan, börja med din egen profil först. Detta säkerställer att bara du kan komma åt deras hälsodata." +minor_next_steps: "Efter att du skapat din dossier kan du lägga till andra." +use_different_dob: "Använd ett annat födelsedatum" + +# Minor login block +minor_login_blocked: "Du måste vara 18 för att logga in" +minor_ask_guardian: "Be %s om åtkomst till din dossier." +minor_ask_guardian_generic: "Be en förälder eller vårdnadshavare om åtkomst till din dossier." + +# Dashboard +dossiers: "Dossier" +dossiers_intro: "Hantera hälsodata för dig själv eller andra" +you: "du" +view: "Visa" +save: "Spara" +cancel: "Avbryt" +add_dossier: "Lägg till dossier" +edit_dossier: "Redigera dossier" +care: "vård" +logout: "Logga ut" + +# Profile detail +back_to_dossiers: "Tillbaka till dossier" +born: "Född" +no_access_yet: "Bara du har åtkomst." +people_with_access: "Personer med åtkomst" +share_access: "Dela åtkomst" +can_edit: "kan lägga till data" +remove: "Ta bort" +confirm_revoke: "Ta bort åtkomst?" + +# Dossier sections +section_imaging: "Bilddiagnostik" +section_labs: "Labb" +section_uploads: "Uppladdningar" +section_vitals: "Vitalvärden" +section_medications: "Läkemedel" +section_records: "Journaler" +section_journal: "Dagbok" +section_genetics: "Genetik" +section_privacy: "Integritet" + +# Section summaries +imaging_summary: "%d undersökningar · %d snitt" +no_imaging: "Ingen bilddata" +no_lab_data: "Ingen labbdata" +no_genetics: "Ingen genetisk data" +no_files: "Inga filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtryck, puls, SpO₂, vikt, blodsocker" +medications_desc: "Recept och kosttillskott" +records_desc: "Kliniska anteckningar och journaler" +journal_desc: "Symtom, smärta och observationer" + +# Buttons and actions +open_viewer: "Öppna visare" +manage: "Hantera" +show_all_studies: "Visa alla %d undersökningar..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Ladda upp hälsodata" +upload_files_intro: "Ladda upp medicinsk bilddiagnostik, labbresultat, genomfiler eller hälsorelaterade dokument." +upload_hint_broad: "DICOM, PDF, CSV, VCF med mera" +uploading: "Laddar upp..." +files_uploaded: "filer uppladdade" +upload_scans: "Ladda upp skanningar" +upload_scans_intro: "Ladda upp en mapp med DICOM-filer från din bildundersökning." +upload_drop: "Klicka eller dra en mapp hit" +upload_hint: "Endast DICOM-mappar" + +# Add profile +add_dossier_intro: "Lägg till någon vars hälsodata du vill hantera." +email_optional: "E-post (valfritt)" +email_optional_hint: "Om de är 18 kan de logga in själva" +your_relation: "Din relation till dem" +select_relation: "Välj..." +i_provide_care: "Jag ger vård till denna person" +i_am_their: "Jag är deras..." + +# Share access +share_access_intro: "Bjud in någon att få åtkomst" +their_relation: "Deras relation till denna person" +can_add_data: "Kan lägga till data (kosttillskott, anteckningar, etc.)" +send_invitation: "Skicka inbjudan" +back_to_dossier: "Tillbaka till dossier" + +# Relations +my_role: "min roll" +role: "role" + +# Invitation email +invite_email_subject: "%s lade till dig i inou" +invite_email_body: "%s lade till din hälsodossier i inou så att du kan visa och hantera din medicinska data." +invite_email_cta: "Logga in för att visa" +continue: "Fortsätt" + +# Access management +people_with_access_count: "personer med åtkomst" +view_audit_log: "Visa aktivitetslogg" +export_data: "Download my data" +relation_with: "Relation till" +audit_log: "Aktivitetslogg" +audit_log_intro: "Aktivitetshistorik för" +audit_log_desc: "Spåra vem som har åtkomst till eller ändrat denna dossier" + +# Install / Connect +install_title: "Anslut till Claude" +install_intro: "Konfigurera inou-bryggan för att låta Claude analysera din hälsodata" +install_step1: "Steg 1: Ladda ner" +install_step1_desc: "Hämta bryggan för din plattform" +install_download_intro: "Ladda ner inou-bryggan för ditt operativsystem:" +install_step2: "Steg 2: Konfigurera" +install_step2_desc: "Lägg till i Claude Desktop-konfigurationen" +install_config_intro: "Lägg till detta i din Claude Desktop-konfigurationsfil:" +install_step3: "Steg 3: Testa" +install_step3_desc: "Verifiera anslutningen" +install_test_intro: "Starta om Claude Desktop och fråga: 'Visa mina inou-profiler'" +nav_install: "Anslut till Claude" +nav_home: "Hem" + +# Status +pending: "väntande" +rate_limit_exceeded: "För många registreringsförsök från din plats. Försök igen imorgon." + +# Sex display +sex_male: "man" +sex_female: "kvinna" +sex_na: "annat" + +# Friend invite email +friend_invite_subject: "Kolla in det här — %s" +friend_invite_p1: "Jag använder inou, det säkra sättet att lagra hälsodata och utforska den med AI. Det håller all min familjs hälsoinformation på ett ställe — bildstudier, labbresultat, journaler — och jag tänkte att det kanske kunde vara användbart för dig också." +friend_invite_p2: "Den verkliga kraften ligger i att kunna använda AI för att förstå allt: förstå vad en rapport faktiskt betyder, upptäcka trender över tid, eller bara ställa frågor på vanlig svenska och få tydliga svar." +friend_invite_btn: "Upptäck inou" +friend_invite_dear: "Hej %s," +rel_0: "du" +rel_1: "Förälder" +rel_2: "Barn" +rel_3: "Make/Maka" +rel_4: "Syskon" +rel_5: "Vårdnadshavare" +rel_6: "Vårdgivare" +rel_7: "Coach" +rel_8: "Läkare" +rel_9: "Vän" +rel_10: "Annat" +rel_99: "Demo" +select_relation: "Välj relation..." + +# Kategorier +category000: Bilddiagnostik +category001: Dokument +category002: Labbresultat +category003: Genom +category004: Uppladdning +category005: Konsultation +category006: Diagnos +category007: Bildresultat +category008: EEG-resultat +category009: Vitalvärde +category010: Träning +category011: Läkemedel +category012: Tillskott +category013: Näring +category014: Fertilitet +category015: Symptom +category016: Anteckning +category017: Sjukdomshistorik +category018: Familjehistorik +category019: Kirurgi +category020: Sjukhusvistelse +category021: Födelsedata +category022: Medicinsk utrustning +category023: Terapi +category024: Bedömning +category025: Vårdgivare +category026: Fråga + +# Genome +genome_english_only: "All genetisk information är på engelska. Använd Claude för att diskutera det på svenska." +genome_variants: "varianter" +genome_hidden: "dolda" +genome_show_all_categories: "Visa alla %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/zh.yaml b/lang/zh.yaml new file mode 100644 index 0000000..680b577 --- /dev/null +++ b/lang/zh.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "你的健康数据。" +headline_2: "你的AI。" +headline_3: "你的答案。" +intro: "上传影像、化验等。连接AI帮助你理解所看到的内容。" +email: "电子邮件" +get_started: "开始使用" +data_yours: "你的数据归你所有" +never_training: "从不用于训练" +never_training_desc: "你的图像从不用于训练AI模型。" +never_shared: "从不共享" +never_shared_desc: "我们从不与任何人共享你的数据。" +encrypted: "加密存储" +encrypted_desc: "所有数据静态加密。" +delete: "随时删除" +delete_desc: "你的数据,你做主。" + +# Verify +check_email: "检查你的邮箱" +code_sent_to: "我们已发送6位验证码到" +verification_code: "验证码" +verify: "验证" +use_different_email: "使用其他邮箱" +invalid_code: "验证码无效或已过期。请重试。" + +# Onboard +create_dossier: "创建你的档案" +create_profile_intro: "告诉我们关于你的信息以开始使用。" +name: "姓名" +name_placeholder: "你的姓名" +date_of_birth: "出生日期" +sex_at_birth: "出生时性别" +female: "女" +male: "男" +create_my_dossier: "创建我的档案" + +# Minor error +must_be_18: "你必须年满18岁才能创建账户" +minor_explanation: "如果你是为他人设置,请先从你自己的个人资料开始。这确保只有你才能访问他们的健康数据。" +minor_next_steps: "创建档案后,你可以添加其他人。" +use_different_dob: "使用其他出生日期" + +# Minor login block +minor_login_blocked: "你必须年满18岁才能登录" +minor_ask_guardian: "请联系%s访问你的档案。" +minor_ask_guardian_generic: "请联系父母或监护人访问你的档案。" + +# Dashboard +dossiers: "档案" +dossiers_intro: "管理你或他人的健康数据" +you: "你" +view: "查看" +save: "保存" +cancel: "取消" +add_dossier: "添加档案" +edit_dossier: "编辑档案" +care: "护理" +logout: "退出登录" + +# Profile detail +back_to_dossiers: "返回档案" +born: "出生" +no_access_yet: "只有你有访问权限。" +people_with_access: "有访问权限的人" +share_access: "共享访问权限" +can_edit: "可以添加数据" +remove: "移除" +confirm_revoke: "移除访问权限?" + +# Dossier sections +section_imaging: "影像" +section_labs: "化验" +section_uploads: "上传" +section_vitals: "生命体征" +section_medications: "药物" +section_records: "记录" +section_journal: "日记" +section_genetics: "遗传" +section_privacy: "隐私" + +# Section summaries +imaging_summary: "%d项检查 · %d张切片" +no_imaging: "无影像数据" +no_lab_data: "无化验数据" +no_genetics: "无遗传数据" +no_files: "无文件" +no_upload_access: "You don't have permission to upload" +files_summary: "%d个文件(%s)" +series_count: "%d个序列" +vitals_desc: "血压、心率、血氧、体重、血糖" +medications_desc: "处方药和补充剂" +records_desc: "临床记录和病历" +journal_desc: "症状、疼痛和观察" + +# Buttons and actions +open_viewer: "打开查看器" +manage: "管理" +show_all_studies: "显示全部%d项检查..." +coming_soon: "即将推出" + +# Upload page +upload_files: "上传健康数据" +upload_files_intro: "上传医学影像、化验结果、基因组文件或任何健康相关文档。" +upload_hint_broad: "DICOM、PDF、CSV、VCF等" +uploading: "上传中..." +files_uploaded: "文件已上传" +upload_scans: "上传扫描" +upload_scans_intro: "上传包含影像检查DICOM文件的文件夹。" +upload_drop: "点击或拖拽文件夹到此处" +upload_hint: "仅限DICOM文件夹" + +# Add profile +add_dossier_intro: "添加你想要管理其健康数据的人。" +email_optional: "电子邮件(可选)" +email_optional_hint: "如果年满18岁,他们可以自己登录" +your_relation: "你与此人的关系" +select_relation: "选择..." +i_provide_care: "我为此人提供护理" +i_am_their: "我是他们的..." + +# Share access +share_access_intro: "邀请他人访问" +their_relation: "他们与此人的关系" +can_add_data: "可以添加数据(补充剂、备注等)" +send_invitation: "发送邀请" +back_to_dossier: "返回档案" + +# Relations +my_role: "我的角色" +role: "role" + +# Invitation email +invite_email_subject: "%s将你添加到inou" +invite_email_body: "%s将你的健康档案添加到inou,这样你可以查看和管理你的医疗数据。" +invite_email_cta: "登录查看" +continue: "继续" + +# Access management +people_with_access_count: "有访问权限的人" +view_audit_log: "查看审计日志" +export_data: "Download my data" +relation_with: "与...的关系" +audit_log: "审计日志" +audit_log_intro: "活动历史" +audit_log_desc: "跟踪谁访问或修改了此档案" + +# Install / Connect +install_title: "连接到Claude" +install_intro: "设置inou桥接器以让Claude分析你的健康数据" +install_step1: "步骤1:下载" +install_step1_desc: "获取适合你平台的桥接器" +install_download_intro: "为你的操作系统下载inou桥接器:" +install_step2: "步骤2:配置" +install_step2_desc: "添加到Claude桌面配置" +install_config_intro: "将此添加到你的Claude桌面配置文件:" +install_step3: "步骤3:测试" +install_step3_desc: "验证连接" +install_test_intro: "重启Claude桌面并询问:'显示我的inou档案'" +nav_install: "连接到Claude" +nav_home: "首页" + +# Status +pending: "待处理" +rate_limit_exceeded: "你所在位置的注册尝试次数过多。请明天再试。" + +# Sex display +sex_male: "男" +sex_female: "女" +sex_na: "其他" + +# Friend invite email +friend_invite_subject: "看看这个 — %s" +friend_invite_p1: "我一直在用inou,一种安全存储健康数据并用AI分析的方式。它把我家人的所有健康信息都放在一个地方——影像检查、化验结果、病历——我想这对你也可能有用。" +friend_invite_p2: "真正的力量在于能用AI理解一切:理解报告实际意味着什么,发现随时间变化的趋势,或者只是用普通话提问并获得清晰的答案。" +friend_invite_btn: "了解inou" +friend_invite_dear: "%s," +rel_0: "你" +rel_1: "父母" +rel_2: "子女" +rel_3: "配偶" +rel_4: "兄弟姐妹" +rel_5: "监护人" +rel_6: "护理者" +rel_7: "教练" +rel_8: "医生" +rel_9: "朋友" +rel_10: "其他" +rel_99: "Demo" +select_relation: "选择关系..." + +# 类别 +category000: 影像 +category001: 文档 +category002: 检验结果 +category003: 基因组 +category004: 上传 +category005: 咨询 +category006: 诊断 +category007: 影像检查结果 +category008: 脑电图结果 +category009: 生命体征 +category010: 运动 +category011: 药物 +category012: 补充剂 +category013: 营养 +category014: 生育 +category015: 症状 +category016: 笔记 +category017: 病史 +category018: 家族史 +category019: 手术 +category020: 住院 +category021: 出生数据 +category022: 医疗设备 +category023: 治疗 +category024: 评估 +category025: 医疗服务提供者 +category026: 问题 + +# Genome +genome_english_only: "所有基因信息均为英文。使用Claude可以用中文讨论。" +genome_variants: "变异" +genome_hidden: "隐藏" +genome_show_all_categories: "显示全部%d个类别" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lib/._db_schema.go b/lib/._db_schema.go new file mode 100644 index 0000000..416537b Binary files /dev/null and b/lib/._db_schema.go differ diff --git a/portal/static/download/inou.mcpb b/portal/static/download/inou.mcpb new file mode 100644 index 0000000..da559ce Binary files /dev/null and b/portal/static/download/inou.mcpb differ diff --git a/portal/static/download/things-mcpb.mcpb b/portal/static/download/things-mcpb.mcpb new file mode 100644 index 0000000..fd3e295 Binary files /dev/null and b/portal/static/download/things-mcpb.mcpb differ diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..5020bd2 --- /dev/null +++ b/restart.sh @@ -0,0 +1,12 @@ +#!/bin/bash +cd /tank/inou + +echo "=== Inou Restart ===" + +# Stop +pkill -x inou-viewer 2>/dev/null && echo "Viewer: stopped" || echo "Viewer: not running" +pkill -x inou-portal 2>/dev/null && echo "Portal: stopped" || echo "Portal: not running" +sleep 1 + +# Start +./start.sh diff --git a/smtp.env b/smtp.env new file mode 100644 index 0000000..5934b58 --- /dev/null +++ b/smtp.env @@ -0,0 +1,5 @@ +SMTP_HOST=smtp.protonmail.ch +SMTP_PORT=587 +SMTP_USER=noreply@inou.com +SMTP_TOKEN=YKEPACTZJE6VJJBE +SMTP_FROM_NAME=inou health diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..8bf3f96 --- /dev/null +++ b/start.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Start Inou services +cd /tank/inou + +echo "=== Inou Start ===" + +# API (start first, portal proxies to it) +if pgrep -f "bin/api$" > /dev/null; then + echo "API: already running (PID $(pgrep -f 'bin/api$'))" +else + ./bin/api >> /tank/inou/logs/api.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/api$" > /dev/null; then + echo "API: started (PID $!)" + else + echo "API: FAILED - check logs/api.log" + fi +fi + +# Viewer +if pgrep -f "bin/viewer$" > /dev/null; then + echo "Viewer: already running (PID $(pgrep -f 'bin/viewer$'))" +else + ./bin/viewer >> /tank/inou/logs/viewer.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/viewer$" > /dev/null; then + echo "Viewer: started (PID $!)" + else + echo "Viewer: FAILED - check logs/viewer.log" + fi +fi + +# Portal +if pgrep -f "bin/portal$" > /dev/null; then + echo "Portal: already running (PID $(pgrep -f 'bin/portal$'))" +else + ./bin/portal >> /tank/inou/logs/portal.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/portal$" > /dev/null; then + echo "Portal: started (PID $!)" + else + echo "Portal: FAILED - check logs/portal.log" + fi +fi + +echo "" +echo "Portal: https://inou.com" +echo "Viewer: https://inou.com:8767" +echo "API: https://inou.com/api/* (internal :8082)" diff --git a/static/4bit.png b/static/4bit.png new file mode 100644 index 0000000..5bba571 Binary files /dev/null and b/static/4bit.png differ diff --git a/static/4bit.webp b/static/4bit.webp new file mode 100644 index 0000000..ae2f8bd Binary files /dev/null and b/static/4bit.webp differ diff --git a/static/8bit.png b/static/8bit.png new file mode 100644 index 0000000..100d98f Binary files /dev/null and b/static/8bit.png differ diff --git a/static/api-docs.html b/static/api-docs.html new file mode 100644 index 0000000..f37c163 --- /dev/null +++ b/static/api-docs.html @@ -0,0 +1,71 @@ + + +inou API Documentation + +

inou Health Dossier API

+ +

Authentication

+

All endpoints require a token query parameter - your authentication token (dossier GUID).

+ +

Base URL

+

https://inou.com

+ +

Endpoints

+ +

GET /api/dossiers

+

List all patient dossiers accessible to this account.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/dossiers?token=YOUR_TOKEN

+ +

GET /api/studies

+

List all imaging studies in a dossier.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID to query
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/studies?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID

+ +

GET /api/series

+

List all series in a study.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID
  • +
  • study_guid (required) - The study GUID
  • +
  • filter (optional) - Filter by description (e.g., "T1", "FLAIR", "SAG")
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/series?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&study_guid=STUDY_ID

+ +

GET /api/slices

+

List all slices in a series with position data.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID
  • +
  • series_guid (required) - The series GUID
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/slices?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&series_guid=SERIES_ID

+ +

GET /image/{slice_guid}

+

Fetch a slice as PNG image.

+

Parameters:

+
    +
  • slice_guid (in path, required) - The slice GUID
  • +
  • token (required) - Your authentication token
  • +
  • ww (optional) - Window width for contrast (Brain=80, Bone=2000)
  • +
  • wc (optional) - Window center for brightness (Brain=40, Bone=500)
  • +
+

Example: GET https://inou.com/image/SLICE_GUID?token=YOUR_TOKEN

+ +

Default response is JSON. Add &format=text for plain text output.

+ + + diff --git a/static/api-docs.txt b/static/api-docs.txt new file mode 100644 index 0000000..71128b9 --- /dev/null +++ b/static/api-docs.txt @@ -0,0 +1,78 @@ +inou Health API +=============== + +Base URL: https://inou.com + +Authentication: Bearer token in Authorization header, or token query parameter. +Your token is your dossier ID (16-character hex). + +Example: Authorization: Bearer abc123def456789a + + +DATA TYPES +---------- +- Imaging: MRI, CT, X-ray, ultrasound (DICOM format) +- Labs: Blood tests, metabolic panels, etc. +- Genome: SNP variants with clinical annotations + + +IMAGING ENDPOINTS +----------------- + +GET /api/v1/dossiers + List all dossiers accessible to your account. + +GET /api/v1/dossiers/{dossier}/entries?category=imaging + List imaging studies for a dossier. + +GET /api/v1/dossiers/{dossier}/entries?parent={study} + List series in a study. + Optional: filter (e.g. T1, FLAIR, AX, SAG) + +GET /api/v1/dossiers/{dossier}/entries?parent={series} + List slices in a series with position data. + +GET /image/{slice}?token={dossier} + Fetch slice as PNG image. + Optional: ww (window width), wc (window center) + +GET /contact-sheet.webp/{series}?token={dossier} + Fetch thumbnail grid for navigation. + Optional: ww, wc + + +LAB ENDPOINTS +------------- + +GET /api/labs/tests?dossier={dossier} + List all available lab test names. + +GET /api/labs/results?dossier={dossier}&names={names} + Get lab results for specified tests. + Required: names (comma-separated test names) + Optional: from, to (YYYY-MM-DD), latest (true/false) + + +GENOME ENDPOINTS +---------------- + +GET /api/categories?dossier={dossier} + Get top-level observation categories. + Optional: type=genome (for genome categories) + Optional: category={category} (for subcategories) + +GET /api/genome?dossier={dossier} + Query genome variants. + Optional: gene (e.g. MTHFR, COMT) + Optional: search (gene, subcategory, or summary text) + Optional: category (filter by category) + Optional: rsids (comma-separated rs numbers) + Optional: min_magnitude (0-4) + Optional: include_hidden (true/false) + + +NAVIGATION FLOW +--------------- +Imaging: dossiers → studies → series → slices → image +Labs: dossiers → tests → results +Genome: dossiers → categories → variants diff --git a/static/app-ads.txt b/static/app-ads.txt new file mode 100644 index 0000000..e69de29 diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..37e7fba Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/download/inou.mcpb b/static/download/inou.mcpb new file mode 100644 index 0000000..8a1397e Binary files /dev/null and b/static/download/inou.mcpb differ diff --git a/static/download/mac/amd64/inou_bridge b/static/download/mac/amd64/inou_bridge new file mode 100644 index 0000000..ba5cf71 Binary files /dev/null and b/static/download/mac/amd64/inou_bridge differ diff --git a/static/download/mac/arm64/inou_bridge b/static/download/mac/arm64/inou_bridge new file mode 100644 index 0000000..c185e2c Binary files /dev/null and b/static/download/mac/arm64/inou_bridge differ diff --git a/static/download/win/amd64/inou_bridge.exe b/static/download/win/amd64/inou_bridge.exe new file mode 100644 index 0000000..7eb7147 Binary files /dev/null and b/static/download/win/amd64/inou_bridge.exe differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..0f13ed8 --- /dev/null +++ b/static/favicon.ico @@ -0,0 +1 @@ + diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..0f13ed8 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/static/input.css b/static/input.css new file mode 100644 index 0000000..029729d --- /dev/null +++ b/static/input.css @@ -0,0 +1,738 @@ +/* ======================================== + INOU INPUT SCREEN + Modern health data input with voice, camera, text + ======================================== */ + +/* Container */ +.input-container { + max-width: 480px; + margin: 0 auto; + padding: 24px 20px 32px; + min-height: 100vh; + min-height: 100dvh; /* Dynamic viewport for mobile */ + display: flex; + flex-direction: column; +} + +/* Header */ +.input-header { + text-align: center; + margin-bottom: 28px; +} + +.input-header h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text); + margin: 0 0 6px; + letter-spacing: -0.02em; +} + +.input-subtitle { + font-size: 1rem; + color: var(--text-muted); + font-weight: 300; + margin: 0; +} + +/* ======================================== + SEGMENTED CONTROL + ======================================== */ +.segment-control { + display: flex; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px; + margin-bottom: 24px; + gap: 4px; +} + +.segment-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; + background: transparent; + border: none; + border-radius: 8px; + font-family: inherit; + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.segment-btn:hover { + color: var(--text); +} + +.segment-btn.active { + background: var(--bg-card); + color: var(--accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.segment-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* ======================================== + MODE PANELS + ======================================== */ +.mode-panels { + flex: 1; + position: relative; +} + +.mode-panel { + display: none; + animation: fadeIn 0.25s ease; +} + +.mode-panel.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ======================================== + TEXT INPUT PANEL + ======================================== */ +.text-input-wrap { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.text-input-wrap:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.text-input { + width: 100%; + min-height: 140px; + padding: 16px; + font-family: inherit; + font-size: 1rem; + line-height: 1.6; + color: var(--text); + background: transparent; + border: none; + resize: none; + outline: none; +} + +.text-input::placeholder { + color: var(--text-subtle); +} + +.text-input-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.char-count { + font-size: 0.8rem; + color: var(--text-subtle); +} + +.input-hints { + display: flex; + gap: 6px; +} + +.hint-tag { + font-size: 0.75rem; + padding: 4px 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-muted); +} + +/* ======================================== + VOICE INPUT PANEL + ======================================== */ +.voice-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 16px; +} + +/* Mic Button */ +.mic-btn { + position: relative; + width: 96px; + height: 96px; + border-radius: 50%; + background: var(--bg-card); + border: 2px solid var(--border); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.mic-btn:hover { + border-color: var(--accent); + transform: scale(1.02); +} + +.mic-btn.listening { + border-color: var(--accent); + background: var(--accent-light); +} + +.mic-icon { + width: 36px; + height: 36px; + color: var(--text-muted); + transition: color 0.2s; +} + +.mic-btn.listening .mic-icon { + color: var(--accent); +} + +/* Pulse Animation */ +.mic-pulse { + position: absolute; + inset: -8px; + border-radius: 50%; + border: 2px solid var(--accent); + opacity: 0; + transform: scale(1); + pointer-events: none; +} + +.mic-btn.listening .mic-pulse { + animation: pulse 1.5s ease-out infinite; +} + +@keyframes pulse { + 0% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1.4); + } +} + +/* Status Text */ +.mic-status { + margin-top: 16px; + font-size: 0.95rem; + color: var(--text-muted); + font-weight: 400; + text-align: center; +} + +.mic-btn.listening + .mic-status { + color: var(--accent); + font-weight: 500; +} + +/* Transcript Area */ +.transcript-area { + width: 100%; + margin-top: 24px; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + min-height: 100px; + display: none; +} + +.transcript-area.has-content { + display: block; +} + +.transcript-text { + font-size: 1rem; + line-height: 1.6; + color: var(--text); + margin: 0; +} + +.transcript-interim { + font-size: 1rem; + line-height: 1.6; + color: var(--text-subtle); + font-style: italic; + margin: 4px 0 0; +} + +/* Voice Unsupported */ +.voice-unsupported { + text-align: center; + padding: 32px; + color: var(--text-muted); +} + +.voice-unsupported svg { + color: var(--text-subtle); + margin-bottom: 12px; +} + +.voice-unsupported p { + margin: 0; + font-size: 1rem; +} + +.voice-fallback-hint { + margin-top: 8px !important; + font-size: 0.9rem !important; + color: var(--text-subtle) !important; +} + +/* ======================================== + CAMERA/SCAN PANEL + ======================================== */ +.camera-container { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + min-height: 360px; +} + +/* Camera Start State */ +.camera-start { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + min-height: 360px; +} + +.camera-start-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px 32px; + background: var(--accent-light); + border: 2px dashed var(--accent); + border-radius: 16px; + color: var(--accent); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.camera-start-btn:hover { + background: var(--accent); + border-style: solid; + color: white; +} + +.camera-start-btn:hover svg { + stroke: white; +} + +.camera-hint { + margin-top: 16px; + font-size: 0.9rem; + color: var(--text-muted); + text-align: center; + max-width: 280px; +} + +/* Camera Viewfinder */ +.camera-viewfinder { + position: relative; + background: #000; +} + +#camera-video { + width: 100%; + height: auto; + display: block; + max-height: 400px; + object-fit: cover; +} + +.viewfinder-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.viewfinder-frame { + width: 85%; + height: 70%; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 8px; +} + +/* Camera Controls */ +.camera-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-around; + padding: 20px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); +} + +.camera-ctrl-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.camera-ctrl-btn svg { + width: 22px; + height: 22px; + color: white; +} + +.camera-ctrl-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Capture Button */ +.capture-btn { + width: 72px; + height: 72px; + border-radius: 50%; + background: white; + border: 4px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 4px; + transition: transform 0.1s; +} + +.capture-btn:hover { + transform: scale(1.05); +} + +.capture-btn:active { + transform: scale(0.95); +} + +.capture-btn-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: white; + border: 2px solid #ddd; +} + +/* Photo Preview */ +.photo-preview { + padding: 16px; +} + +#preview-img { + width: 100%; + height: auto; + border-radius: 8px; + max-height: 300px; + object-fit: contain; + background: var(--bg); +} + +.preview-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.preview-actions .btn { + flex: 1; +} + +/* OCR Result */ +.ocr-result { + padding: 16px; +} + +.ocr-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ocr-header h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +/* Result Type Badges */ +.result-type-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; +} + +.result-type-badge.barcode { + background: var(--success-light); + color: var(--success); +} + +.result-type-badge.text { + background: var(--accent-light); + color: var(--accent); +} + +.result-type-badge.empty { + background: var(--bg); + color: var(--text-muted); +} + +.result-type-badge.error { + background: var(--danger-light); + color: var(--danger); +} + +.ocr-text { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + font-size: 0.95rem; + line-height: 1.6; + color: var(--text); + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +} + +.ocr-text:focus { + outline: none; + border-color: var(--accent); +} + +.ocr-text[contenteditable="true"] { + border-color: var(--accent); + background: var(--bg-card); +} + +/* Barcode value styling - monospace, larger */ +.ocr-text.barcode-value { + font-family: "SF Mono", Monaco, "Courier New", monospace; + font-size: 1.25rem; + font-weight: 600; + text-align: center; + letter-spacing: 0.05em; + padding: 24px 16px; + color: var(--text); +} + +.ocr-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.ocr-actions .btn { + flex: 1; +} + +/* OCR Processing */ +.ocr-processing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + min-height: 300px; +} + +.processing-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.ocr-processing p { + margin-top: 16px; + font-size: 1rem; + color: var(--text-muted); +} + +/* Camera Error */ +.camera-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + min-height: 300px; +} + +.camera-error svg { + color: var(--danger); + margin-bottom: 16px; +} + +.camera-error p { + font-size: 1rem; + color: var(--text-muted); + margin: 0 0 20px; +} + +/* ======================================== + SUBMIT SECTION + ======================================== */ +.submit-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.submit-btn { + padding: 14px 24px; + font-size: 1.05rem; + gap: 10px; +} + +.submit-btn:disabled { + background: var(--border); + color: var(--text-subtle); + cursor: not-allowed; +} + +.submit-btn:disabled:hover { + background: var(--border); +} + +.submit-icon { + width: 20px; + height: 20px; +} + +/* ======================================== + MOBILE RESPONSIVE + ======================================== */ +@media (max-width: 480px) { + .input-container { + padding: 16px 16px 24px; + } + + .input-header h1 { + font-size: 1.5rem; + } + + .segment-btn { + padding: 10px 12px; + font-size: 0.9rem; + } + + .segment-btn span { + display: none; + } + + .segment-icon { + width: 24px; + height: 24px; + } + + .mic-btn { + width: 80px; + height: 80px; + } + + .mic-icon { + width: 32px; + height: 32px; + } + + .text-input { + min-height: 120px; + font-size: 16px; /* Prevents zoom on iOS */ + } + + .input-hints { + display: none; + } + + .camera-start { + min-height: 280px; + padding: 32px 16px; + } +} + +/* Larger phones / tablets */ +@media (min-width: 481px) and (max-width: 768px) { + .input-container { + max-width: 520px; + } +} + +/* ======================================== + SAFE AREA (notch/home indicator) + ======================================== */ +@supports (padding-bottom: env(safe-area-inset-bottom)) { + .input-container { + padding-bottom: calc(24px + env(safe-area-inset-bottom)); + } +} + +/* ======================================== + DARK MODE SUPPORT (future) + ======================================== */ +@media (prefers-color-scheme: dark) { + /* Ready for dark mode variables when inou supports it */ +} diff --git a/static/input.js b/static/input.js new file mode 100644 index 0000000..c6856c9 --- /dev/null +++ b/static/input.js @@ -0,0 +1,799 @@ +/** + * inou Health Input Screen + * Voice, Camera/OCR, and Text input for health data + */ + +(function() { + 'use strict'; + + // ======================================== + // STATE + // ======================================== + const state = { + mode: 'type', + inputValue: '', + isListening: false, + recognition: null, + transcript: '', + interimTranscript: '', + stream: null, + facingMode: 'environment', + capturedImage: null, + ocrText: '', + scanResult: null // { type: 'barcode'|'text'|'empty'|'error', format?, value } + }; + + // ======================================== + // DOM ELEMENTS + // ======================================== + const elements = {}; + + function initElements() { + // Segment control + elements.segmentBtns = document.querySelectorAll('.segment-btn'); + elements.modePanels = document.querySelectorAll('.mode-panel'); + + // Text input + elements.textInput = document.getElementById('text-input'); + elements.charCount = document.getElementById('char-count'); + + // Voice input + elements.micBtn = document.getElementById('mic-btn'); + elements.micStatus = document.getElementById('mic-status'); + elements.transcriptArea = document.getElementById('transcript-area'); + elements.transcriptText = document.getElementById('transcript-text'); + elements.transcriptInterim = document.getElementById('transcript-interim'); + elements.voiceUnsupported = document.getElementById('voice-unsupported'); + + // Camera/Scan + elements.cameraContainer = document.getElementById('camera-container'); + elements.cameraStart = document.getElementById('camera-start'); + elements.startCameraBtn = document.getElementById('start-camera-btn'); + elements.cameraViewfinder = document.getElementById('camera-viewfinder'); + elements.cameraVideo = document.getElementById('camera-video'); + elements.switchCameraBtn = document.getElementById('switch-camera-btn'); + elements.captureBtn = document.getElementById('capture-btn'); + elements.closeCameraBtn = document.getElementById('close-camera-btn'); + elements.photoPreview = document.getElementById('photo-preview'); + elements.previewImg = document.getElementById('preview-img'); + elements.retakeBtn = document.getElementById('retake-btn'); + elements.processBtn = document.getElementById('process-btn'); + elements.ocrResult = document.getElementById('ocr-result'); + elements.ocrHeader = document.querySelector('.ocr-header'); + elements.ocrText = document.getElementById('ocr-text'); + elements.ocrEditBtn = document.getElementById('ocr-edit-btn'); + elements.scanAnotherBtn = document.getElementById('scan-another-btn'); + elements.useOcrBtn = document.getElementById('use-ocr-btn'); + elements.ocrProcessing = document.getElementById('ocr-processing'); + elements.cameraError = document.getElementById('camera-error'); + elements.cameraErrorMsg = document.getElementById('camera-error-msg'); + elements.retryCameraBtn = document.getElementById('retry-camera-btn'); + elements.captureCanvas = document.getElementById('capture-canvas'); + + // Submit + elements.submitBtn = document.getElementById('submit-btn'); + } + + // ======================================== + // MODE SWITCHING + // ======================================== + function switchMode(mode) { + state.mode = mode; + + // Update segment buttons + elements.segmentBtns.forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + + // Update panels + elements.modePanels.forEach(panel => { + panel.classList.toggle('active', panel.dataset.panel === mode); + }); + + // Stop voice if switching away + if (mode !== 'speak' && state.isListening) { + stopListening(); + } + + // Stop camera if switching away + if (mode !== 'scan' && state.stream) { + stopCamera(); + } + + updateSubmitButton(); + } + + // ======================================== + // TEXT INPUT + // ======================================== + function initTextInput() { + elements.textInput.addEventListener('input', handleTextInput); + elements.textInput.addEventListener('focus', handleTextFocus); + } + + function handleTextInput(e) { + const value = e.target.value; + state.inputValue = value; + elements.charCount.textContent = value.length; + autoResize(e.target); + updateSubmitButton(); + } + + function handleTextFocus() { + // Ensure mode is set to type when focusing text input + if (state.mode !== 'type') { + switchMode('type'); + } + } + + function autoResize(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.max(140, textarea.scrollHeight) + 'px'; + } + + // ======================================== + // VOICE INPUT (Web Speech API) + // ======================================== + function initVoiceInput() { + // Check for support + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + + if (!SpeechRecognition) { + elements.micBtn.style.display = 'none'; + elements.micStatus.style.display = 'none'; + elements.voiceUnsupported.style.display = 'block'; + return; + } + + // Initialize recognition + state.recognition = new SpeechRecognition(); + state.recognition.continuous = true; + state.recognition.interimResults = true; + state.recognition.lang = navigator.language || 'en-US'; + + // Event handlers + state.recognition.onstart = () => { + state.isListening = true; + elements.micBtn.classList.add('listening'); + elements.micStatus.textContent = 'Listening...'; + }; + + state.recognition.onend = () => { + state.isListening = false; + elements.micBtn.classList.remove('listening'); + elements.micStatus.textContent = 'Tap to start speaking'; + + // Finalize any interim transcript + if (state.interimTranscript) { + state.transcript += state.interimTranscript; + state.interimTranscript = ''; + updateTranscriptDisplay(); + } + updateSubmitButton(); + }; + + state.recognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + state.isListening = false; + elements.micBtn.classList.remove('listening'); + + let errorMsg = 'Tap to try again'; + if (event.error === 'not-allowed') { + errorMsg = 'Microphone access denied. Check permissions.'; + } else if (event.error === 'no-speech') { + errorMsg = 'No speech detected. Tap to try again.'; + } + elements.micStatus.textContent = errorMsg; + }; + + state.recognition.onresult = (event) => { + let interim = ''; + let final = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + final += transcript + ' '; + } else { + interim += transcript; + } + } + + if (final) { + state.transcript += final; + } + state.interimTranscript = interim; + updateTranscriptDisplay(); + updateSubmitButton(); + }; + + // Click handler + elements.micBtn.addEventListener('click', toggleListening); + } + + function toggleListening() { + if (state.isListening) { + stopListening(); + } else { + startListening(); + } + } + + function startListening() { + if (!state.recognition) return; + + try { + state.recognition.start(); + } catch (e) { + // Already started + console.log('Recognition already started'); + } + } + + function stopListening() { + if (!state.recognition) return; + + try { + state.recognition.stop(); + } catch (e) { + console.log('Recognition already stopped'); + } + } + + function updateTranscriptDisplay() { + const hasContent = state.transcript || state.interimTranscript; + elements.transcriptArea.classList.toggle('has-content', hasContent); + elements.transcriptText.textContent = state.transcript; + elements.transcriptInterim.textContent = state.interimTranscript; + } + + // ======================================== + // CAMERA / OCR + // ======================================== + function initCamera() { + elements.startCameraBtn.addEventListener('click', startCamera); + elements.switchCameraBtn.addEventListener('click', switchCamera); + elements.captureBtn.addEventListener('click', capturePhoto); + elements.closeCameraBtn.addEventListener('click', closeCamera); + elements.retakeBtn.addEventListener('click', retakePhoto); + elements.processBtn.addEventListener('click', processOCR); + elements.ocrEditBtn.addEventListener('click', toggleOCREdit); + elements.scanAnotherBtn.addEventListener('click', scanAnother); + elements.useOcrBtn.addEventListener('click', useOCRText); + elements.retryCameraBtn.addEventListener('click', startCamera); + } + + async function startCamera() { + try { + // Hide all states, show viewfinder + hideAllCameraStates(); + elements.cameraViewfinder.style.display = 'block'; + + // Get camera stream + const constraints = { + video: { + facingMode: state.facingMode, + width: { ideal: 1920 }, + height: { ideal: 1080 } + } + }; + + state.stream = await navigator.mediaDevices.getUserMedia(constraints); + elements.cameraVideo.srcObject = state.stream; + await elements.cameraVideo.play(); + + } catch (err) { + console.error('Camera error:', err); + showCameraError(getCameraErrorMessage(err)); + } + } + + function getCameraErrorMessage(err) { + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + return 'Camera access denied. Please allow camera permissions.'; + } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { + return 'No camera found on this device.'; + } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { + return 'Camera is in use by another application.'; + } + return 'Could not access camera. Please try again.'; + } + + function showCameraError(message) { + hideAllCameraStates(); + elements.cameraErrorMsg.textContent = message; + elements.cameraError.style.display = 'flex'; + } + + function stopCamera() { + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + elements.cameraVideo.srcObject = null; + } + + function closeCamera() { + stopCamera(); + hideAllCameraStates(); + elements.cameraStart.style.display = 'flex'; + } + + async function switchCamera() { + state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment'; + stopCamera(); + await startCamera(); + } + + function capturePhoto() { + const video = elements.cameraVideo; + const canvas = elements.captureCanvas; + + // Set canvas size to video size + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Draw current frame + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0); + + // Get data URL + state.capturedImage = canvas.toDataURL('image/jpeg', 0.9); + + // Stop camera and show preview + stopCamera(); + hideAllCameraStates(); + elements.previewImg.src = state.capturedImage; + elements.photoPreview.style.display = 'block'; + } + + function retakePhoto() { + state.capturedImage = null; + startCamera(); + } + + // ======================================== + // BARCODE DETECTION + // ======================================== + + /** + * Try to detect barcode using native BarcodeDetector API + * Returns { found: boolean, format: string, value: string } or null + */ + async function detectBarcodeNative(imageSource) { + if (!('BarcodeDetector' in window)) { + return null; + } + + try { + const formats = await BarcodeDetector.getSupportedFormats(); + const detector = new BarcodeDetector({ formats }); + const barcodes = await detector.detect(imageSource); + + if (barcodes.length > 0) { + const barcode = barcodes[0]; + return { + found: true, + format: barcode.format, + value: barcode.rawValue + }; + } + } catch (err) { + console.log('Native barcode detection failed:', err); + } + + return { found: false }; + } + + /** + * Try to detect barcode via backend API + */ + async function detectBarcodeBackend(blob) { + try { + const formData = new FormData(); + formData.append('image', blob, 'capture.jpg'); + + const response = await fetch('/api/barcode', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + return { found: false }; + } + + const result = await response.json(); + if (result.found && result.value) { + return { + found: true, + format: result.format || 'unknown', + value: result.value + }; + } + } catch (err) { + console.log('Backend barcode detection failed:', err); + } + + return { found: false }; + } + + /** + * Main processing function: Barcode → OCR → Nothing + */ + async function processImage() { + if (!state.capturedImage) return; + + hideAllCameraStates(); + elements.ocrProcessing.style.display = 'flex'; + updateProcessingStatus('Scanning for barcode...'); + + try { + const blob = dataURLtoBlob(state.capturedImage); + + // Create an image element for native barcode detection + const img = new Image(); + img.src = state.capturedImage; + await new Promise(resolve => img.onload = resolve); + + // Step 1: Try native BarcodeDetector API (Chrome/Edge) + let barcodeResult = await detectBarcodeNative(img); + + // Step 2: If native fails, try backend barcode detection + if (!barcodeResult || !barcodeResult.found) { + barcodeResult = await detectBarcodeBackend(blob); + } + + // Step 3: If barcode found, show it + if (barcodeResult && barcodeResult.found) { + state.scanResult = { + type: 'barcode', + format: barcodeResult.format, + value: barcodeResult.value + }; + showScanResult(); + return; + } + + // Step 4: No barcode, try OCR + updateProcessingStatus('Extracting text...'); + + const formData = new FormData(); + formData.append('image', blob, 'capture.jpg'); + + const response = await fetch('/api/ocr', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + if (result.text && result.text.trim()) { + state.scanResult = { + type: 'text', + value: result.text + }; + showScanResult(); + return; + } + } + + // Step 5: Nothing found + state.scanResult = { + type: 'empty', + value: '' + }; + showScanResult(); + + } catch (err) { + console.error('Image processing error:', err); + state.scanResult = { + type: 'error', + value: 'Could not process image. Please try again.' + }; + showScanResult(); + } + } + + function updateProcessingStatus(message) { + const statusEl = elements.ocrProcessing.querySelector('p'); + if (statusEl) { + statusEl.textContent = message; + } + } + + function showScanResult() { + hideAllCameraStates(); + + const result = state.scanResult; + + if (result.type === 'barcode') { + // Show barcode result + elements.ocrHeader.innerHTML = ` +
+ + + + + + + + + Barcode (${formatBarcodeType(result.format)}) +
+ + `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.add('barcode-value'); + + } else if (result.type === 'text') { + // Show OCR result + elements.ocrHeader.innerHTML = ` +
+ + + + + + + Extracted Text +
+ + `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.remove('barcode-value'); + + } else if (result.type === 'empty') { + elements.ocrHeader.innerHTML = ` +
No content detected
+ `; + elements.ocrText.textContent = 'Try adjusting lighting or holding the camera closer to the document.'; + elements.ocrText.classList.remove('barcode-value'); + + } else { + // Error + elements.ocrHeader.innerHTML = ` +
Error
+ `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.remove('barcode-value'); + } + + elements.ocrText.contentEditable = 'false'; + elements.ocrResult.style.display = 'block'; + + // Re-bind edit button + const editBtn = document.getElementById('ocr-edit-btn'); + if (editBtn) { + editBtn.addEventListener('click', toggleOCREdit); + } + + updateSubmitButton(); + } + + function formatBarcodeType(format) { + const types = { + 'upc_a': 'UPC-A', + 'upc_e': 'UPC-E', + 'ean_13': 'EAN-13', + 'ean_8': 'EAN-8', + 'code_128': 'Code 128', + 'code_39': 'Code 39', + 'code_93': 'Code 93', + 'codabar': 'Codabar', + 'itf': 'ITF', + 'qr_code': 'QR Code', + 'data_matrix': 'Data Matrix', + 'aztec': 'Aztec', + 'pdf417': 'PDF417' + }; + return types[format] || format || 'Unknown'; + } + + // Legacy function name for backwards compatibility + async function processOCR() { + return processImage(); + } + + function toggleOCREdit() { + const isEditing = elements.ocrText.contentEditable === 'true'; + elements.ocrText.contentEditable = isEditing ? 'false' : 'true'; + if (!isEditing) { + elements.ocrText.focus(); + } + } + + function scanAnother() { + state.capturedImage = null; + state.ocrText = ''; + startCamera(); + } + + function useOCRText() { + // Get possibly edited text from the contenteditable div + const editedValue = elements.ocrText.textContent || ''; + + // Update scanResult with edited value + if (state.scanResult) { + state.scanResult.value = editedValue; + } + state.ocrText = editedValue; + + // Switch to type mode and populate + switchMode('type'); + elements.textInput.value = editedValue; + state.inputValue = editedValue; + elements.charCount.textContent = editedValue.length; + autoResize(elements.textInput); + updateSubmitButton(); + } + + function hideAllCameraStates() { + elements.cameraStart.style.display = 'none'; + elements.cameraViewfinder.style.display = 'none'; + elements.photoPreview.style.display = 'none'; + elements.ocrResult.style.display = 'none'; + elements.ocrProcessing.style.display = 'none'; + elements.cameraError.style.display = 'none'; + } + + function dataURLtoBlob(dataURL) { + const arr = dataURL.split(','); + const mime = arr[0].match(/:(.*?);/)[1]; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new Blob([u8arr], { type: mime }); + } + + // ======================================== + // SUBMIT + // ======================================== + function initSubmit() { + elements.submitBtn.addEventListener('click', handleSubmit); + } + + function updateSubmitButton() { + let hasContent = false; + + switch (state.mode) { + case 'type': + hasContent = state.inputValue.trim().length > 0; + break; + case 'speak': + hasContent = state.transcript.trim().length > 0; + break; + case 'scan': + hasContent = state.scanResult && + state.scanResult.value && + state.scanResult.value.trim().length > 0 && + state.scanResult.type !== 'empty' && + state.scanResult.type !== 'error'; + break; + } + + elements.submitBtn.disabled = !hasContent; + } + + function handleSubmit() { + let content = ''; + let scanType = null; + let scanFormat = null; + + switch (state.mode) { + case 'type': + content = state.inputValue.trim(); + break; + case 'speak': + content = state.transcript.trim(); + break; + case 'scan': + if (state.scanResult) { + content = (elements.ocrText.textContent || state.scanResult.value || '').trim(); + scanType = state.scanResult.type; + scanFormat = state.scanResult.format || null; + } + break; + } + + if (!content) return; + + // Build payload + const payload = { + mode: state.mode, + content: content, + timestamp: new Date().toISOString() + }; + + // Add barcode-specific fields + if (state.mode === 'scan' && scanType === 'barcode') { + payload.scanType = 'barcode'; + payload.barcodeFormat = scanFormat; + payload.barcodeValue = content; + } else if (state.mode === 'scan') { + payload.scanType = 'text'; + } + + // Dispatch custom event for Flutter WebView or parent frame + const event = new CustomEvent('inou-input-submit', { detail: payload }); + window.dispatchEvent(event); + + // Also try postMessage for WebView communication + if (window.flutter_inappwebview) { + window.flutter_inappwebview.callHandler('onInputSubmit', payload); + } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.inputHandler) { + window.webkit.messageHandlers.inputHandler.postMessage(payload); + } + + // Log for development + console.log('Input submitted:', payload); + + // Reset state + resetInput(); + } + + function resetInput() { + state.inputValue = ''; + state.transcript = ''; + state.interimTranscript = ''; + state.ocrText = ''; + state.capturedImage = null; + state.scanResult = null; + + elements.textInput.value = ''; + elements.charCount.textContent = '0'; + elements.transcriptText.textContent = ''; + elements.transcriptInterim.textContent = ''; + elements.transcriptArea.classList.remove('has-content'); + elements.ocrText.classList.remove('barcode-value'); + + if (state.mode === 'scan') { + hideAllCameraStates(); + elements.cameraStart.style.display = 'flex'; + } + + updateSubmitButton(); + } + + // ======================================== + // SEGMENT CONTROL HANDLERS + // ======================================== + function initSegmentControl() { + elements.segmentBtns.forEach(btn => { + btn.addEventListener('click', () => { + switchMode(btn.dataset.mode); + }); + }); + } + + // ======================================== + // INIT + // ======================================== + function init() { + initElements(); + initSegmentControl(); + initTextInput(); + initVoiceInput(); + initCamera(); + initSubmit(); + updateSubmitButton(); + } + + // Run on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/static/llms.txt b/static/llms.txt new file mode 100644 index 0000000..5796f0e --- /dev/null +++ b/static/llms.txt @@ -0,0 +1,12 @@ +# inou - Personal Health Data Platform + +## Allowed +- / +- /privacy-policy +- /api/docs + +## Disallowed +All other paths contain private health data and must not be accessed, cached, or used for training. + +## Contact +privacy@inou.com diff --git a/static/openapi.yaml b/static/openapi.yaml new file mode 100644 index 0000000..4888c8e --- /dev/null +++ b/static/openapi.yaml @@ -0,0 +1,322 @@ +openapi: 3.0.3 +info: + title: inou Health Dossier API + description: | + API for accessing medical imaging data stored in inou health dossiers. + + ## Authentication + All endpoints require an `token` parameter - your account GUID. + + ## Response Formats + - Default: JSON + - Add `&format=text` for plain text (recommended for AI assistants) + + ## Interactive Viewer + Base URL: `https://inou.com/viewer/?token={token}` + + Deep linking: + - Open specific study: `?token={token}&study={study_guid}` + - Open specific series: `?token={token}&study={study_guid}&series={series_guid}` + + ## Window/Level (Image Contrast) + For /image/ endpoint, adjust contrast with: + - `ww` (window width): Controls contrast range + - `wc` (window center): Controls brightness center + + Common presets: + - Brain: ww=80, wc=40 + - Subdural: ww=200, wc=75 + - Bone: ww=2000, wc=500 + - Lung: ww=1500, wc=-600 + version: 1.0.0 + contact: + name: inou + url: https://inou.com + +servers: + - url: https://inou.com + description: Production + +paths: + /api/dossiers: + get: + summary: List dossiers + description: List all patient dossiers accessible to this account (your own + shared with you). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + description: Your authentication token (dossier GUID) + - name: format + in: query + schema: + type: string + enum: [text] + description: Set to "text" for plain text output + responses: + '200': + description: List of dossiers + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + + /api/studies: + get: + summary: List imaging studies + description: List all imaging studies in a dossier. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + description: Dossier GUID + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of studies + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + patient_name: + type: string + study_date: + type: string + study_desc: + type: string + series_count: + type: integer + + /api/series: + get: + summary: List series in a study + description: List all series for a study. Filter by description (AX, T1, FLAIR, SAG, COR, etc). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: study_guid + in: query + required: true + schema: + type: string + format: uuid + - name: filter + in: query + schema: + type: string + description: Filter by series description (e.g., "T1", "FLAIR", "SAG") + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of series + + /api/slices: + get: + summary: List slices in a series + description: List all slices with position data (mm coordinates, orientation, pixel spacing). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: series_guid + in: query + required: true + schema: + type: string + format: uuid + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of slices with position info + + + /image/{slice_guid}: + get: + summary: Get slice image + description: Fetch a slice as PNG image. Adjust window/level for contrast. + parameters: + - name: slice_guid + in: path + required: true + schema: + type: string + format: uuid + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: ww + in: query + schema: + type: number + description: Window width (contrast range). Brain=80, Bone=2000, Lung=1500 + - name: wc + in: query + schema: + type: number + description: Window center (brightness). Brain=40, Bone=500, Lung=-600 + responses: + '200': + description: PNG image + content: + image/png: + schema: + type: string + format: binary + + /viewer/: + get: + summary: Interactive DICOM viewer + description: Open the web-based DICOM viewer with 3D crosshair navigation. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + description: Dossier GUID + - name: study + in: query + schema: + type: string + format: uuid + description: Open specific study + - name: series + in: query + schema: + type: string + format: uuid + description: Open specific series (requires study parameter) + responses: + '200': + description: HTML viewer page + + /api/labs/tests: + get: + summary: List lab test names + description: List all lab test names available for a dossier. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of test names + + /api/labs/results: + get: + summary: Get lab results + description: Get lab results by test name, with optional date filtering. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: names + in: query + required: true + schema: + type: string + description: Comma-separated test names + - name: from + in: query + schema: + type: string + format: date + description: Start date (YYYY-MM-DD) + - name: to + in: query + schema: + type: string + format: date + description: End date (YYYY-MM-DD) + - name: latest + in: query + schema: + type: boolean + description: Return only most recent result per test + responses: + '200': + description: Lab results diff --git a/static/pricing.html b/static/pricing.html new file mode 100644 index 0000000..7010c40 --- /dev/null +++ b/static/pricing.html @@ -0,0 +1,259 @@ + + + + + + inou health - Pricing + + + +
+

inou health

+

AI answers for you

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Monitor
+
Free
+
+
Optimize
+
$12/mo
+
+
Research
+
$35/mo
+
Health Data
Vitals (BP, HR, weight, temp)
Symptoms & conditions
Medications
Exercise & activity
Family history
Lab results
Consumer genome (23andMe)
Medical imaging (MRI, CT, X-ray)
Clinical genome sequencing
AI Features
MCP integration (Claude, ChatGPT)
Personalized AI answersLimited
Health trend analysis
Storage & Access
Multi-dossier support (family)
FIPS 140-3 encryption
Data export
+
+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..1f8237f --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,16 @@ +User-agent: * +Allow: / +Allow: /privacy-policy +Allow: /connect +Disallow: /dossier +Disallow: /dashboard +Disallow: /share +Disallow: /invite +Disallow: /onboard +Disallow: /verify +Disallow: /start +Disallow: /login +Disallow: /set-lang +Disallow: /api/ + +Sitemap: https://inou.com/sitemap.xml diff --git a/static/sitemap.xml b/static/sitemap.xml new file mode 100644 index 0000000..4b482fa --- /dev/null +++ b/static/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://inou.com/ + weekly + 1.0 + + + https://inou.com/privacy-policy + monthly + 0.5 + + + https://inou.com/connect + monthly + 0.7 + + diff --git a/static/slice12_thumb.png b/static/slice12_thumb.png new file mode 100644 index 0000000..2bd771d Binary files /dev/null and b/static/slice12_thumb.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..775787e --- /dev/null +++ b/static/style.css @@ -0,0 +1,1864 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; +} + +body { + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + font-weight: 400; + line-height: 1.5; + font-size: 15px; +} + +/* Navigation */ +.nav { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 12px 24px; + max-width: 1200px; + margin: 0 auto; + border-bottom: 1px solid var(--border); +} + +.logo { + display: flex; + align-items: baseline; + gap: 6px; + font-family: "Sora", sans-serif; + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + text-transform: lowercase; + text-decoration: none; +} +.logo:hover { text-decoration: none; } +.logo .inou { color: var(--accent); font-weight: 700; } +.logo .health { color: var(--text-muted); font-weight: 300; } +.logo .logo-tagline { + font-size: 0.95rem; + font-weight: 300; + color: var(--text-muted); + letter-spacing: 0.04em; + text-transform: none; +} + +.nav-right { + display: flex; + align-items: baseline; + gap: 16px; +} + +/* User menu with hover dropdown */ +.nav-user-menu { + position: relative; +} + +.nav-user-name { + font-size: 0.85rem; + color: var(--text); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.nav-user-name:hover { + background: var(--border); +} + +.nav-user-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 100px; + z-index: 100; +} + +.nav-user-menu:hover .nav-user-dropdown { + display: block; + +} + +.nav-user-dropdown a { + display: block; + + padding: 8px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.nav-user-dropdown a:hover { + background: var(--bg); + color: var(--accent); +} + +/* Language menu */ +.lang-menu { + position: relative; +} + +.lang-current { + min-width: 36px; + display: inline-block; + text-align: center; + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.lang-current:hover { + border-color: var(--border-hover); +} + +.lang-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 120px; + white-space: nowrap; + z-index: 100; + padding-top: 8px; + margin-top: 0; +} + +.lang-menu::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 8px; +} + +.lang-menu:hover .lang-dropdown { + display: block; + +} + +.lang-dropdown a { + display: block; + + padding: 6px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.lang-dropdown a:hover { + background: var(--bg); +} + +.lang-dropdown a.active { + color: var(--accent); + font-weight: 500; +} + +.nav-user { + font-size: 0.8rem; + color: var(--text-muted); +} + +.nav-user a { + color: var(--text); + text-decoration: none; +} + +.nav-user a:hover { + color: var(--accent); +} + +.lang-picker { + font-size: 1rem; + color: var(--text-muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; +} + +/* Container */ +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; +} + +.container-narrow { + max-width: 360px; + margin: 0 auto; + padding: 60px 20px 40px; +} + +/* Dossier header */ +.dossier-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; +} + +.dossier-header-left h1 { + margin-bottom: 0; +} + +.dossier-header-left p { + margin: 4px 0 0 0; +} + +/* Coming soon badge */ +.badge-soon { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; +} + +.data-card.coming-soon { + opacity: 0.6; +} + +/* Typography */ +h1 { + font-size: 2.25rem; + font-weight: 300; + line-height: 1.2; + margin-bottom: 16px; + letter-spacing: -0.03em; + color: var(--text); +} + +h1.small { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 4px; +} + +h2 { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +h3 { + font-size: 1.125rem; + font-weight: 500; + margin-bottom: 4px; + color: var(--text); +} + +.intro { + font-size: 1rem; + color: var(--text-muted); + margin-bottom: 32px; +} + +.intro.small { + font-size: 1rem; + margin-bottom: 24px; +} + +.section-label { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 12px; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-group select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + cursor: pointer; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.form-group input.code-input { + font-size: 1.375rem; + text-align: center; + letter-spacing: 0.4em; + font-weight: 500; + font-family: "SF Mono", "Monaco", monospace; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.radio-group { + display: flex; + gap: 16px; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 1rem; + color: var(--text); + cursor: pointer; + font-weight: 400; +} + +.radio-group input { + width: auto; + accent-color: var(--accent); +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + color: var(--text-muted); + cursor: pointer; +} + +.checkbox-group input { + width: auto; + accent-color: var(--accent); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + text-decoration: none; + border-radius: 6px; + transition: all 0.15s; + border: none; + cursor: pointer; + text-align: center; + gap: 6px; +} + +.btn-full { + width: 100%; +} + +.btn-primary { + background: var(--accent); + color: #FFFFFF; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--border-hover); + background: var(--bg); +} + +.btn-danger { + background: var(--danger-light); + color: var(--danger); + border: 1px solid #FECACA; +} + +.btn-danger:hover { + background: #FEE2E2; +} + +.btn-small { + padding: 6px 12px; + font-size: 1rem; +} + +.btn-disabled { + background: var(--bg-subtle); + color: var(--text-subtle); + cursor: not-allowed; + opacity: 0.6; +} + +.btn-icon { + padding: 4px 8px; + background: transparent; + color: var(--text-subtle); + border: none; + font-size: 1rem; + line-height: 1; + border-radius: 4px; +} + +.btn-icon:hover { + color: var(--danger); + background: var(--danger-light); +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.card-link { + + text-decoration: none; + color: inherit; + padding: 0; + transition: all 0.15s; +} + +.card-link:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + text-decoration: none; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-header h3 { + margin-bottom: 0; +} + +.card-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.card-actions { + display: flex; + gap: 6px; +} + +.card-add { + border: 2px dashed var(--border); + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + cursor: pointer; + text-decoration: none; +} + +.card-add:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.card-add .plus { + font-size: 1.75rem; + color: var(--accent); + margin-bottom: 6px; +} + +.card-add span { + color: var(--text-muted); + font-size: 1rem; +} + +/* Profiles grid */ +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +/* Profile card hover */ +.profile-card { + cursor: pointer; + transition: all 0.15s; +} + +.profile-card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* Profile badge */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 1rem; + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} + +.badge-care { + background: var(--success-light); + color: var(--success); +} + +/* Access list */ +.access-list { + margin-top: 8px; +} + +.access-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.access-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.access-info { + display: flex; + flex-direction: column; + gap: 1px; +} + +.access-name { + font-weight: 500; + font-size: 1rem; +} + +.access-relation { + font-size: 1rem; + color: var(--text-subtle); +} + +/* Messages */ +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +/* Trust section */ +.trust { + border-top: 1px solid var(--border); + padding-top: 32px; + margin-top: 32px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.trust-item { + font-size: 1rem; + color: var(--text-muted); +} + +.trust-item strong { + + font-weight: 500; + color: var(--text); + margin-bottom: 2px; +} + +/* Footer */ +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 1rem; + color: var(--text-subtle); +} + +/* Upload area */ +.upload-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: var(--bg-card); +} + +.upload-area:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-area.dragover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-icon { + color: var(--accent); + margin-bottom: 12px; +} + +.upload-text { + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.upload-hint { + font-size: 1rem; + color: var(--text-muted); +} + +/* Progress */ +.progress-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + z-index: 1000; +} + +.progress-modal { + background: var(--bg-card); + padding: 32px; + border-radius: 12px; + text-align: center; + min-width: 280px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); +} + +.progress-bar-wrap { + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; + margin-top: 16px; +} + +.progress-bar { + background: var(--accent); + height: 100%; + width: 0%; + transition: width 0.2s; +} + +.progress-detail { + margin-top: 12px; + font-size: 1rem; + color: var(--text-muted); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; + margin-right: auto; +} + +/* File table */ +.file-table { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.file-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 1rem; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row.file-deleted { + background: var(--bg); + color: var(--text-subtle); +} + +.file-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.file-name { + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-deleted .file-name { + color: var(--text-subtle); + text-decoration: line-through; +} + +.file-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.file-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.status-expires { + color: var(--text-muted); +} + +.status-deleted { + color: var(--text-subtle); + font-style: italic; +} + +/* Link */ +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Helpers */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.text-small { font-size: 1rem; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mb-8 { margin-bottom: 0; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } + +/* Relation cards */ +.relation-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.relation-card { + + padding: 10px 8px; + border: 1px solid var(--border); + border-radius: 6px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.relation-card:hover { + border-color: var(--accent); +} + +.relation-card input { + display: none; +} + +.relation-card input:checked + span { + font-weight: 600; +} + +.relation-card:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); +} + +.radio-pill { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.radio-pill:hover { + border-color: var(--accent); +} + +.radio-pill input { + display: none; +} + +.radio-pill:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +/* Data cards on profile page */ +.data-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + cursor: pointer; +} + +.data-section-info h3 { + margin-bottom: 2px; +} + +.data-section-meta { + font-size: 1rem; + color: var(--text-muted); +} + +.data-section-arrow { + color: var(--accent); + font-size: 1.375rem; + font-weight: 500; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; +} + + +/* Category inline selector */ +.category-inline { + font-size: 0.8rem; + padding: 0.15rem 0.4rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); + cursor: pointer; +} +.category-inline:hover { + border-color: var(--accent); +} +.category-inline:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* Status badge */ +.status-badge { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 4px; + margin-right: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* Form select for category */ +.form-select { + + width: 100%; + padding: 0.6rem 0.8rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + cursor: pointer; +} +.form-select:hover { + border-color: var(--border-hover); +} +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + + +/* Data Cards - Andrew McCalip inspired */ +.data-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; +} + +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} + +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; + flex-shrink: 0; +} + +.data-card-indicator.imaging { background: var(--accent); } +.data-card-indicator.labs { background: #059669; } +.data-card-indicator.uploads { background: #6366f1; } +.data-card-indicator.vitals { background: #ec4899; } +.data-card-indicator.medications { background: #8b5cf6; } +.data-card-indicator.records { background: #06b6d4; } +.data-card-indicator.journal { background: #f59e0b; } +.data-card-indicator.privacy { background: #64748b; } + +.data-card-title { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.data-card-summary { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Data Table */ +.data-table { + border-top: 1px solid var(--border); +} + +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); + gap: 16px; +} + +.data-row:last-child { + border-bottom: none; +} + +.data-row.expandable { + cursor: pointer; +} + +.data-row.expandable:hover { + background: var(--bg); +} + +.data-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.expand-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono, monospace); + font-size: 14px; + color: var(--text-muted); + flex-shrink: 0; +} + +.data-row.expanded .expand-icon { + transform: rotate(45deg); +} + +.data-row.single .data-row-main { + padding-left: 32px; +} + +.data-label { + font-weight: 500; + color: var(--text); +} + +.data-meta { + font-size: 0.85rem; + color: var(--text-muted); +} + +.data-values { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.data-value { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; +} + +.data-value.mono { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; +} + +.data-date { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; + color: var(--text-muted); + min-width: 80px; + text-align: right; +} + +/* Expandable children */ +.data-row-children { + display: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.data-row-children.show { + display: block; + +} + +.data-row.child { + padding-left: 48px; + border-bottom: 1px dashed var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.data-row.child:last-child { + border-bottom: none; +} + +.data-row.child .data-label { + font-weight: 400; + font-size: 0.9rem; + flex: 1; +} + +/* Section heading - smaller, uppercase */ +.section-heading { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text); +} + +/* Utility */ +.p-16 { padding: 16px; } + +/* Privacy actions row */ +.privacy-actions { + display: flex; + gap: 24px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.privacy-action { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} + +.privacy-action:hover { + text-decoration: underline; +} + +/* Share form */ +.share-form { + padding: 24px; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.form-row:last-child { + margin-bottom: 0; + margin-top: 24px; +} + +.form-row > label { + flex: 0 0 180px; + + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + margin-bottom: 0; +} + +.form-input, +.form-select { + width: 100%; + padding: 12px 16px; + font-size: 1rem; + font-family: inherit; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +/* Custom select wrapper */ +.select-wrapper { + position: relative; +} + +.select-wrapper::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-muted); + pointer-events: none; +} + +.form-select { + appearance: none; + -webkit-appearance: none; + cursor: pointer; + padding-right: 40px; +} + +/* Checkbox styling */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + font-weight: 400 !important; +} + +.checkbox-label input[type="checkbox"] { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.4; + white-space: nowrap; +} + +/* Form row flex children */ +.form-row .form-input, +.form-row .select-wrapper { + flex: 1; +} + +.form-row .select-wrapper .form-select { + width: 100%; +} + +/* Install page */ +.install-content { + padding: 16px 24px 24px; +} + +.install-content p { + margin: 0 0 16px; + color: var(--text-muted); +} + +.download-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 0.85rem; + overflow-x: auto; + white-space: pre; +} + +.form-spacer { + flex: 0 0 180px; +} + +/* Nav links */ +.nav-link { + font-size: 0.85rem; + color: var(--text-muted); + text-decoration: none; +} + +.nav-link:hover { + color: var(--text); + text-decoration: none; +} + + +/* Privacy page */ +.privacy-container { + max-width: 720px; + margin: 0 auto; + padding: 60px 40px; +} + +.privacy-container h1 { + font-family: Sora, sans-serif; + font-size: 2.5rem; + font-weight: 700; + color: #2C1810; + margin-bottom: 16px; +} + +.privacy-container .intro { + font-family: Sora, sans-serif; + font-size: 1.15rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 48px; +} + +.privacy-container h2 { + font-family: Sora, sans-serif; + font-size: 1.4rem; + font-weight: 600; + color: #2C1810; + margin-top: 48px; + margin-bottom: 24px; +} + +.privacy-container h3 { + font-family: Sora, sans-serif; + font-size: 1.1rem; + font-weight: 600; + color: #2C1810; + margin-top: 24px; + margin-bottom: 8px; +} + +.privacy-container p { + font-family: Sora, sans-serif; + font-size: 1rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 16px; +} + +.privacy-container strong { + font-weight: 600; + color: #2C1810; +} + +.privacy-container a { + color: #B45309; +} + +.privacy-container .legal-section { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid #E5DDD3; +} + +.inou-brand { + font-weight: 700; + color: #B45309; +} +/* Base styles */ +.sg-container { max-width: 1200px; margin: 0 auto; padding: 48px 24px 80px; } +.sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; } +.sg-card-content { padding: 32px; } +.sg-card-content-sm { padding: 24px; max-width: 480px; } +.sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; } +.sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; } +.sg-profile-card .card-meta { margin-bottom: 8px; } +.sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; } +.sg-profile-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 12px; } +.sg-profile-stat { display: flex; align-items: center; gap: 4px; } +.sg-row-link { color: var(--accent); font-size: 1.1rem; text-decoration: none; padding: 4px 8px; border-radius: 4px; } +.sg-row-link:hover { background: var(--accent-light); } +.sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); } +.sg-supp-timing { font-size: 0.8rem; color: var(--text-subtle); } +.sg-supp-amount { font-size: 0.8rem; color: var(--text-muted); margin-left: 8px; } +.sg-footer { margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; } +.sg-footer-left { font-size: 0.9rem; color: var(--text-muted); display: flex; gap: 16px; align-items: center; } +.sg-footer-left a { color: var(--text-muted); text-decoration: none; } +.sg-footer-left a:hover { color: var(--accent); } +.sg-footer-right { font-family: "Sora", sans-serif; font-size: 1rem; } +.sg-footer-right .inou { font-weight: 700; color: var(--accent); } +.sg-footer-right .health { font-weight: 400; color: var(--text-muted); } +.sg-select { width: 100%; padding: 10px 12px; font-size: 1rem; font-family: inherit; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; cursor: pointer; } +.sg-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.badge, .badge-care, .badge-soon, .status-badge { font-family: "Sora", sans-serif; } +.sg-gene-row { display: flex; flex-direction: column; gap: 4px; } +.sg-gene-main { display: flex; align-items: center; gap: 8px; } +.sg-gene-name { font-weight: 600; } +.sg-gene-rsid { font-size: 0.8rem; color: var(--text-muted); font-family: "SF Mono", Monaco, monospace; } +.sg-gene-allele { font-family: "SF Mono", Monaco, monospace; font-size: 0.95rem; font-weight: 600; background: var(--bg); padding: 2px 8px; border-radius: 4px; } +.sg-gene-summary { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-top: 4px; } +.sg-gene-actions { display: flex; gap: 8px; margin-top: 8px; } +.sg-ask-ai { font-size: 0.75rem; padding: 4px 10px; background: var(--accent-light); color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-family: "Sora", sans-serif; font-weight: 500; } +.sg-ask-ai:hover { background: var(--accent); color: white; } +.sg-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; padding: 16px; } +.sg-modal-overlay.show { display: flex; } +.sg-modal { background: var(--bg-card); border-radius: 12px; padding: 24px; max-width: 560px; width: 100%; box-shadow: 0 20px 40px rgba(0,0,0,0.2); } +.sg-modal h3 { margin-bottom: 16px; } +.sg-modal-prompt { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; line-height: 1.6; margin-bottom: 16px; white-space: pre-wrap; } +.sg-modal-actions { display: flex; gap: 12px; justify-content: flex-end; } +.sg-settings-row { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--border); gap: 24px; } +.sg-settings-row:last-child { border-bottom: none; } +.sg-settings-label { font-weight: 500; } +.sg-settings-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 2px; } +.sg-settings-control { min-width: 200px; } +.sg-llm-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; margin-bottom: 8px; } +.sg-llm-option:hover { border-color: var(--accent); } +.sg-llm-option.selected { border-color: var(--accent); background: var(--accent-light); } +.sg-llm-option input { display: none; } +.sg-llm-icon { width: 24px; height: 24px; border-radius: 4px; background: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 0.8rem; } +.sg-show-more { padding: 12px 16px; text-align: center; color: var(--accent); font-size: 0.85rem; cursor: pointer; border-top: 1px solid var(--border); } +.sg-show-more:hover { background: var(--accent-light); } +.sg-vital-history { padding: 12px 16px 12px 48px; background: var(--bg); border-top: 1px solid var(--border); } +.sg-vital-entry { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--border); } +.sg-vital-entry:last-child { border-bottom: none; } +.sg-vital-date { font-size: 0.8rem; color: var(--text-muted); } +.sg-vital-val { font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; } +.sg-vital-graph { height: 60px; display: flex; align-items: flex-end; gap: 4px; padding: 8px 0; } +.sg-vital-bar { width: 24px; background: var(--accent); border-radius: 3px 3px 0 0; opacity: 0.7; } +.sg-vital-bar:last-child { opacity: 1; } +.sg-note-detail { padding: 16px; background: var(--bg); border-top: 1px solid var(--border); } +.sg-note-photos { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.sg-note-photo-item { text-align: center; } +.sg-note-photo-img { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); background: #E5E2DE; display: flex; align-items: center; justify-content: center; font-size: 2rem; } +.sg-note-photo-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; } +.sg-note-timeline { margin-top: 12px; } +.sg-note-timeline-entry { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px dashed var(--border); } +.sg-note-timeline-entry:last-child { border-bottom: none; } +.sg-note-timeline-date { font-size: 0.8rem; color: var(--text-muted); min-width: 80px; } +.sg-note-timeline-text { font-size: 0.9rem; color: var(--text); } +.sg-note-category { font-size: 0.75rem; color: var(--text-subtle); background: var(--bg); padding: 2px 6px; border-radius: 3px; margin-left: 8px; } +.sg-note-icon { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; } +.sg-note-icon.temp { background: #FEE2E2; color: #DC2626; } +.sg-note-icon.weight { background: #DBEAFE; color: #2563EB; } +.sg-note-icon.bp { background: #FCE7F3; color: #DB2777; } +.sg-note-icon.note { background: var(--accent-light); color: var(--accent); } +.sg-note-icon.photo { background: #E0E7FF; color: #4F46E5; } +.sg-peptide-dates { font-size: 0.8rem; color: var(--text-muted); } +.sg-peptide-history { font-size: 0.75rem; color: var(--text-subtle); margin-top: 2px; } + +/* ======================================== + MOBILE RESPONSIVE STYLES + ======================================== */ + +/* Tablet and below */ +@media (max-width: 768px) { + .sg-container { padding: 24px 16px 60px; } + .sg-card-content { padding: 20px; } + .sg-card-content-sm { padding: 16px; max-width: 100%; } + + /* Settings row stacks */ + .sg-settings-row { flex-direction: column; align-items: flex-start; gap: 12px; } + .sg-settings-control { min-width: 100%; width: 100%; } + + /* Profile grid single column */ + .profiles-grid { grid-template-columns: 1fr !important; } + + /* Data card indicators grid */ + .sg-indicators-grid { grid-template-columns: repeat(2, 1fr) !important; } + + /* Gene row layout */ + .sg-gene-row > div:first-child { flex-direction: column; align-items: flex-start !important; gap: 8px; } + + /* Vital history less padding */ + .sg-vital-history { padding-left: 16px; } +} + +/* Phone portrait */ +@media (max-width: 480px) { + .sg-container { padding: 16px 12px 48px; } + .sg-container > h1 { font-size: 2rem; } + .sg-container > .intro { font-size: 1rem; } + + /* Data rows stack */ + .data-row:not(.child) { flex-direction: column; align-items: flex-start !important; gap: 8px; padding: 12px; } + .data-row .data-values { width: 100%; justify-content: flex-start; gap: 12px; } + .data-row .data-row-main { width: 100%; } + + /* Child rows stay horizontal but tighter */ + .data-row.child { padding: 10px 12px 10px 24px; } + .data-row.child .data-values { gap: 8px; } + + /* Buttons wrap */ + .sg-card-content > div[style*="flex-wrap"] { gap: 8px; } + + /* Modal full width */ + .sg-modal { padding: 16px; border-radius: 8px; } + .sg-modal-prompt { font-size: 0.8rem; padding: 12px; } + .sg-modal-actions { flex-direction: column; } + .sg-modal-actions .btn { width: 100%; } + + /* Footer stacks */ + .sg-footer { flex-direction: column; gap: 12px; align-items: center; text-align: center; } + .sg-footer-left { flex-direction: column; gap: 8px; } + + /* Typography scale values hide on mobile */ + .data-card:nth-child(2) .data-values { display: none; } + + /* Profile cards tighter */ + .sg-profile-card { padding: 16px; min-height: auto; } + .sg-profile-stats { flex-wrap: wrap; gap: 8px; } + + /* LLM options full width */ + .sg-llm-option { padding: 12px; } + + /* Photos smaller */ + .sg-note-photo-img { width: 64px; height: 64px; font-size: 1.5rem; } + + /* Timeline entry stacks */ + .sg-note-timeline-entry { flex-direction: column; gap: 4px; } + .sg-note-timeline-date { min-width: auto; } + + /* Gene allele and badge wrap */ + .sg-gene-row > div:first-child > div:last-child { flex-wrap: wrap; } +} + +/* Extra small phones */ +@media (max-width: 360px) { + .sg-container { padding: 12px 8px 40px; } + .data-row { padding: 10px 8px; } + .sg-note-photos { gap: 8px; } + .sg-note-photo-img { width: 56px; height: 56px; } +} + +/* Sticky footer - push to bottom when content is short */ +.sg-container { + min-height: calc(100vh - 48px); + padding-bottom: 24px !important; + display: flex; + flex-direction: column; +} + +.sg-container > .sg-footer { + margin-top: auto; +} + +.sg-container.sticky-footer { + padding-bottom: 24px; +} + +.sg-footer { + padding-top: 24px; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} +.modal-content { + background: #fff; + border-radius: 12px; + padding: 24px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; +} +.modal-content h3 { font-weight: 600; } +.modal-content ul { list-style: disc; } +.modal-content li { margin-bottom: 6px; } + +/* Genetics hidden category indicator */ +.genetics-hidden .data-label::after { + content: '⚠'; + margin-left: 8px; + font-size: 0.8rem; +} + +/* Install page */ +.install-container { + max-width: 1200px; + margin: 0 auto; + padding: 48px 24px 80px; +} +.install-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} +.install-header h1 { + font-size: 2.5rem; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} +.install-header p { + font-size: 1.15rem; + font-weight: 300; + color: var(--text-muted); +} +.install-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 24px; +} +.login-prompt { + background: var(--accent-light); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 16px 24px; + margin-bottom: 24px; + font-size: 1rem; + font-weight: 300; + color: var(--text); +} +.login-prompt a { + color: var(--accent); + font-weight: 500; +} + +/* Tabs */ +.ai-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + padding: 0 24px; +} +.ai-tab { + padding: 16px 24px; + cursor: pointer; + border: none; + background: none; + font-family: inherit; + font-size: 1rem; + color: var(--text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s; +} +.ai-tab:hover { color: var(--text); } +.ai-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 500; +} +.ai-content { + display: none; + padding: 32px; +} +.ai-content.active { display: block; } +.ai-content > p:first-child { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + margin-bottom: 24px; +} + +/* Steps */ +.step { + margin-bottom: 24px; + padding: 24px; + background: var(--bg); + border-radius: 8px; +} +.step:last-child { margin-bottom: 0; } +.step-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.step-num { + width: 32px; + height: 32px; + background: var(--accent); + color: white; + border-radius: 50%; + text-align: center; + line-height: 32px; + font-weight: 600; + font-size: 0.9rem; + flex-shrink: 0; +} +.step-num.muted { background: var(--text-muted); } +.step-num.warning { background: #F59E0B; } +.step h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} +.step p { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + line-height: 1.8; + margin: 0; +} +.step p + p { margin-top: 12px; } +.step a { color: var(--accent); } +.step ul { + margin: 12px 0 0 0; + padding-left: 20px; + color: var(--text-muted); + font-weight: 300; + line-height: 1.8; +} + +/* Code wrapper with copy button */ +.code-wrapper { + position: relative; + margin-top: 16px; +} +.code-wrapper pre { + background: #1C1917; + color: #F5F5F4; + padding: 16px; + padding-right: 48px; + border-radius: 6px; + font-family: "SF Mono", Monaco, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + line-height: 1.6; +} +.copy-icon { + position: absolute; + top: 8px; + right: 8px; + background: transparent; + border: none; + cursor: pointer; + padding: 6px; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.2s, background 0.2s; +} +.copy-icon:hover { opacity: 1; background: rgba(255,255,255,0.1); } +.copy-icon svg { width: 18px; height: 18px; stroke: #A8A29E; fill: none; } +.copy-icon.copied svg { stroke: var(--success); } + +/* Quick start box */ +.quick-start { + background: var(--bg); + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; +} +.quick-start h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin: 0 0 16px 0; +} +.quick-start p { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + margin: 0; +} + +/* Step note (smaller text) */ +.step-note { + margin-top: 12px; + font-size: 0.9rem; +} + +/* Install page mobile */ +@media (max-width: 768px) { + .install-container { padding: 24px 16px 48px; } + .install-header { flex-direction: column; gap: 16px; } + .install-header h1 { font-size: 2rem; } + .ai-tabs { padding: 0 16px; overflow-x: auto; } + .ai-tab { padding: 12px 16px; font-size: 0.9rem; white-space: nowrap; } + .ai-content { padding: 24px 16px; } + .step { padding: 20px 16px; } +} +@media (max-width: 480px) { + .install-container { padding: 16px 12px 32px; } + .install-header h1 { font-size: 1.75rem; } + .install-header p { font-size: 1rem; } + .ai-tabs { padding: 0 12px; } + .ai-tab { padding: 10px 12px; font-size: 0.85rem; } + .ai-content { padding: 20px 12px; } + .step { padding: 16px 12px; } + .code-wrapper pre { font-size: 0.8rem; padding: 12px; padding-right: 40px; } +} diff --git a/static/style.css.backup b/static/style.css.backup new file mode 100644 index 0000000..c22f81d --- /dev/null +++ b/static/style.css.backup @@ -0,0 +1,1391 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; +} + +body { + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + font-weight: 400; + line-height: 1.5; + font-size: 15px; +} + +/* Navigation */ +.nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + max-width: 1200px; + margin: 0 auto; + border-bottom: 1px solid var(--border); +} + +.logo { + font-family: "Sora", sans-serif; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0; + text-transform: lowercase; + color: #B45309; + text-decoration: none; +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +/* User menu with hover dropdown */ +.nav-user-menu { + position: relative; +} + +.nav-user-name { + font-size: 0.85rem; + color: var(--text); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.nav-user-name:hover { + background: var(--border); +} + +.nav-user-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 100px; + z-index: 100; +} + +.nav-user-menu:hover .nav-user-dropdown { + display: block; + +} + +.nav-user-dropdown a { + display: block; + + padding: 8px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.nav-user-dropdown a:hover { + background: var(--bg); + color: var(--accent); +} + +/* Language menu */ +.lang-menu { + position: relative; +} + +.lang-current { + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.lang-current:hover { + border-color: var(--border-hover); +} + +.lang-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 120px; + z-index: 100; + margin-top: 4px; +} + +.lang-menu:hover .lang-dropdown { + display: block; + +} + +.lang-dropdown a { + display: block; + + padding: 6px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.lang-dropdown a:hover { + background: var(--bg); +} + +.lang-dropdown a.active { + color: var(--accent); + font-weight: 500; +} + +.nav-user { + font-size: 0.8rem; + color: var(--text-muted); +} + +.nav-user a { + color: var(--text); + text-decoration: none; +} + +.nav-user a:hover { + color: var(--accent); +} + +.lang-picker { + font-size: 1rem; + color: var(--text-muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; +} + +/* Container */ +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; +} + +.container-narrow { + max-width: 360px; + margin: 0 auto; + padding: 60px 20px 40px; +} + +/* Dossier header */ +.dossier-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; +} + +.dossier-header-left h1 { + margin-bottom: 0; +} + +.dossier-header-left p { + margin: 4px 0 0 0; +} + +/* Coming soon badge */ +.badge-soon { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; +} + +.data-card.coming-soon { + opacity: 0.6; +} + +/* Typography */ +h1 { + font-size: 2.25rem; + font-weight: 300; + line-height: 1.2; + margin-bottom: 16px; + letter-spacing: -0.03em; + color: var(--text); +} + +h1.small { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 4px; +} + +h2 { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +h3 { + font-size: 1.125rem; + font-weight: 500; + margin-bottom: 4px; + color: var(--text); +} + +.intro { + font-size: 1rem; + color: var(--text-muted); + margin-bottom: 32px; +} + +.intro.small { + font-size: 1rem; + margin-bottom: 24px; +} + +.section-label { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 12px; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.form-group input.code-input { + font-size: 1.375rem; + text-align: center; + letter-spacing: 0.4em; + font-weight: 500; + font-family: "SF Mono", "Monaco", monospace; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.radio-group { + display: flex; + gap: 16px; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 1rem; + color: var(--text); + cursor: pointer; + font-weight: 400; +} + +.radio-group input { + width: auto; + accent-color: var(--accent); +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + color: var(--text-muted); + cursor: pointer; +} + +.checkbox-group input { + width: auto; + accent-color: var(--accent); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + text-decoration: none; + border-radius: 6px; + transition: all 0.15s; + border: none; + cursor: pointer; + text-align: center; + gap: 6px; +} + +.btn-full { + width: 100%; +} + +.btn-primary { + background: var(--accent); + color: #FFFFFF; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--border-hover); + background: var(--bg); +} + +.btn-danger { + background: var(--danger-light); + color: var(--danger); + border: 1px solid #FECACA; +} + +.btn-danger:hover { + background: #FEE2E2; +} + +.btn-small { + padding: 6px 12px; + font-size: 1rem; +} + +.btn-icon { + padding: 4px 8px; + background: transparent; + color: var(--text-subtle); + border: none; + font-size: 1rem; + line-height: 1; + border-radius: 4px; +} + +.btn-icon:hover { + color: var(--danger); + background: var(--danger-light); +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.card-link { + + text-decoration: none; + color: inherit; + padding: 0; + transition: all 0.15s; +} + +.card-link:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + text-decoration: none; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-header h3 { + margin-bottom: 0; +} + +.card-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.card-actions { + display: flex; + gap: 6px; +} + +.card-add { + border: 2px dashed var(--border); + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + cursor: pointer; + text-decoration: none; +} + +.card-add:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.card-add .plus { + font-size: 1.75rem; + color: var(--accent); + margin-bottom: 6px; +} + +.card-add span { + color: var(--text-muted); + font-size: 1rem; +} + +/* Profiles grid */ +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +/* Profile card hover */ +.profile-card { + cursor: pointer; + transition: all 0.15s; +} + +.profile-card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* Profile badge */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 1rem; + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} + +.badge-care { + background: var(--success-light); + color: var(--success); +} + +/* Access list */ +.access-list { + margin-top: 8px; +} + +.access-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.access-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.access-info { + display: flex; + flex-direction: column; + gap: 1px; +} + +.access-name { + font-weight: 500; + font-size: 1rem; +} + +.access-relation { + font-size: 1rem; + color: var(--text-subtle); +} + +/* Messages */ +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +/* Trust section */ +.trust { + border-top: 1px solid var(--border); + padding-top: 32px; + margin-top: 32px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.trust-item { + font-size: 1rem; + color: var(--text-muted); +} + +.trust-item strong { + + font-weight: 500; + color: var(--text); + margin-bottom: 2px; +} + +/* Footer */ +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 1rem; + color: var(--text-subtle); +} + +/* Upload area */ +.upload-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: var(--bg-card); +} + +.upload-area:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-area.dragover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-icon { + color: var(--accent); + margin-bottom: 12px; +} + +.upload-text { + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.upload-hint { + font-size: 1rem; + color: var(--text-muted); +} + +/* Progress */ +.progress-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + z-index: 1000; +} + +.progress-modal { + background: var(--bg-card); + padding: 32px; + border-radius: 12px; + text-align: center; + min-width: 280px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); +} + +.progress-bar-wrap { + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; + margin-top: 16px; +} + +.progress-bar { + background: var(--accent); + height: 100%; + width: 0%; + transition: width 0.2s; +} + +.progress-detail { + margin-top: 12px; + font-size: 1rem; + color: var(--text-muted); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; + margin-right: auto; +} + +/* File table */ +.file-table { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.file-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 1rem; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row.file-deleted { + background: var(--bg); + color: var(--text-subtle); +} + +.file-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.file-name { + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-deleted .file-name { + color: var(--text-subtle); + text-decoration: line-through; +} + +.file-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.file-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.status-expires { + color: var(--text-muted); +} + +.status-deleted { + color: var(--text-subtle); + font-style: italic; +} + +/* Link */ +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Helpers */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.text-small { font-size: 1rem; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mb-8 { margin-bottom: 0; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } + +/* Relation cards */ +.relation-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.relation-card { + + padding: 10px 8px; + border: 1px solid var(--border); + border-radius: 6px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.relation-card:hover { + border-color: var(--accent); +} + +.relation-card input { + display: none; +} + +.relation-card input:checked + span { + font-weight: 600; +} + +.relation-card:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); +} + +.radio-pill { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.radio-pill:hover { + border-color: var(--accent); +} + +.radio-pill input { + display: none; +} + +.radio-pill:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +/* Data cards on profile page */ +.data-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + cursor: pointer; +} + +.data-section-info h3 { + margin-bottom: 2px; +} + +.data-section-meta { + font-size: 1rem; + color: var(--text-muted); +} + +.data-section-arrow { + color: var(--accent); + font-size: 1.375rem; + font-weight: 500; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; +} + + +/* Category inline selector */ +.category-inline { + font-size: 0.8rem; + padding: 0.15rem 0.4rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); + cursor: pointer; +} +.category-inline:hover { + border-color: var(--accent); +} +.category-inline:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* Status badge */ +.status-badge { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 4px; + margin-right: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* Form select for category */ +.form-select { + + width: 100%; + padding: 0.6rem 0.8rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + cursor: pointer; +} +.form-select:hover { + border-color: var(--border-hover); +} +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + + +/* Data Cards - Andrew McCalip inspired */ +.data-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; +} + +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} + +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; + flex-shrink: 0; +} + +.data-card-indicator.imaging { background: var(--accent); } +.data-card-indicator.labs { background: #059669; } +.data-card-indicator.uploads { background: #6366f1; } +.data-card-indicator.vitals { background: #ec4899; } +.data-card-indicator.medications { background: #8b5cf6; } +.data-card-indicator.records { background: #06b6d4; } +.data-card-indicator.journal { background: #f59e0b; } +.data-card-indicator.privacy { background: #64748b; } + +.data-card-title { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.data-card-summary { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Data Table */ +.data-table { + border-top: 1px solid var(--border); +} + +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); + gap: 16px; +} + +.data-row:last-child { + border-bottom: none; +} + +.data-row.expandable { + cursor: pointer; +} + +.data-row.expandable:hover { + background: var(--bg); +} + +.data-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.expand-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono, monospace); + font-size: 14px; + color: var(--text-muted); + flex-shrink: 0; +} + +.data-row.expanded .expand-icon { + transform: rotate(45deg); +} + +.data-row.single .data-row-main { + padding-left: 32px; +} + +.data-label { + font-weight: 500; + color: var(--text); +} + +.data-meta { + font-size: 0.85rem; + color: var(--text-muted); +} + +.data-values { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.data-value { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; +} + +.data-value.mono { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; +} + +.data-date { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; + color: var(--text-muted); + min-width: 80px; + text-align: right; +} + +/* Expandable children */ +.data-row-children { + display: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.data-row-children.show { + display: block; + +} + +.data-row.child { + padding-left: 48px; + border-bottom: 1px dashed var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.data-row.child:last-child { + border-bottom: none; +} + +.data-row.child .data-label { + font-weight: 400; + font-size: 0.9rem; + flex: 1; +} + +/* Section heading - smaller, uppercase */ +.section-heading { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text); +} + +/* Utility */ +.p-16 { padding: 16px; } + +/* Privacy actions row */ +.privacy-actions { + display: flex; + gap: 24px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.privacy-action { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} + +.privacy-action:hover { + text-decoration: underline; +} + +/* Share form */ +.share-form { + padding: 24px; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.form-row:last-child { + margin-bottom: 0; + margin-top: 24px; +} + +.form-row > label { + flex: 0 0 180px; + + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + margin-bottom: 0; +} + +.form-input, +.form-select { + width: 100%; + padding: 12px 16px; + font-size: 1rem; + font-family: inherit; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +/* Custom select wrapper */ +.select-wrapper { + position: relative; +} + +.select-wrapper::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-muted); + pointer-events: none; +} + +.form-select { + appearance: none; + -webkit-appearance: none; + cursor: pointer; + padding-right: 40px; +} + +/* Checkbox styling */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + font-weight: 400 !important; +} + +.checkbox-label input[type="checkbox"] { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.4; + white-space: nowrap; +} + +/* Form row flex children */ +.form-row .form-input, +.form-row .select-wrapper { + flex: 1; +} + +.form-row .select-wrapper .form-select { + width: 100%; +} + +/* Install page */ +.install-content { + padding: 16px 24px 24px; +} + +.install-content p { + margin: 0 0 16px; + color: var(--text-muted); +} + +.download-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 0.85rem; + overflow-x: auto; + white-space: pre; +} + +.form-spacer { + flex: 0 0 180px; +} + +/* Nav links */ +.nav-link { + font-size: 0.85rem; + color: var(--text-muted); + text-decoration: none; +} + +.nav-link:hover { + color: var(--text); + text-decoration: none; +} + + +/* Privacy page */ +.privacy-container { + max-width: 720px; + margin: 0 auto; + padding: 60px 40px; +} + +.privacy-container h1 { + font-family: Sora, sans-serif; + font-size: 2.5rem; + font-weight: 700; + color: #2C1810; + margin-bottom: 16px; +} + +.privacy-container .intro { + font-family: Sora, sans-serif; + font-size: 1.15rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 48px; +} + +.privacy-container h2 { + font-family: Sora, sans-serif; + font-size: 1.4rem; + font-weight: 600; + color: #2C1810; + margin-top: 48px; + margin-bottom: 24px; +} + +.privacy-container h3 { + font-family: Sora, sans-serif; + font-size: 1.1rem; + font-weight: 600; + color: #2C1810; + margin-top: 24px; + margin-bottom: 8px; +} + +.privacy-container p { + font-family: Sora, sans-serif; + font-size: 1rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 16px; +} + +.privacy-container strong { + font-weight: 600; + color: #2C1810; +} + +.privacy-container a { + color: #B45309; +} + +.privacy-container .legal-section { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid #E5DDD3; +} + +.inou-brand { + font-weight: 700; + color: #B45309; +} diff --git a/static/swagger.html b/static/swagger.html new file mode 100644 index 0000000..ad4d984 --- /dev/null +++ b/static/swagger.html @@ -0,0 +1,26 @@ + + + + + inou API + + + + +
+ + + + diff --git a/static/test_thumb.png b/static/test_thumb.png new file mode 100644 index 0000000..c16c999 Binary files /dev/null and b/static/test_thumb.png differ diff --git a/static/thumb_150px_q30.jpg b/static/thumb_150px_q30.jpg new file mode 100644 index 0000000..f7ac9e1 Binary files /dev/null and b/static/thumb_150px_q30.jpg differ diff --git a/static/thumb_150px_q50.jpg b/static/thumb_150px_q50.jpg new file mode 100644 index 0000000..4fcca36 Binary files /dev/null and b/static/thumb_150px_q50.jpg differ diff --git a/static/thumb_200px_q10.jpg b/static/thumb_200px_q10.jpg new file mode 100644 index 0000000..eb78aa0 Binary files /dev/null and b/static/thumb_200px_q10.jpg differ diff --git a/static/thumb_200px_q15.jpg b/static/thumb_200px_q15.jpg new file mode 100644 index 0000000..ecb5d31 Binary files /dev/null and b/static/thumb_200px_q15.jpg differ diff --git a/static/thumb_200px_q20.jpg b/static/thumb_200px_q20.jpg new file mode 100644 index 0000000..3c3be25 Binary files /dev/null and b/static/thumb_200px_q20.jpg differ diff --git a/static/thumb_256px_q10.jpg b/static/thumb_256px_q10.jpg new file mode 100644 index 0000000..3086ae7 Binary files /dev/null and b/static/thumb_256px_q10.jpg differ diff --git a/static/thumb_256px_q15.jpg b/static/thumb_256px_q15.jpg new file mode 100644 index 0000000..d843954 Binary files /dev/null and b/static/thumb_256px_q15.jpg differ diff --git a/static/thumb_300px_q10.jpg b/static/thumb_300px_q10.jpg new file mode 100644 index 0000000..323e952 Binary files /dev/null and b/static/thumb_300px_q10.jpg differ diff --git a/static/thumb_gif.gif b/static/thumb_gif.gif new file mode 100644 index 0000000..13630c1 Binary files /dev/null and b/static/thumb_gif.gif differ diff --git a/static/thumb_jpg_q10.jpg b/static/thumb_jpg_q10.jpg new file mode 100644 index 0000000..8f368b0 Binary files /dev/null and b/static/thumb_jpg_q10.jpg differ diff --git a/static/thumb_jpg_q20.jpg b/static/thumb_jpg_q20.jpg new file mode 100644 index 0000000..06f7916 Binary files /dev/null and b/static/thumb_jpg_q20.jpg differ diff --git a/static/thumb_jpg_q30.jpg b/static/thumb_jpg_q30.jpg new file mode 100644 index 0000000..f7ac9e1 Binary files /dev/null and b/static/thumb_jpg_q30.jpg differ diff --git a/static/thumb_jpg_q50.jpg b/static/thumb_jpg_q50.jpg new file mode 100644 index 0000000..4fcca36 Binary files /dev/null and b/static/thumb_jpg_q50.jpg differ diff --git a/static/thumb_jpg_q70.jpg b/static/thumb_jpg_q70.jpg new file mode 100644 index 0000000..d62d68d Binary files /dev/null and b/static/thumb_jpg_q70.jpg differ diff --git a/static/thumb_jpg_q85.jpg b/static/thumb_jpg_q85.jpg new file mode 100644 index 0000000..499be5e Binary files /dev/null and b/static/thumb_jpg_q85.jpg differ diff --git a/static/thumb_jpg_q95.jpg b/static/thumb_jpg_q95.jpg new file mode 100644 index 0000000..3f2f862 Binary files /dev/null and b/static/thumb_jpg_q95.jpg differ diff --git a/static/thumb_png_-1.png b/static/thumb_png_-1.png new file mode 100644 index 0000000..fdf70db Binary files /dev/null and b/static/thumb_png_-1.png differ diff --git a/static/thumb_png_-2.png b/static/thumb_png_-2.png new file mode 100644 index 0000000..601aea8 Binary files /dev/null and b/static/thumb_png_-2.png differ diff --git a/static/thumb_png_-3.png b/static/thumb_png_-3.png new file mode 100644 index 0000000..b2f8c71 Binary files /dev/null and b/static/thumb_png_-3.png differ diff --git a/static/thumb_png_0.png b/static/thumb_png_0.png new file mode 100644 index 0000000..1c605fd Binary files /dev/null and b/static/thumb_png_0.png differ diff --git a/static/thumbs.html b/static/thumbs.html new file mode 100644 index 0000000..3eca0ee --- /dev/null +++ b/static/thumbs.html @@ -0,0 +1,43 @@ + + +Thumbnail Test + + + +

Thumbnails

+
Loading...
+ + + diff --git a/static/viewer-screenshot.png b/static/viewer-screenshot.png new file mode 100644 index 0000000..d7e3b1a Binary files /dev/null and b/static/viewer-screenshot.png differ diff --git a/static/viewer.css b/static/viewer.css new file mode 100644 index 0000000..b949d55 --- /dev/null +++ b/static/viewer.css @@ -0,0 +1,513 @@ +@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap'); +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; overflow: hidden; } +body { background: #000; color: #fff; font-family: monospace; display: flex; flex-direction: column; } +#header { padding: 10px 16px; background: #1a1a1a; display: flex; gap: 20px; align-items: center; font-family: 'Sora', sans-serif; flex-shrink: 0; } +#header-left { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +#header-right { display: flex; gap: 16px; align-items: center; margin-left: auto; } +#coordsBox { display: flex; gap: 5px; align-items: center; } +#coordsBox input { background: #222; color: #B45309; border: 1px solid #B45309; padding: 6px 10px; width: 200px; font-family: monospace; font-size: 12px; border-radius: 4px; } +#coordsBox button { background: #B45309; color: #000; border: none; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; } +#coordsBox button:hover { background: #D97706; } +#branding { display: flex; align-items: center; gap: 6px; font-size: 15px; } +#branding .brand-inou { color: #B45309; font-weight: 600; } +#branding .brand-health { color: #888; font-weight: 300; } +#panels { display: flex; flex: 1; overflow: hidden; min-height: 0; } +.panel { flex: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid #333; } +.panel:last-child { border-right: none; } +.panel-header { padding: 5px 10px; background: #2a2a2a; font-size: 12px; } +.panel-header .series-name { color: #B45309; font-weight: 500; } +.panel-content { flex: 1; display: flex; justify-content: center; align-items: center; overflow: hidden; position: relative; min-height: 0; } +.panel-content.zoomed { cursor: grab; } +.panel-content.panning { cursor: grabbing; } +.panel-content img { max-width: 100%; max-height: 100%; } +.thumbnails { height: 110px; background: #111; display: flex; padding: 8px 16px; gap: 10px; align-items: stretch; font-family: 'Sora', sans-serif; } +.wl-presets { display: flex; gap: 8px; flex-shrink: 0; } +.wl-preset { display: flex; flex-direction: column; align-items: center; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; } +.wl-preset:hover { opacity: 1; } +.wl-preset.active { opacity: 1; } +.wl-preset img { height: 70px; width: auto; border: 2px solid transparent; border-radius: 4px; } +.wl-preset.active img { border-color: #B45309; } +.wl-preset span { font-size: 10px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } +.wl-preset.active span { color: #B45309; } +.slice-scrubber { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 0 20px; min-width: 200px; max-width: 500px; } +.scrubber-label { font-size: 11px; color: #666; margin-bottom: 8px; } +.scrubber-track { height: 8px; background: #222; border-radius: 4px; position: relative; cursor: pointer; } +.scrubber-fill { height: 100%; background: linear-gradient(90deg, #F59E0B, #B45309); border-radius: 4px; position: absolute; left: 0; top: 0; pointer-events: none; } +.scrubber-handle { width: 16px; height: 16px; background: #B45309; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%, -50%); cursor: grab; box-shadow: 0 0 8px rgba(245,158,11,0.5); } +.scrubber-handle:active { cursor: grabbing; } +.scrubber-ticks { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: #444; } +.thumb { height: 70px; cursor: pointer; opacity: 0.6; } +.thumb:hover { opacity: 0.8; } +.thumb.active { opacity: 1; border: 2px solid #B45309; } +select { + background: #1a1a1a; + color: #fff; + border: 1px solid #333; + padding: 8px 32px 8px 12px; + border-radius: 8px; + font-family: 'Sora', sans-serif; + font-size: 13px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: border-color 0.15s, background-color 0.15s; +} +select:hover { background-color: #222; border-color: #444; } +select:focus { outline: none; border-color: #B45309; } +button { + background: #1a1a1a; + color: #fff; + border: 1px solid #333; + padding: 8px 16px; + border-radius: 8px; + font-family: 'Sora', sans-serif; + font-size: 13px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} +button:hover { background: #222; border-color: #444; } +button:disabled { background: #111; color: #555; cursor: not-allowed; border-color: #222; } +button:disabled:hover { background: #111; } + +.sync-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #888; + cursor: pointer; +} +.sync-label input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + border: 1px solid #444; + border-radius: 3px; + background: #1a1a1a; + cursor: pointer; + position: relative; +} +.sync-label input[type="checkbox"]:checked { + background: #B45309; + border-color: #B45309; +} +.sync-label input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #000; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.sync-label span { white-space: nowrap; } + +#coordDisplay { + position: fixed; + bottom: 45px; + right: 20px; + background: rgba(0,0,0,0.8); + padding: 5px 12px; + font-size: 13px; + color: #B45309; + border: 1px solid rgba(245,158,11,0.3); + border-radius: 4px; + z-index: 1000; + font-variant-numeric: tabular-nums; +} +#wlHint { + position: fixed; + background: rgba(0,0,0,0.9); + padding: 6px 12px; + font-size: 12px; + font-family: 'Sora', sans-serif; + color: #fff; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + z-index: 2000; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; +} +#wlHint.show { opacity: 1; } +#debugInfo { + color: #f88; + font-size: 11px; + display: none; +} +.rect-overlay { + position: absolute; + border: 2px solid #ff0; + background: rgba(255, 255, 0, 0.1); + pointer-events: none; +} +.img-wrapper { + position: relative; + display: inline-block; + transition: transform 0.15s ease-out; + transform-origin: center center; +} +.crosshair-h, .crosshair-v { + position: absolute; + background: rgba(245, 158, 11, 0.7); + pointer-events: none; +} +.crosshair-h { + height: 1px; + left: 0; + right: 0; +} +.crosshair-v { + width: 1px; + top: 0; + bottom: 0; +} +.panel-label { + position: absolute; + top: 5px; + left: 5px; + color: #B45309; + font-size: 14px; + font-weight: bold; + text-shadow: 1px 1px 2px #000; +} +#helpBtn { + background: #222; + border: 2px solid #B45309; + color: #B45309; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 16px; + font-weight: bold; + cursor: pointer; + margin-left: 20px; + line-height: 24px; + text-align: center; + padding: 0; +} +#helpBtn:hover { background: #B45309; color: #000; } + +/* Image info overlay - premium medical display */ +.image-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + z-index: 10; +} + +/* Corner info blocks */ +.overlay-top-left { + position: absolute; + top: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 0; +} +.overlay-top-right { + position: absolute; + top: 16px; + right: 16px; + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; +} +.overlay-bottom-left { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} +.overlay-bottom-right { + position: absolute; + bottom: 16px; + right: 16px; + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +/* Patient and study info */ +.overlay-patient { + font-size: 15px; + font-weight: 500; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6); + letter-spacing: 0.3px; + line-height: 20px; +} +.overlay-datetime { + font-size: 13px; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9); + line-height: 20px; +} +.overlay-accession { + font-size: 13px; + color: rgba(255,255,255,0.5); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} +.overlay-institution { + font-size: 13px; + color: rgba(255,255,255,0.5); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} +.overlay-study { + font-size: 11px; + font-weight: 400; + color: rgba(255,255,255,0.6); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} + +/* Series name - prominent orange accent */ +.overlay-series { + font-size: 15px; + font-weight: 600; + color: #B45309; + text-shadow: 0 0 12px rgba(245,158,11,0.4), 0 1px 4px rgba(0,0,0,0.9); + letter-spacing: 0.5px; + line-height: 20px; +} + +/* Slice info */ +.overlay-slice { + font-size: 20px; + font-weight: 300; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9); + font-variant-numeric: tabular-nums; +} +.overlay-slice-total { + font-size: 13px; + color: rgba(255,255,255,0.5); +} +.overlay-pos { + font-size: 13px; + font-weight: 400; + color: rgba(255,255,255,0.9); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + font-variant-numeric: tabular-nums; + line-height: 20px; +} +.overlay-thickness { + font-size: 10px; + color: rgba(255,255,255,0.4); + line-height: 20px; +} + +/* W/L display - elegant pill design */ +.overlay-wl { + display: flex; + gap: 12px; + align-items: center; + align-self: flex-end; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 8px 14px; + border-radius: 20px; + border: 1px solid rgba(255,255,255,0.2); +} +.overlay-wl-item { + display: flex; + align-items: baseline; + gap: 6px; +} +.overlay-wl-label { + font-size: 10px; + font-weight: 500; + color: rgba(255,255,255,0.4); + text-transform: uppercase; + letter-spacing: 1px; +} +.overlay-wl-value { + font-size: 14px; + font-weight: 500; + color: rgba(255,255,255,0.9); + font-variant-numeric: tabular-nums; + min-width: 55px; + text-align: right; +} +.overlay-wl-value.wl-adjusted { + color: #ffeb3b !important; + text-shadow: 0 0 8px rgba(255,235,59,0.5); +} +.overlay-wl-divider { + width: 1px; + height: 16px; + background: rgba(255,255,255,0.15); +} + +/* Orientation markers - elegant edge labels */ +.overlay-orient { + position: absolute; + font-size: 20px; + font-weight: 600; + color: rgba(245,158,11,0.7); + text-shadow: 0 0 10px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6); + letter-spacing: 1px; +} +.overlay-orient-top { + top: 16px; + left: 50%; + transform: translateX(-50%); +} +.overlay-orient-bottom { + bottom: 16px; + left: 50%; + transform: translateX(-50%); +} +.overlay-orient-left { + left: 16px; + top: 50%; + transform: translateY(-50%); +} +.overlay-orient-right { + right: 16px; + top: 50%; + transform: translateY(-50%); +} + +/* Zoom indicator */ +.overlay-zoom { + font-size: 12px; + font-weight: 500; + color: rgba(255,255,255,0.6); + background: rgba(0,0,0,0.4); + padding: 4px 10px; + border-radius: 12px; + display: none; +} +.overlay-zoom.active { + display: inline-block; + color: #B45309; +} +/* Tour overlay */ +#tourOverlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 3000; +} +#tourOverlay.show { display: block; } +#tourSpotlight { + position: absolute; + border: 3px solid #B45309; + border-radius: 8px; + box-shadow: 0 0 0 9999px rgba(0,0,0,0.5), 0 0 20px #B45309; + pointer-events: none; + transition: all 0.3s ease; + z-index: 1; +} +#tourTooltip { + position: absolute; + background: #111; + border: 2px solid #B45309; + color: #fff; + padding: 15px 20px; + max-width: 450px; + border-radius: 8px; + font-size: 14px; + line-height: 1.5; + z-index: 3001; + display: block; +} +#tourTooltip h3 { color: #B45309; margin: 0 0 8px 0; font-size: 16px; } +#tourTooltip p { margin: 0 0 12px 0; } +#tourTooltip .tour-nav { display: flex; justify-content: space-between; align-items: center; } +#tourTooltip .tour-nav button { + background: #B45309; color: #000; border: none; padding: 6px 16px; + border-radius: 4px; cursor: pointer; font-weight: bold; +} +#tourTooltip .tour-nav button:hover { background: #0aa; } +#tourTooltip .tour-skip { background: transparent !important; color: #888 !important; } +#tourTooltip .tour-step { color: #666; font-size: 12px; } +#tourBtn { + background: #222; border: 1px solid #555; color: #B45309; + padding: 5px 10px; cursor: pointer; font-size: 12px; +} +#tourBtn:hover { background: #333; } +#helpModal { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.8); + z-index: 2000; + justify-content: center; + align-items: center; +} +#helpModal.show { display: flex; } +#helpContent { + background: #111; + border: 1px solid #B45309; + padding: 20px 30px; + max-width: 400px; + font-size: 14px; + line-height: 1.8; +} +#helpContent h2 { color: #B45309; margin: 0 0 15px 0; font-size: 18px; } +#helpContent table { width: 100%; } +#helpContent td { padding: 4px 0; } +#helpContent td:first-child { color: #ff0; width: 140px; } +#helpContent hr { border: none; border-top: 1px solid #333; margin: 12px 0; } + + +/* Light background overlay adjustments (for X-rays, etc.) */ +.panel.light-bg .overlay-patient, +.panel.light-bg .overlay-accession, +.panel.light-bg .overlay-study-desc, +.panel.light-bg .overlay-datetime, +.panel.light-bg .overlay-institution { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-series { + color: #92400E; + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-slice { + color: rgba(0,0,0,0.9); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-slice-total { + color: rgba(0,0,0,0.5); +} +.panel.light-bg .overlay-pos, +.panel.light-bg .overlay-thickness { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-orient { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 3px rgba(255,255,255,0.9); +} +.panel.light-bg .overlay-wl { + background: rgba(255,255,255,0.6); + border-color: rgba(0,0,0,0.2); +} +.panel.light-bg .overlay-wl-label { + color: rgba(0,0,0,0.5); +} +.panel.light-bg .overlay-wl-value { + color: rgba(0,0,0,0.9); +} diff --git a/static/viewer.js b/static/viewer.js new file mode 100644 index 0000000..e0bd9eb --- /dev/null +++ b/static/viewer.js @@ -0,0 +1,1648 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome', + text: 'Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + await setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +async function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + await addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + // Auto-load default series, but skip if URL has a series parameter (will be loaded by init) + const params = new URLSearchParams(window.location.search); + const urlSeries = params.get('series'); + if (idx < seriesList.length && !(idx === 0 && urlSeries)) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + await loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1).then(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId)); + const data = await res.json(); + const slices = data.slices || data; + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + const sagData = getData(sagPanel); + const axData = getData(axPanel); + const corData = getData(corPanel); + + panels.forEach((p, idx) => { + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + const myData = getData(p); + const rect = img.getBoundingClientRect(); + // Divide out zoom since crosshairs are inside the transformed wrapper + const zoom = zoomLevels[zoomState[p.orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + // Build target point from CENTER of other slices + // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y + let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z; + if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x; + if (axData && p.orientation !== 'AX') targetZ = axData.center_z; + if (corData && p.orientation !== 'COR') targetY = corData.center_y; + + // Offset from corner to target + const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z]; + + // Project onto row/col directions + const vPixel = dot(offset, myData.rowVec) / myData.psCol; + const hPixel = dot(offset, myData.colVec) / myData.psRow; + + if (hPixel >= 0 && hPixel <= myData.rows) { + hLine.style.top = (hPixel * scaleY) + 'px'; + hLine.style.display = 'block'; + } else { + hLine.style.display = 'none'; + } + + if (vPixel >= 0 && vPixel <= myData.cols) { + vLine.style.left = (vPixel * scaleX) + 'px'; + vLine.style.display = 'block'; + } else { + vLine.style.display = 'none'; + } + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/static/viewer.js.bak b/static/viewer.js.bak new file mode 100644 index 0000000..d0838b5 --- /dev/null +++ b/static/viewer.js.bak @@ -0,0 +1,1646 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome to Inou', + text: '
異能
"extraordinary ability"
Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + if (idx < seriesList.length) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1); + setTimeout(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }, 50); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId)); + const slices = await res.json(); + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + slice_loc: s.slice_location, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + // Get reference data from FIRST slice of each series (fixed reference frame) + const getRefData = (panel) => { + if (!panel || !panel.slices || !panel.slices.length) return null; + const s = panel.slices[0]; // Use first slice as reference + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + rows: s.rows, + cols: s.cols, + psRow: s.pixel_spacing_row, + psCol: s.pixel_spacing_col + }; + }; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + // Current slice_location from each panel (through-plane position) + const sagLoc = sagPanel?.slices[sagPanel.currentSlice]?.slice_location || 0; // X + const axLoc = axPanel?.slices[axPanel.currentSlice]?.slice_location || 0; // Z + const corLoc = corPanel?.slices[corPanel.currentSlice]?.slice_location || 0; // Y + + // Reference frames (first slice of each series) + const sagRef = getRefData(sagPanel); + const axRef = getRefData(axPanel); + const corRef = getRefData(corPanel); + + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + console.log("Panel " + idx + ": naturalWidth=" + img.naturalWidth); + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + // DEBUG: Fixed position at top-left corner + hLine.style.top = '0px'; + hLine.style.display = 'block'; + vLine.style.left = '0px'; + vLine.style.display = 'block'; + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/static/viewer_js b/static/viewer_js new file mode 100644 index 0000000..e439fca --- /dev/null +++ b/static/viewer_js @@ -0,0 +1,1646 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome to Inou', + text: '
異能
"extraordinary ability"
Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + if (idx < seriesList.length) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1); + setTimeout(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }, 50); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId + '&thumbs=false')); + const data = await res.json(); + const slices = data.slices || data; + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + const sagData = getData(sagPanel); + const axData = getData(axPanel); + const corData = getData(corPanel); + + panels.forEach((p, idx) => { + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + const myData = getData(p); + const rect = img.getBoundingClientRect(); + // Divide out zoom since crosshairs are inside the transformed wrapper + const zoom = zoomLevels[zoomState[p.orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + // Build target point from CENTER of other slices + // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y + let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z; + if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x; + if (axData && p.orientation !== 'AX') targetZ = axData.center_z; + if (corData && p.orientation !== 'COR') targetY = corData.center_y; + + // Offset from corner to target + const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z]; + + // Project onto row/col directions + const vPixel = dot(offset, myData.rowVec) / myData.psCol; + const hPixel = dot(offset, myData.colVec) / myData.psRow; + + if (hPixel >= 0 && hPixel <= myData.rows) { + hLine.style.top = (hPixel * scaleY) + 'px'; + hLine.style.display = 'block'; + } else { + hLine.style.display = 'none'; + } + + if (vPixel >= 0 && vPixel <= myData.cols) { + vLine.style.left = (vPixel * scaleX) + 'px'; + vLine.style.display = 'block'; + } else { + vLine.style.display = 'none'; + } + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/status.sh b/status.sh new file mode 100644 index 0000000..81d2086 --- /dev/null +++ b/status.sh @@ -0,0 +1,40 @@ +#!/bin/bash +cd /tank/inou + +echo "=== Inou Status ===" +echo "" + +# Services +echo "Services:" +if pgrep -f "bin/api$" > /dev/null; then + echo " API: running (PID $(pgrep -f 'bin/api$'))" +else + echo " API: stopped" +fi + +if pgrep -f "bin/viewer$" > /dev/null; then + echo " Viewer: running (PID $(pgrep -f 'bin/viewer$'))" +else + echo " Viewer: stopped" +fi + +if pgrep -f "bin/portal$" > /dev/null; then + echo " Portal: running (PID $(pgrep -f 'bin/portal$'))" +else + echo " Portal: stopped" +fi + +echo "" +echo "Endpoints:" +echo " Portal: https://inou.com" +echo " Viewer: https://inou.com:8767" +echo " API: https://inou.com/api/* (internal :8082)" + +echo "" +echo "FIPS 140-3 Build Status:" + +if [[ -x "bin/fips-check" ]]; then + bin/fips-check bin/api bin/portal bin/viewer bin/import-genome bin/lab-scrape bin/lab-import 2>/dev/null | sed 's/^/ /' +else + echo " (fips-check not found - run make deploy)" +fi diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..effd205 --- /dev/null +++ b/stop.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Stop Inou services +echo "=== Inou Stop ===" + +pkill -f "bin/api$" && echo "API: stopped" || echo "API: not running" +pkill -f "bin/viewer$" && echo "Viewer: stopped" || echo "Viewer: not running" +pkill -f "bin/portal$" && echo "Portal: stopped" || echo "Portal: not running" diff --git a/templates/add_dossier.tmpl b/templates/add_dossier.tmpl new file mode 100644 index 0000000..10828bf --- /dev/null +++ b/templates/add_dossier.tmpl @@ -0,0 +1,109 @@ +{{define "add_dossier"}} +
+ +
+
+

{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}

+

{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + + +
+ +
+ +
+ + +
+
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+ + +
+ {{end}} + +
+ + +
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+ +
+
+ +
+ {{end}} + + {{if and .ConfirmDuplicate (not .EditMode)}} +
+ +
+ {{end}} +
+ {{.T.cancel}} + +
+
+
+
+ + {{template "footer"}} +
+ +{{end}} \ No newline at end of file diff --git a/templates/api.tmpl b/templates/api.tmpl new file mode 100644 index 0000000..d020f11 --- /dev/null +++ b/templates/api.tmpl @@ -0,0 +1,143 @@ +{{define "api"}} + + +
+

API

+

Access your health dossier data programmatically — or let AI do it for you.

+ + {{if .Dossier}} +
+

{{.T.api_token}}

+ {{if .APIToken}} +

{{.T.api_token_use}}

+
+ + +
+

{{.T.api_token_warning}}

+
+ +
+ {{else}} +

{{.T.api_token_none}}

+
+ +
+ {{end}} +
+ + {{end}} + +

{{.T.api_authentication}}

+

{{.T.api_auth_instructions}}

+
Authorization: Bearer YOUR_API_TOKEN
+ +

Endpoints

+ + + +
+ GET /api/v1/dossiers +

List all dossiers accessible to this account (your own + any shared with you).

+
+ + + +
+ GET /api/v1/dossiers/{id}/entries?category=imaging +

List all imaging studies in a dossier. Returns study ID, date, description, and series count.

+
+ +
+ GET /api/v1/entries/{study_id}/children +

List series in a study. Optional: ?filter=SAG or ?filter=T1 to filter by description.

+
+ +
+ GET /api/v1/entries/{series_id}/children +

List slices with position data (mm coordinates, orientation, pixel spacing).

+
+ +
+ GET /api/v1/entries/{slice_id}?detail=full +

Get slice image as PNG. Optional: &ww=WIDTH&wc=CENTER for windowing.

+
+ + + +
+ GET /api/v1/dossiers/{id}/entries?category=genome +

List genome variant categories: medication, cardiovascular, metabolism, fertility, traits, longevity.

+
+ +
+ GET /api/v1/dossiers/{id}/genome?search=MTHFR +

Query genome variants. Optional filters: &category=medication, &rsids=rs1234,rs5678, &min_magnitude=2

+
+ + + +
+ GET /api/v1/dossiers/{id}/labs/tests +

List all available lab test names for a dossier.

+
+ +
+ GET /api/v1/dossiers/{id}/labs/results?names=TSH,T4 +

Get lab results. Required: &names= (comma-separated). Optional: &from=2024-01-01, &to=2024-12-31, &latest=true

+
+ +
+ Text Format: Add &format=text to any endpoint for AI-friendly plain text output instead of JSON. +
+ +

Example

+
# List your dossiers
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers
+
+# List imaging studies
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers/DOSSIER_ID/entries?category=imaging
+
+# Query genome variants
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers/DOSSIER_ID/genome?search=MTHFR
+ +
+{{end}} diff --git a/templates/audit.tmpl b/templates/audit.tmpl new file mode 100644 index 0000000..df09388 --- /dev/null +++ b/templates/audit.tmpl @@ -0,0 +1,50 @@ +{{define "audit"}} +
+
+
+

{{.T.audit_log}}

+

{{.T.audit_log_intro}} {{.TargetDossier.Name}}

+
+ ← {{.T.back_to_dossier}} +
+ +
+
+
+
+ {{.T.audit_log}} + {{len .AuditList}} entries +
+
+ +
+ {{range .AuditList}} +
+
+ {{.ActorName}} + {{.Action}} +
+
+ {{.Details}} + +
+
+ {{else}} +
+ No activity recorded yet +
+ {{end}} +
+
+
+ + +{{end}} diff --git a/templates/base.tmpl b/templates/base.tmpl new file mode 100644 index 0000000..ccba68f --- /dev/null +++ b/templates/base.tmpl @@ -0,0 +1,122 @@ + + + + + + inou{{if .Title}} - {{.Title}}{{end}} + + + + + + + {{if .Embed}}{{end}} + + + {{if not .Embed}} + + {{end}} + + {{if eq .Page "landing"}}{{template "landing" .}} + {{else if eq .Page "landing_nl"}}{{template "landing_nl" .}} + {{else if eq .Page "landing_ru"}}{{template "landing_ru" .}} + {{else if eq .Page "landing_de"}}{{template "landing_de" .}} + {{else if eq .Page "landing_fr"}}{{template "landing_fr" .}} + {{else if eq .Page "landing_es"}}{{template "landing_es" .}} + {{else if eq .Page "landing_pt"}}{{template "landing_pt" .}} + {{else if eq .Page "landing_it"}}{{template "landing_it" .}} + {{else if eq .Page "landing_sv"}}{{template "landing_sv" .}} + {{else if eq .Page "landing_no"}}{{template "landing_no" .}} + {{else if eq .Page "landing_da"}}{{template "landing_da" .}} + {{else if eq .Page "landing_fi"}}{{template "landing_fi" .}} + {{else if eq .Page "landing_ja"}}{{template "landing_ja" .}} + {{else if eq .Page "landing_ko"}}{{template "landing_ko" .}} + {{else if eq .Page "landing_zh"}}{{template "landing_zh" .}} + {{else if eq .Page "verify"}}{{template "verify" .}} + {{else if eq .Page "onboard"}}{{template "onboard" .}} + {{else if eq .Page "minor_error"}}{{template "minor_error" .}} + {{else if eq .Page "dashboard"}}{{template "dashboard" .}} + {{else if eq .Page "dossier"}}{{template "dossier" .}} + {{else if eq .Page "add_dossier"}}{{template "add_dossier" .}} + {{else if eq .Page "share"}}{{template "share" .}} + {{else if eq .Page "upload"}}{{template "upload" .}} + {{else if eq .Page "audit"}}{{template "audit" .}} + {{else if eq .Page "connect"}}{{template "connect" .}} + {{else if eq .Page "connect_nl"}}{{template "connect_nl" .}} + {{else if eq .Page "connect_ru"}}{{template "connect_ru" .}} + {{else if eq .Page "invite"}}{{template "invite" .}} + {{else if eq .Page "login"}}{{template "login" .}} + {{else if eq .Page "privacy"}}{{template "privacy" .}} + {{else if eq .Page "security"}}{{template "security" .}} + {{else if eq .Page "dpa"}}{{template "dpa" .}} + {{else if eq .Page "styleguide"}}{{template "styleguide" .}} + {{else if eq .Page "pricing"}}{{template "pricing" .}} + {{else if eq .Page "faq"}}{{template "faq" .}} + {{else if eq .Page "prompts"}}{{template "prompts" .}} + {{else if eq .Page "permissions"}}{{template "permissions" .}} + {{else if eq .Page "edit_access"}}{{template "edit_access" .}} + {{end}} + + + + diff --git a/templates/connect.tmpl b/templates/connect.tmpl new file mode 100644 index 0000000..3d2e6fb --- /dev/null +++ b/templates/connect.tmpl @@ -0,0 +1,286 @@ +{{define "connect"}} +
+ +
+
+

Connect AI to Your Data

+

Choose your AI assistant and follow the setup instructions.

+
+ {{if and .Dossier .Dossier.DossierID}}← Back{{else}}← Home{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.

+ +
+
+ 1 +

Install Claude Desktop

+
+

Download and install from claude.ai/download

+
+ +
+
+ 2 +

Install Inou Extension

+
+

Download inou.mcpb and install:

+
    +
  • Mac: Double-click the file
  • +
  • Windows: In Claude Desktop, go to File → Import Extension and select the downloaded file
  • +
+

When prompted, enter your API token:

+ {{if and .Dossier .Dossier.DossierID}} + {{if .APIToken}} +
+
{{.APIToken}}
+ +
+ {{else}} +
+ +
+ {{end}} + {{else}} +
+
YOUR_API_TOKEN
+
+ {{end}} +

Note: You'll see a permissions warning — this is normal for any extension not yet in Claude's official directory. inou only makes HTTPS calls to inou.com; it does not access local files.

+
+ +
+
+ 3 +

Enable & Allow Permissions

+
+

Go to Settings → Extensions and enable the Inou extension if it's not already on.

+

When first using the extension, Claude will ask permission for each tool. Select "Allow for this chat" and check "Don't ask again for inou-health" to grant all permissions at once — otherwise you'll be prompted 11 times.

+
+ +
+
+ 4 +

Test

+
+

Open Claude Desktop and paste:

+
+
List my available dossiers using the inou bridge.
+ +
+

You should see your dossier(s). You're all set!

+
+ +
+ Manual installation (advanced) +
+
+
+ 1 +

Download Bridge

+
+

Download the bridge for your system:

+ +

Save to ~/bin/inou_bridge (Mac) or %USERPROFILE%\inou\inou_bridge.exe (Windows). Make executable on Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Configure Claude

+
+

Edit Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) and add to mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if .APIToken}}{{.APIToken}}{{else}}YOUR_API_TOKEN{{end}}"]
+}
+ +
+

Replace BRIDGE_PATH with the actual path. Restart Claude Desktop.

+
+
+
+
+ + +
+

Grok can access your health data through our API using HTTP requests. No installation needed.

+ + {{if and .Dossier .Dossier.DossierID}} + {{if .TempToken}} +
+
+ 1 +

Paste this into Grok

+
+
+
Access my health data using the Inou API.
+
+Fetch https://inou.com/api/v1/dossiers?token={{.TempToken}}
+
+Show me the list of dossiers with their details and wait for my instructions.
+
+API docs: https://inou.com/api/docs
+
+IMPORTANT: This is real medical data. NEVER hallucinate. Only describe what you see.
+ +
+

Token expires at {{.TempTokenExpires}}. Refresh page for a new token.

+
+ {{else}} +
+
+ 1 +

Generate API Token

+
+

You need an API token to connect Grok to your data.

+
+ +
+
+ {{end}} + {{else}} + + {{end}} + +
+
+ +

What Grok can do

+
+

Once connected, ask Grok to:

+
    +
  • List all your imaging studies, genome data, and lab results
  • +
  • Show series within a specific study
  • +
  • Fetch and analyze individual slices
  • +
  • Compare images across different sequences (T1, T2, FLAIR)
  • +
  • Navigate to specific anatomical regions
  • +
  • Query genome variants by gene, category, or rsid
  • +
  • Review medication responses and health risks
  • +
  • Track lab values over time
  • +
+
+ +

See the full API documentation for all available endpoints.

+
+ + +
+

Not recommended for medical imaging due to elevated hallucination risk in our testing.

+ +
+
+ +

Why not ChatGPT?

+
+

Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.

+
+ +
+
+ +

Recommended alternatives

+
+

Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.

+
+
+ + +
+

Other AI assistants can access your data through our web API, though capabilities vary.

+ +
+
+ +

Gemini

+
+

Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:

+
    +
  • Copy API responses manually and paste them into Gemini
  • +
  • Use Google AI Studio with function calling
  • +
  • Consider using Claude Desktop or Grok instead
  • +
+
+ +
+
+ +

Build Your Own

+
+

Our API is simple REST + JSON. See the API documentation for endpoints and authentication.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/connect_nl.tmpl b/templates/connect_nl.tmpl new file mode 100644 index 0000000..c7998fc --- /dev/null +++ b/templates/connect_nl.tmpl @@ -0,0 +1,243 @@ +{{define "connect_nl"}} +
+ +
+
+

Verbind AI met je gegevens

+

Kies je AI-assistent en volg de installatie-instructies.

+
+ {{if and .Dossier .Dossier.DossierID}}← Terug{{else}}← Home{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop met MCP biedt de beste ervaring — native tool-toegang, geen URL-fetching, volledige API-mogelijkheden.

+ +
+
+ 1 +

Installeer Claude Desktop

+
+

Download en installeer vanaf claude.ai/download

+
+ +
+
+ 2 +

Installeer de inou-extensie

+
+

Download inou.mcpb en installeer:

+
    +
  • Mac: Dubbelklik op het bestand
  • +
  • Windows: Ga in Claude Desktop naar File → Import Extension en selecteer het gedownloade bestand
  • +
+

Voer je account-token in wanneer daarom gevraagd wordt:

+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}
+ +
+

Let op: Je ziet een machtigingswaarschuwing — dit is normaal voor extensies die nog niet in Claude's officiële directory staan. inou maakt alleen HTTPS-verbindingen met inou.com; het benadert geen lokale bestanden.

+
+ +
+
+ 3 +

Inschakelen & machtigingen toestaan

+
+

Ga naar Settings → Extensions en schakel de inou-extensie in als dat nog niet gedaan is.

+

Bij het eerste gebruik vraagt Claude toestemming voor elke tool. Selecteer "Allow for this chat" en vink "Don't ask again for inou-health" aan om alle machtigingen in één keer toe te staan — anders word je 11 keer gevraagd.

+
+ +
+
+ 4 +

Test

+
+

Open Claude Desktop en plak:

+
+
Toon mijn beschikbare dossiers via de inou-bridge.
+ +
+

Je zou je dossier(s) moeten zien. Klaar!

+
+ +
+ Handmatige installatie (geavanceerd) +
+
+
+ 1 +

Download bridge

+
+

Download de bridge voor jouw systeem:

+ +

Sla op naar ~/bin/inou_bridge (Mac) of %USERPROFILE%\inou\inou_bridge.exe (Windows). Maak uitvoerbaar op Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Configureer Claude

+
+

Bewerk Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) en voeg toe aan mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}"]
+}
+ +
+

Vervang BRIDGE_PATH door het daadwerkelijke pad. Herstart Claude Desktop.

+
+
+
+
+ + +
+

Grok kan via het web je gezondheidsgegevens benaderen via onze API. Geen installatie nodig.

+ +
+

Snel starten

+

Kopieer dit en plak het in Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+
+ +

Wat Grok kan doen

+
+

Eenmaal verbonden kun je Grok vragen om:

+
    +
  • Al je beeldvormingsstudies, genoomgegevens en labresultaten te tonen
  • +
  • Series binnen een specifieke studie te laten zien
  • +
  • Individuele slices op te halen en te analyseren
  • +
  • Beelden te vergelijken tussen verschillende sequenties (T1, T2, FLAIR)
  • +
  • Naar specifieke anatomische regio's te navigeren
  • +
  • Genoomvarianten op te zoeken op gen, categorie of rsid
  • +
  • Medicatiereacties en gezondheidsrisico's te bekijken
  • +
  • Labwaarden in de tijd te volgen
  • +
+
+ +

Zie de volledige API-documentatie voor alle beschikbare endpoints.

+
+ + +
+

Niet aanbevolen voor medische beeldvorming vanwege verhoogd risico op hallucinaties in onze tests.

+ +
+
+ +

Waarom niet ChatGPT?

+
+

Medische beeldvorming vereist absolute nauwkeurigheid. In onze tests verzon ChatGPT informatie, zelfs wanneer correcte gegevens duidelijk werden verstrekt. We kunnen het niet aanbevelen voor het analyseren van gezondheidsgegevens waar fouten echte gevolgen hebben.

+
+ +
+
+ +

Aanbevolen alternatieven

+
+

Gebruik Claude Desktop voor de beste ervaring met native tool-toegang, of Grok voor webtoegang zonder installatie.

+
+
+ + +
+

Andere AI-assistenten kunnen je gegevens benaderen via onze web-API, hoewel mogelijkheden variëren.

+ +
+
+ +

Gemini

+
+

Gemini's webbrowsing is momenteel beperkt en kan mogelijk geen inou.com-URL's direct ophalen. Workarounds:

+
    +
  • Kopieer API-responses handmatig en plak ze in Gemini
  • +
  • Gebruik Google AI Studio met function calling
  • +
  • Overweeg Claude Desktop of Grok in plaats daarvan
  • +
+
+ +
+
+ +

Bouw je eigen

+
+

Onze API is eenvoudige REST + JSON. Zie de API-documentatie voor endpoints en authenticatie.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/connect_ru.tmpl b/templates/connect_ru.tmpl new file mode 100644 index 0000000..4aa72be --- /dev/null +++ b/templates/connect_ru.tmpl @@ -0,0 +1,243 @@ +{{define "connect_ru"}} +
+ +
+
+

Подключите ИИ к вашим данным

+

Выберите вашего ИИ-ассистента и следуйте инструкциям по установке.

+
+ {{if and .Dossier .Dossier.DossierID}}← Назад{{else}}← Главная{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop с MCP обеспечивает лучший опыт — нативный доступ к инструментам, без загрузки URL, полные возможности API.

+ +
+
+ 1 +

Установите Claude Desktop

+
+

Скачайте и установите с claude.ai/download

+
+ +
+
+ 2 +

Установите расширение inou

+
+

Скачайте inou.mcpb и установите:

+
    +
  • Mac: Дважды щёлкните по файлу
  • +
  • Windows: В Claude Desktop перейдите в File → Import Extension и выберите скачанный файл
  • +
+

При запросе введите токен вашей учётной записи:

+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}
+ +
+

Примечание: Вы увидите предупреждение о разрешениях — это нормально для расширений, ещё не включённых в официальный каталог Claude. inou делает только HTTPS-запросы к inou.com; локальные файлы не затрагиваются.

+
+ +
+
+ 3 +

Включите и разрешите доступ

+
+

Перейдите в Settings → Extensions и включите расширение inou, если оно ещё не включено.

+

При первом использовании Claude запросит разрешение для каждого инструмента. Выберите "Allow for this chat" и отметьте "Don't ask again for inou-health", чтобы дать все разрешения сразу — иначе вас спросят 11 раз.

+
+ +
+
+ 4 +

Тест

+
+

Откройте Claude Desktop и вставьте:

+
+
Покажи мои доступные досье через мост inou.
+ +
+

Вы должны увидеть ваше(и) досье. Готово!

+
+ +
+ Ручная установка (продвинутый) +
+
+
+ 1 +

Скачайте мост

+
+

Скачайте мост для вашей системы:

+ +

Сохраните в ~/bin/inou_bridge (Mac) или %USERPROFILE%\inou\inou_bridge.exe (Windows). Сделайте исполняемым на Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Настройте Claude

+
+

Отредактируйте конфигурацию Claude (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) и добавьте в mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}"]
+}
+ +
+

Замените BRIDGE_PATH на фактический путь. Перезапустите Claude Desktop.

+
+
+
+
+ + +
+

Grok может получить доступ к вашим медицинским данным напрямую через наш API. Установка не требуется.

+ +
+

Быстрый старт

+

Скопируйте это и вставьте в Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+
+ +

Что может делать Grok

+
+

После подключения попросите Grok:

+
    +
  • Показать все ваши исследования визуализации, геномные данные и результаты анализов
  • +
  • Показать серии в конкретном исследовании
  • +
  • Загрузить и проанализировать отдельные срезы
  • +
  • Сравнить изображения между разными последовательностями (T1, T2, FLAIR)
  • +
  • Перейти к определённым анатомическим областям
  • +
  • Найти геномные варианты по гену, категории или rsid
  • +
  • Просмотреть реакции на лекарства и риски для здоровья
  • +
  • Отслеживать лабораторные показатели во времени
  • +
+
+ +

См. полную документацию API для всех доступных эндпоинтов.

+
+ + +
+

Не рекомендуется для медицинской визуализации из-за повышенного риска галлюцинаций в наших тестах.

+ +
+
+ +

Почему не ChatGPT?

+
+

Медицинская визуализация требует абсолютной точности. В наших тестах ChatGPT выдумывал информацию, даже когда правильные данные были чётко предоставлены. Мы не можем рекомендовать его для анализа медицинских данных, где ошибки имеют реальные последствия.

+
+ +
+
+ +

Рекомендуемые альтернативы

+
+

Используйте Claude Desktop для лучшего опыта с нативным доступом к инструментам, или Grok для веб-доступа без установки.

+
+
+ + +
+

Другие ИИ-ассистенты могут получить доступ к вашим данным через наш веб-API, хотя возможности различаются.

+ +
+
+ +

Gemini

+
+

Веб-браузинг Gemini в настоящее время ограничен и может не загружать URL-адреса inou.com напрямую. Обходные пути:

+
    +
  • Скопируйте ответы API вручную и вставьте их в Gemini
  • +
  • Используйте Google AI Studio с вызовом функций
  • +
  • Рассмотрите вместо этого Claude Desktop или Grok
  • +
+
+ +
+
+ +

Создайте своё

+
+

Наш API — простой REST + JSON. См. документацию API для эндпоинтов и аутентификации.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/dashboard.tmpl b/templates/dashboard.tmpl new file mode 100644 index 0000000..fa61703 --- /dev/null +++ b/templates/dashboard.tmpl @@ -0,0 +1,67 @@ +{{define "dashboard"}} +
+

{{.T.dossiers}}

+

{{.T.dossiers_intro}}

+ +
+ + + + + {{range .AccessibleDossiers}} + + {{end}} + + + + + + {{.T.add_dossier}} + +
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/dossier.tmpl b/templates/dossier.tmpl new file mode 100644 index 0000000..0fb6ee5 --- /dev/null +++ b/templates/dossier.tmpl @@ -0,0 +1,752 @@ +{{define "dossier"}} + +
+
+
+

{{.TargetDossier.Name}}

+ {{if .ShowDetails}} +

+ {{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}} + {{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}} +

+ {{end}} +
+ ← {{.T.back_to_dossiers}} +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + + +
+
+
+
+ {{.T.section_imaging}} + {{if .Studies}} + + {{else}} + {{.T.no_imaging}} + {{end}} +
+ {{if .HasImaging}} + {{.T.open_viewer}} + {{end}} +
+ + {{if .Studies}} +
+ {{range $i, $s := .Studies}} + {{if eq $s.SeriesCount 1}} +
+
+ {{$s.Description}} +
+
+ + +
+
+ {{else}} + +
+ {{range $s.Series}}{{if gt .SliceCount 0}} +
+ {{if .Description}}{{.Description}}{{else}}{{.Modality}}{{end}} + + +
+ {{end}}{{end}} +
+ {{end}} + {{end}} + {{if gt .StudyCount 5}} +
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_labs}} + {{if .Labs}} + {{len .Labs}} results + {{else}} + {{.T.no_lab_data}} + {{end}} +
+
+ {{if .Labs}} +
+ {{range .Labs}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_records}} + {{len .Documents}} documents +
+
+ {{if .Documents}} +
+ {{range .Documents}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Type}}{{.Type}}{{end}} + {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Procedures & Surgery + {{len .Procedures}} procedures +
+
+ {{if .Procedures}} +
+ {{range .Procedures}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Clinical Assessments + {{len .Assessments}} assessments +
+
+ {{if .Assessments}} +
+ {{range .Assessments}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_genetics}} + Loading... +
+ +
+
+
+ + + + + +
+
+
+
+ {{.T.section_uploads}} + + {{if .Uploads}}{{else}}{{.T.no_files}}{{end}} + +
+ {{if .CanEdit}}{{.T.manage}}{{else}}{{.T.manage}}{{end}} +
+
+ + +
+
+
+
+ {{.T.section_medications}} + {{len .Medications}} medications +
+
+ {{if .Medications}} +
+ {{range .Medications}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Symptoms + {{len .Symptoms}} symptoms +
+
+ {{if .Symptoms}} +
+ {{range .Symptoms}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Hospitalizations + {{len .Hospitalizations}} hospitalizations +
+
+ {{if .Hospitalizations}} +
+ {{range .Hospitalizations}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Therapies + {{len .Therapies}} therapies +
+
+ {{if .Therapies}} +
+ {{range .Therapies}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_vitals}} + {{.T.vitals_desc}} +
+ {{.T.coming_soon}} +
+
+ + +
+
+
+
+ {{.T.section_privacy}} + {{len .AccessList}} {{.T.people_with_access_count}} +
+
+ +
+ {{range .AccessList}} +
+
+ {{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}} + {{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}} +
+ {{if and $.CanManageAccess (not .IsSelf)}} +
+ Edit +
+ + +
+
+ {{end}} +
+ {{end}} + {{if not .AccessList}} +
+ {{.T.no_access_yet}} +
+ {{end}} + + +
+ {{.T.share_access}} + {{if .CanManageAccess}}{{.T.manage_permissions}}{{end}} + {{.T.view_audit_log}} + {{if or (eq .Dossier.DossierID .TargetDossier.DossierID) .CanManageAccess}}{{.T.export_data}}{{end}} +
+
+
+ + {{template "footer"}} +
+ + +{{end}} diff --git a/templates/dpa.tmpl b/templates/dpa.tmpl new file mode 100644 index 0000000..aa45c94 --- /dev/null +++ b/templates/dpa.tmpl @@ -0,0 +1,248 @@ +{{define "dpa"}} + + +
+ +
+

Data Processing Agreement

+

This agreement describes how inou processes your health data. It applies to all users and any third-party services that access your data through our platform.

+
+ +
+

Definitions

+ +

Data Controller.

+

You. You decide what data to upload, who can access it, and when to delete it.

+ +

Data Processor.

+

inou. We store, encrypt, and transmit your data according to your instructions.

+ +

Sub-processors.

+

Third-party services you explicitly connect to your account, such as AI assistants. We do not use sub-processors for storage or core functionality.

+
+ +
+

Data we process

+ +

Health data.

+

Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.

+ +

Account data.

+

Name, email address, date of birth, and sex. Used for account management and medical context.

+ +

Technical data.

+

IP addresses and session identifiers. Used exclusively for security and access control.

+
+ +
+

How we process it

+ +

Storage.

+

All health data is encrypted using FIPS 140-3 validated cryptography before storage. Data resides on dedicated infrastructure in the United States that we own and operate.

+ +

Transmission.

+

All data in transit is protected by TLS 1.3 encryption. When you connect third-party services, data travels through an encrypted bridge directly to your session.

+ +

Access.

+

Only you and accounts you explicitly authorize can access your data. Staff access requires your explicit request, is restricted to senior personnel, and is logged.

+
+ +
+

Processing restrictions

+ +

We process your data solely to provide the service. Specifically, we do not:

+
    +
  • Use your data for AI model training
  • +
  • Sell, rent, or share your data with third parties
  • +
  • Analyze your data for advertising or profiling
  • +
  • Access your data without your explicit request
  • +
  • Retain your data after account deletion
  • +
+
+ +
+

Third-party connections

+ +

When you connect an AI assistant or other service to inou:

+
    +
  • You explicitly authorize each connection
  • +
  • Data is transmitted only for your active session
  • +
  • We do not store copies of transmitted data
  • +
  • You can revoke access at any time
  • +
  • Each third party operates under their own privacy policy
  • +
+

We recommend reviewing the privacy policy of any service you connect.

+
+ +
+

Security measures

+ +

Encryption.

+

FIPS 140-3 validated encryption at rest. TLS 1.3 encryption in transit. Application-layer encryption before database storage.

+ +

Infrastructure.

+

Dedicated hardware. No shared cloud environments. Redundant storage with RAID-Z2. Uninterruptible power with generator backup.

+ +

Access control.

+

Role-based access control. Mandatory authentication. All access logged and auditable.

+ +

Monitoring.

+

Continuous automated monitoring. Intrusion detection. Regular security assessments.

+
+ +
+

Data retention

+ +

We retain your data for as long as your account is active. When you delete your account:

+
    +
  • All personal data is permanently destroyed
  • +
  • All health data is permanently destroyed
  • +
  • Deletion is immediate and irreversible
  • +
  • Backups are overwritten within 30 days
  • +
+

We do not offer recovery of deleted data.

+
+ +
+

Your rights

+ +

Access.

+

See and export everything we store — data you've entered, account details, access logs, and audit history.

+ +

Rectification.

+

Correct any inaccurate data directly or by request.

+ +

Erasure.

+

Delete your account and all associated data instantly.

+ +

Portability.

+

Download data you've entered in standard formats. Your uploaded files are already yours.

+ +

Objection.

+

Revoke any permission at any time. We comply immediately.

+
+ +
+

Compliance

+ +

This agreement is designed to comply with:

+
    +
  • GDPR (European Union General Data Protection Regulation)
  • +
  • FADP (Swiss Federal Act on Data Protection)
  • +
  • HIPAA (US Health Insurance Portability and Accountability Act)
  • +
+

We apply the highest standard regardless of your jurisdiction.

+
+ +
+

Contact

+

Questions about data processing: privacy@inou.com

+

This agreement was last updated on January 21, 2026.

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/edit_access.tmpl b/templates/edit_access.tmpl new file mode 100644 index 0000000..7c66053 --- /dev/null +++ b/templates/edit_access.tmpl @@ -0,0 +1,89 @@ +{{define "edit_access"}} +
+
+
+
+
+

Edit access

+

{{.GranteeName}}'s access to {{.TargetDossier.Name}}

+
+ {{.T.back}} +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + +
+ + + +
+

Permissions

+
+ + + + +
+
+ + +
+

Data access

+

Select which types of data this person can access. Unchecked categories will be hidden.

+ +
+ {{range .Categories}} + + {{end}} +
+
+ + + {{if .EntryGrants}} +
+

Specific items shared

+ {{range .EntryGrants}} +
+
+ {{.Description}} + {{.CategoryName}} +
+ +
+ {{end}} +
+ {{end}} + +
+ {{.T.cancel}} + +
+
+ + +
+
+ + +
+
+
+
+
+{{end}} diff --git a/templates/faq.tmpl b/templates/faq.tmpl new file mode 100644 index 0000000..4678daa --- /dev/null +++ b/templates/faq.tmpl @@ -0,0 +1,1097 @@ +{{define "faq"}} + + +
+
+
+

Frequently Asked Questions

+

Everything you need to know about inou health

+
+
+ + +
+
+
+
+ Pricing & Plans + 7 questions +
+
+
+ +
+
+

Monitor (Free)

+
    +
  • Track vitals, period/fertility, exercise, symptoms, and food
  • +
  • Text and voice entry
  • +
  • Up to 4 dossiers (family members)
  • +
  • 100MB storage
  • +
  • MCP integration with limited AI insights
  • +
  • Perfect for basic health tracking
  • +
+

Optimize ($12/month or $120/year)

+
    +
  • Everything in Monitor, plus:
  • +
  • Photo uploads with OCR
  • +
  • Supplements & medications tracking
  • +
  • Family history
  • +
  • Lab results tracking
  • +
  • Consumer genome analysis (~160 curated variants covering pharmacogenomics, disease risks, methylation, athletic performance, nutrition, personality traits, and physical traits)
  • +
  • Full AI insights, trend analysis, predictions, and health correlations
  • +
  • 1GB storage
  • +
  • Up to 4 dossiers
  • +
+

Research ($35/month or $350/year)

+
    +
  • Everything in Optimize, plus:
  • +
  • Medical imaging (MRI, CT, X-ray scans)
  • +
  • Complete genome analysis (all 5,000+ variants from SNPedia)
  • +
  • Clinical genome sequencing support
  • +
  • Browse and search any genetic variant
  • +
  • 100GB storage (imaging files are large)
  • +
  • Up to 4 dossiers
  • +
+
+
+ + +
+
+

Annual plans are priced at 10 months - you get 2 months free:

+
    +
  • Optimize: $120/year instead of $144 (save $24)
  • +
  • Research: $350/year instead of $420 (save $70)
  • +
+
+
+ + +
+
+

inou is in active development. If you sign up now:

+
    +
  • No charges until July 1, 2026 - use any paid tier completely free
  • +
  • No auto-renewal on July 1, 2026 - we'll ask if you want to continue
  • +
  • No credit card required during early access - just sign up and start using it
  • +
  • Choose to continue with a paid plan or stay on the free Monitor tier after July 1st
  • +
+

This gives you 6+ months to try inou with full access to Optimize or Research features before deciding if you want to pay.

+
+
+ + +
+
+

Yes, you can change your plan at any time:

+
    +
  • Upgrade: Takes effect immediately, you get access to new features right away
  • +
  • Downgrade: Takes effect at the end of your current billing period
  • +
  • Data preservation: Your data is never deleted when you downgrade - features just become read-only until you upgrade again
  • +
+

For example, if you downgrade from Optimize to Monitor, your lab results and genome data remain stored, but you'll lose AI analysis features until you upgrade again.

+
+
+ + +
+
+
    +
  • Monitor (100MB): You'll receive a notification when you reach 80% and 95% of your limit. At 100%, you can't add new data until you upgrade or delete old entries.
  • +
  • Optimize (1GB): Same notification system. 1GB covers approximately 1,000 lab PDFs or 50 consumer genome files.
  • +
  • Research (100GB): Designed for medical imaging. 100GB covers roughly 200-400 MRI/CT studies depending on series count.
  • +
+

We don't charge overage fees. If you need more storage, you'll need to upgrade to the next tier or manage your existing data.

+
+
+ + +
+
+

Yes! All tiers include up to 4 dossiers. A "dossier" is an individual health profile. This means you can:

+
    +
  • Track your own health plus 3 family members (spouse, children, parents)
  • +
  • Manage your child's medical records
  • +
  • Help an elderly parent track their medications and appointments
  • +
  • Keep separate profiles for complex multi-person health situations
  • +
+

Each dossier has its own data, permissions, and privacy settings. Family members can have their own login access or you can manage everything from your account.

+
+
+
+
+ + +
+
+
+
+ Features & Capabilities + 7 questions +
+
+
+ +
+
+

All tiers:

+
    +
  • Vitals: blood pressure, heart rate, weight, temperature, oxygen saturation
  • +
  • Period/fertility: cycle dates, flow, symptoms, basal body temperature, cervical mucus
  • +
  • Exercise: type, duration, intensity, routes, performance metrics
  • +
  • Symptoms: headaches, pain, fatigue, digestive issues, mood, sleep quality
  • +
  • Food: meal logging via text, voice, or photo
  • +
+

Optimize & Research add:

+
    +
  • Supplements & medications: dosage, timing, refills, interactions
  • +
  • Family history: genetic conditions, disease patterns
  • +
  • Lab results: blood tests, panels, biomarkers with trend tracking
  • +
  • Consumer genome: 23andMe, AncestryDNA, or similar test results
  • +
+

Research tier adds:

+
    +
  • Medical imaging: MRI, CT, X-ray scans (DICOM format)
  • +
  • Clinical genome sequencing: whole exome, whole genome, gene panels
  • +
+
+
+ + +
+
+

This is what makes inou different.

+

A single MRI can contain thousands of images across multiple sequences (T1, T2, FLAIR, etc.). Uploading all of them to an AI chat is impractical - you'd hit context limits and waste time selecting slices manually.

+

inou solves this:

+
    +
  • Upload your entire scan once (we handle DICOM natively)
  • +
  • Your AI sees the study metadata - what sequences exist, how many slices, anatomical positions
  • +
  • When analyzing, the AI requests exactly the slices it needs
  • +
  • It can pull specific images on demand: "show me axial T2 slice 45" or "compare sagittal FLAIR at L4-L5"
  • +
+

The AI intelligently navigates your imaging library without you having to manually find and upload individual images. Ask about a herniated disc, and it fetches the relevant spine sequences. Ask about a brain lesion, and it pulls the right FLAIR slices.

+

Example: "Look at my May 2024 lumbar MRI and tell me if there's any change compared to my January 2023 scan" - the AI fetches and compares the relevant slices from both studies automatically.

+
+
+ + +
+
+

For Optimize ($12/mo):

+

Upload your raw data file from 23andMe, AncestryDNA, or similar consumer tests. inou analyzes ~160 carefully curated, high-confidence genetic variants:

+
    +
  • Pharmacogenomics (40 variants): Which medications work best for your genetics (statins, blood thinners, antidepressants, pain meds)
  • +
  • Disease risk (30 variants): BRCA1/2, APOE (Alzheimer's), Lynch syndrome, familial hypercholesterolemia, clotting disorders
  • +
  • Methylation & detox (10 variants): MTHFR, CBS, MTR - affects folate metabolism, B12 needs, homocysteine
  • +
  • Athletic performance (20 variants): Muscle fiber type, VO2max potential, injury risk, recovery speed
  • +
  • Nutrition (20 variants): Lactose intolerance, caffeine metabolism, alcohol flush, vitamin needs
  • +
  • Personality & traits (20 variants): COMT (stress response), sleep chronotype, pain sensitivity
  • +
  • Physical traits (20 variants): Eye color, hair traits, earwax type, bitter taste
  • +
+

For Research ($35/mo):

+

Everything above PLUS access to all 5,000+ variants analyzed by SNPedia, including:

+
    +
  • Rare disease variants
  • +
  • Uncertain/early research findings
  • +
  • Polygenic risk scores for 50+ conditions
  • +
  • Carrier status for 200+ recessive conditions
  • +
  • Search any rsID or gene
  • +
  • Clinical genome sequencing data (WES/WGS)
  • +
+

Privacy control: You choose whether to show protective variants (good news), risk variants (bad news), or both. Some people want comprehensive information; others prefer not to see risks they can't control.

+
+
+ + +
+
+

inou connects AI assistants like Claude and Grok directly to your health data. Instead of manually copying and pasting information, your AI can:

+
    +
  • Read your complete medical history
  • +
  • Analyze trends across vitals, labs, symptoms
  • +
  • Correlate genome data with medication responses
  • +
  • Answer questions using YOUR specific health data
  • +
+

How it works:

+
    +
  1. Connect your AI to inou (Claude via MCP bridge, Grok via API)
  2. +
  3. Ask health questions naturally: "Why am I having headaches?" or "Is this medication safe for my genome?"
  4. +
  5. AI sees your relevant data and gives personalized answers
  6. +
  7. Data never leaves inou permanently - AI queries it in real-time
  8. +
+

Monitor tier: MCP works, but AI has limited data (no labs/genome), so insights are basic

+

Optimize/Research: Full AI capabilities with complete health context

+
+
+ + +
+
+

Currently supported:

+
    +
  • Lab results: PDF upload with OCR
  • +
  • Genome: 23andMe, AncestryDNA raw data files
  • +
  • Medical imaging: DICOM files from radiology
  • +
  • Photos: Medication bottles, food, health documents
  • +
+

Coming soon:

+
    +
  • Apple Health / HealthKit integration
  • +
  • Google Fit integration
  • +
  • Wearable devices (Garmin, Oura, Whoop)
  • +
  • MyChart / Epic integration
  • +
  • Laboratory portal direct imports
  • +
+

You can also enter data manually via text or voice for anything not yet automated.

+
+
+ + +
+
+

No. inou is a tool for organizing your health data and enabling AI to help you understand it. It is NOT:

+
    +
  • A diagnostic tool
  • +
  • A replacement for medical advice
  • +
  • A treatment recommendation system
  • +
  • A prescription service
  • +
+

inou helps you:

+
    +
  • Track your health comprehensively
  • +
  • Understand patterns and trends
  • +
  • Communicate better with your doctor (export reports for appointments)
  • +
  • Research your conditions using AI with your personal context
  • +
  • Manage medications and symptoms
  • +
+

Always consult your healthcare provider for medical decisions. Think of inou as your health data infrastructure - it makes you a more informed patient, but your doctor makes the clinical calls.

+
+
+
+
+ + +
+
+
+
+ Free Period & Early Access + 5 questions +
+
+
+ +
+
+

We're in active development and want early users to:

+
    +
  1. Test features and give feedback while we refine the product
  2. +
  3. Build their health data without worrying about costs
  4. +
  5. Experience full value before committing to a subscription
  6. +
  7. Help us improve by reporting bugs and suggesting features
  8. +
+

Early adopters are incredibly valuable. This free period is our way of saying thank you for being part of the journey.

+
+
+ + +
+
+

We'll contact you before July 1st to ask if you want to continue:

+
    +
  • Continue with paid tier: Subscribe at the current pricing (prices locked for early users)
  • +
  • Downgrade to Monitor (Free): Keep using basic features forever at no cost
  • +
  • Export and leave: Download all your data and cancel
  • +
+

Important: Your subscription will NOT auto-renew. We will never charge you without explicit confirmation. No surprises, no sneaky billing.

+
+
+ + +
+
+

For early users who sign up during the free period:

+
    +
  • Pricing locked: If you subscribe after July 1st, you'll pay 2026 prices even if we raise them later
  • +
  • Grandfathered forever: As long as you maintain continuous subscription, your rate never increases
  • +
  • Example: If you subscribe at $12/mo in July 2026 and we raise prices to $15/mo in 2027, you still pay $12/mo
  • +
+

New users after July 2026 will pay whatever the current pricing is at that time.

+
+
+ + +
+
+

No - build one comprehensive dossier with as much information as possible. The more data your AI has access to, the better insights it can provide.

+

You can create multiple dossiers under different email addresses, but there's no benefit to splitting your health data. Keep everything in one place for the best AI experience.

+

Use separate dossiers for family members, not for yourself.

+
+
+
+
+ + +
+
+
+
+ Payment & Billing + 5 questions +
+
+
+ +
+
+
    +
  • Credit cards (Visa, Mastercard, American Express, Discover)
  • +
  • Debit cards
  • +
  • Digital wallets (Apple Pay, Google Pay)
  • +
  • ACH bank transfers (annual plans only)
  • +
+

Coming soon: PayPal, Venmo

+
+
+ + +
+
+

No. Payment processing is handled by Stripe, a certified PCI Service Provider Level 1 (the highest security standard). We never see or store your credit card details. Stripe handles all payment security.

+
+
+ + +
+
+

Monthly plans:

+
    +
  • First charge: July 1, 2026 (or later if you sign up after that date)
  • +
  • Recurring: Same day each month (if you subscribe on July 15th, you're billed the 15th of each month)
  • +
  • Prorated: If you upgrade mid-cycle, you're charged the prorated difference immediately
  • +
+

Annual plans:

+
    +
  • First charge: July 1, 2026 (or later)
  • +
  • Recurring: Same date each year
  • +
  • No mid-year charges unless you upgrade tiers
  • +
+
+
+ + +
+
+

30-day money-back guarantee:

+

If you subscribe after July 1, 2026 and aren't satisfied, request a full refund within 30 days. No questions asked.

+

Free period users:

+

Since you used the service free for months before subscribing, refunds aren't available after the 30-day guarantee expires. You can always cancel to avoid future charges.

+
+
+ + +
+
+
    +
  1. Day 1: Automatic retry
  2. +
  3. Day 3: Email notification + retry
  4. +
  5. Day 7: Final retry + account locked (read-only access)
  6. +
  7. Day 14: Account suspended (no access until payment resolves)
  8. +
  9. Day 30: Account scheduled for deletion
  10. +
+

Your data is never deleted before 30 days, and we'll send multiple notifications. Update your payment method anytime to restore access immediately.

+
+
+
+
+ + +
+
+
+
+ Cancellation & Data + 4 questions +
+
+
+ +
+
+
    +
  1. Go to Account Settings
  2. +
  3. Click "Subscription" tab
  4. +
  5. Click "Cancel Subscription"
  6. +
  7. Confirm cancellation
  8. +
+

Takes effect:

+
    +
  • End of current billing period (you keep access until then)
  • +
  • Immediate downgrade to Monitor (Free) tier
  • +
  • No further charges
  • +
+

Your data:

+
    +
  • Never deleted
  • +
  • Remains stored and accessible in read-only mode
  • +
  • Full access restored if you resubscribe
  • +
+
+
+ + +
+
+

Yes, anytime. Just:

+
    +
  1. Go to Account Settings
  2. +
  3. Click "Upgrade"
  4. +
  5. Choose your plan and enter payment
  6. +
+

Your data is still there - you'll have immediate access to everything again.

+
+
+ + +
+
+

Account deletion is permanent and immediate:

+
    +
  1. Go to Account Settings
  2. +
  3. Click "Delete Account"
  4. +
  5. Confirm deletion (requires typing "DELETE" to confirm)
  6. +
  7. All data is permanently destroyed within 24 hours
  8. +
+

Before deleting:

+
    +
  • Export your data (we provide standard formats)
  • +
  • Download any reports or documents you want to keep
  • +
  • Consider canceling instead (keeps your data for future use)
  • +
+

After deletion:

+
    +
  • Cannot be undone
  • +
  • Cannot recover any data
  • +
  • Backups purged within 30 days
  • +
+
+
+ + +
+
+

Yes, anytime. Export formats:

+
    +
  • Structured data: JSON, CSV
  • +
  • Labs: PDF copies of original uploads
  • +
  • Genome: Original raw data file
  • +
  • Imaging: DICOM files
  • +
  • Reports: PDF summaries with charts and trends
  • +
+

Export includes everything: vitals, symptoms, medications, labs, genome, imaging, notes - your complete health record.

+

Use cases:

+
    +
  • Switching to another service
  • +
  • Sharing with healthcare providers
  • +
  • Personal backup
  • +
  • Research or analysis
  • +
+

You own your data. We just store it for you.

+
+
+
+
+ + +
+
+
+
+ Privacy & Security + 9 questions +
+
+
+ +
+
+

United States-based servers using enterprise-grade infrastructure:

+
    +
  • Not on Big Tech clouds (no Google, Amazon, Microsoft)
  • +
  • Independent data centers with physical security
  • +
  • Redundant backups across multiple locations
  • +
  • HIPAA-compliant infrastructure
  • +
+

If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.

+
+
+ + +
+
+

FIPS 140-3 encryption (US government standard):

+
    +
  • At rest: All files encrypted using FIPS 140-3 validated cryptography
  • +
  • In transit: TLS 1.3 encryption for all connections
  • +
  • Backups: Encrypted with separate keys
  • +
  • Key management: Hardware security modules (HSMs)
  • +
+

This is the same encryption standard used by banks, hospitals, and government agencies.

+
+
+ + +
+
+

Only you. We never:

+
    +
  • Share data with advertisers
  • +
  • Sell data to third parties
  • +
  • Use data to train AI models
  • +
  • Mine data for research without explicit consent
  • +
  • Provide data to partners or affiliates
  • +
+

Law enforcement:

+

We comply with lawful requests (court orders, subpoenas) but nothing else. If served with a valid legal demand, we must provide requested data. We will notify you unless legally prohibited.

+

Your AI:

+

When you connect AI via MCP, your data is transmitted through an encrypted bridge to your AI session. The AI processes it in real-time but doesn't store it permanently. Check your AI provider's privacy policy for their data handling practices.

+
+
+ + +
+
+

No, except:

+
    +
  • You explicitly request support that requires data access
  • +
  • Legal obligations (court order)
  • +
  • Critical security incident investigation
  • +
+

When access is granted:

+
    +
  • Restricted to senior staff only
  • +
  • Logged in audit trail (visible to you in Account Settings)
  • +
  • Time-limited (access expires after 24 hours)
  • +
  • You're notified when access occurs
  • +
+

Random employees, contractors, or developers never have access to your health data.

+
+
+ + +
+
+

Never. Your data is:

+
    +
  • Not used to train machine learning models
  • +
  • Not used to improve AI assistants
  • +
  • Not used for research or development
  • +
  • Not anonymized and aggregated for analysis
  • +
+

If we ever want to use anonymized, aggregated data for research, we will:

+
    +
  1. Ask for explicit opt-in consent
  2. +
  3. Explain exactly what we're studying
  4. +
  5. Provide the ability to opt out anytime
  6. +
  7. Never share identifiable data
  8. +
+
+
+ + +
+
+

None. We don't use:

+
    +
  • Google Analytics
  • +
  • Meta pixels
  • +
  • Tracking scripts
  • +
  • Third-party cookies
  • +
  • Advertising networks
  • +
+

What we do track:

+
    +
  • One cookie for login session
  • +
  • IP addresses for security logs only
  • +
  • Error logs for debugging (no personal data)
  • +
+

We have no idea what you click, where you came from, or where you go next.

+
+
+ + +
+
+

Yes. We follow HIPAA standards:

+
    +
  • Business Associate Agreements available for covered entities
  • +
  • Administrative, physical, and technical safeguards
  • +
  • Breach notification procedures
  • +
  • Audit controls and access logs
  • +
  • Encrypted storage and transmission
  • +
+

We also comply with:

+
    +
  • GDPR (European data protection)
  • +
  • FADP (Swiss data protection)
  • +
  • CCPA (California consumer privacy)
  • +
+

Regardless of where you live, you get our highest level of privacy protection.

+
+
+ + +
+
+

Users under 18:

+
    +
  • Cannot create accounts independently
  • +
  • Require parent/guardian authorization
  • +
  • Parent/guardian maintains full control
  • +
  • Can be revoked anytime
  • +
+

Parents/guardians can:

+
    +
  • Create dossiers for children
  • +
  • Manage all data and access
  • +
  • Control sharing and AI integration
  • +
  • Delete child's data anytime
  • +
+

Minors cannot share their data with third parties or connect AI without parental consent.

+
+
+
+
+ + +
+
+
+
+ Technical & Support + 6 questions +
+
+
+ +
+
+

Web browser (all tiers):

+
    +
  • Chrome, Firefox, Safari, Edge
  • +
  • Desktop and mobile browsers
  • +
  • Responsive design for any screen size
  • +
+

Mobile apps (coming Q2 2026):

+
    +
  • iOS (iPhone, iPad)
  • +
  • Android
  • +
  • Native apps with full feature parity
  • +
+

Desktop apps (planned):

+
    +
  • macOS
  • +
  • Windows
  • +
+
+
+ + +
+
+

Currently supported:

+
    +
  • Claude (Anthropic) - via MCP bridge (.mcpb file)
  • +
  • Grok (xAI) - via direct API
  • +
+

We're actively adding more integrations. See inou.com/connect for the latest list.

+
+
+ + +
+
+

Report it! We want to know:

+
    +
  • Email: support@inou.com
  • +
  • In-app: Help → Report Bug
  • +
  • Include screenshots if possible
  • +
+

Early users who report bugs help us build a better product. We fix critical issues within 24 hours and minor issues within a week.

+
+
+ + +
+
+

Yes:

+
    +
  • Email support: support@inou.com (24-48 hour response)
  • +
  • This FAQ page
  • +
  • Setup guides at inou.com/connect
  • +
+
+
+ + +
+
+

Absolutely! Email features@inou.com or use the in-app feedback tool. We maintain a public roadmap and regularly implement user suggestions.

+

Early users have significant influence on product direction - if enough people want a feature, we prioritize it.

+
+
+ + +
+
+

We commit to:

+
    +
  • 90 days advance notice
  • +
  • Export tools for all data
  • +
  • Recommendations for alternative services
  • +
  • Option to self-host your data
  • +
+

If acquired, your data either:

+
    +
  • Transfers under the same privacy terms, OR
  • +
  • You're given the option to export and delete
  • +
+

We will never sell user data as part of an acquisition. It's your data, not our asset.

+
+
+
+
+ + +
+
+
+
+ Getting Started + 5 questions +
+
+
+ +
+
+
    +
  1. Go to inou.com
  2. +
  3. Enter your email address
  4. +
  5. Click the verification link sent to your inbox
  6. +
  7. Create your first dossier
  8. +
  9. Start tracking!
  10. +
+

No passwords to remember - we use secure email verification.

+

No credit card required during the free period.

+
+
+ + +
+
+

Quick wins:

+
    +
  1. Current medications - Get AI drug interaction checking
  2. +
  3. Recent lab results - Upload last bloodwork PDF
  4. +
  5. Vitals baseline - Blood pressure, weight, resting heart rate
  6. +
  7. Symptoms if any - Helps establish patterns
  8. +
+

Over time:

+
    +
  1. Upload genome data if you have it (23andMe, Ancestry)
  2. +
  3. Track period/exercise/food as relevant
  4. +
  5. Add family history for context
  6. +
  7. Upload medical imaging for complex conditions
  8. +
+

Start simple - you can always add more later.

+
+
+ + +
+
+

Instantly. Upload takes about 30 seconds (file is ~20MB), then analysis completes in seconds. Results appear immediately - no waiting, no email notification needed.

+
+
+ + +
+
+

Yes, but it depends on format:

+
    +
  • PDFs: Upload directly (labs, imaging reports, doctor notes)
  • +
  • Paper records: Take photos, upload via mobile app
  • +
  • CDs from radiology: DICOM files work natively
  • +
  • Portals (MyChart, etc.): Manual download, then upload
  • +
+

We're working on direct integrations with Epic, Cerner, and other EHR systems.

+
+
+ + +
+
+

Claude Desktop:

+
    +
  1. Download the inou MCP bridge from your dashboard
  2. +
  3. Double-click the .mcpb file to install
  4. +
  5. Restart Claude Desktop
  6. +
  7. Ask Claude health questions - it now has access to your dossier!
  8. +
+

Grok:

+

Grok connects directly via API. Go to your dashboard, generate an API token, and add it to Grok's settings.

+

Detailed setup guides: inou.com/connect

+
+
+
+
+ + +
+
+

Still Have Questions?

+

We're a small team building something we believe in. If you have questions, ideas, or feedback - we want to hear it.

+

+ Email us: support@inou.com +

+
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/footer.tmpl b/templates/footer.tmpl new file mode 100644 index 0000000..62c0d1e --- /dev/null +++ b/templates/footer.tmpl @@ -0,0 +1,10 @@ +{{define "footer"}} + +{{end}} diff --git a/templates/input.tmpl b/templates/input.tmpl new file mode 100644 index 0000000..6e29e49 --- /dev/null +++ b/templates/input.tmpl @@ -0,0 +1,230 @@ +{{define "input"}} + + + + + + + + Add Health Data — inou + + + + + + + +
+ +
+

What's on your mind?

+

Add symptoms, notes, or scan documents

+
+ + +
+ + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

Tap to start speaking

+
+

+

+
+ +
+
+ + +
+
+ +
+ +

Scan medication barcodes, lab results, or medical documents

+
+ + + + + + + + + + + + + + + +
+
+
+ + +
+ +
+ + + +
+ + + + +{{end}} diff --git a/templates/install_public.tmpl b/templates/install_public.tmpl new file mode 100644 index 0000000..3d6d85a --- /dev/null +++ b/templates/install_public.tmpl @@ -0,0 +1,235 @@ +{{define "install_public"}} + + +
+
+
+

Connect AI to Your Data

+

Choose your AI assistant and follow the setup instructions.

+
+ ← Home +
+ + + +
+ + + + +
+ + +
+

Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.

+ +
+ 1 +

Install Claude Desktop

+

Download and install from claude.ai/download

+
+ +
+ 2 +

Install Desktop Commander

+

Open Claude Desktop and paste:

+
+
Please install Desktop Commander MCP server so you can help me with file operations.
+ +
+

Claude will guide you through the installation. Restart Claude when done.

+
+ +
+ 3 +

Install Inou Bridge

+

After restarting, paste this in Claude:

+
+
Please set up the Inou medical imaging bridge:
+
+1. Detect my OS and architecture
+
+2. Download the correct bridge:
+   - Mac Apple Silicon: https://inou.com/download/inou_bridge_darwin_arm64
+   - Mac Intel: https://inou.com/download/inou_bridge_darwin_amd64
+   - Windows 64-bit: https://inou.com/download/inou_bridge_win_amd64.exe
+
+3. Save it to:
+   - Mac: ~/bin/inou_bridge (create ~/bin if needed, make executable)
+   - Windows: %USERPROFILE%\inou\inou_bridge.exe (create folder if needed)
+
+4. Edit Claude config:
+   - Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
+   - Windows: %APPDATA%\Claude\claude_desktop_config.json
+
+5. Add to mcpServers (keep any existing entries like desktop-commander):
+
+"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account=YOUR_ACCOUNT_TOKEN"]
+}
+
+Replace BRIDGE_PATH with the actual path where you saved the bridge.
+Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+
+Tell me when done.
+ +
+
+ +
+ 4 +

Restart & Test

+

Quit Claude Desktop completely, reopen, then paste:

+
+
List my available dossiers using the inou bridge, then show imaging studies for the first one.
+ +
+

You should see your dossier(s) and any imaging studies.

+
+
+ + +
+

Grok can browse the web and access your health data directly through our API. No installation needed.

+ +
+

Quick Start

+

Copy this and paste it into Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token=YOUR_ACCOUNT_TOKEN to list my dossiers.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+ +

What Grok can do

+

Once connected, ask Grok to:

+
    +
  • List all your imaging studies, genome data, and lab results
  • +
  • Show series within a specific study
  • +
  • Fetch and analyze individual slices
  • +
  • Compare images across different sequences (T1, T2, FLAIR)
  • +
  • Navigate to specific anatomical regions
  • +
  • Query genome variants by gene, category, or rsid
  • +
  • Review medication responses and health risks
  • +
  • Track lab values over time
  • +
+
+ +

See the full API documentation for all available endpoints.

+
+ + +
+

Not recommended for medical imaging due to elevated hallucination risk in our testing.

+ +
+ +

Why not ChatGPT?

+

Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.

+
+ +
+ +

Recommended alternatives

+

Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.

+
+
+ + +
+

Other AI assistants can access your data through our web API, though capabilities vary.

+ +
+ +

Gemini

+

Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:

+
    +
  • Copy API responses manually and paste them into Gemini
  • +
  • Use Google AI Studio with function calling
  • +
  • Consider using Claude Desktop or Grok instead
  • +
+
+ +
+ +

Build Your Own

+

Our API is simple REST + JSON. See the API documentation for endpoints and authentication.

+
+
+
+ + +{{end}} diff --git a/templates/invite.tmpl b/templates/invite.tmpl new file mode 100644 index 0000000..be0acdc --- /dev/null +++ b/templates/invite.tmpl @@ -0,0 +1,69 @@ +{{define "invite"}} + +
+ +
+

Invite a friend

+

Know someone who could benefit from inou? Send them an invitation.

+
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+

What happens next?

+

We'll send them a personal email from you with an invitation to join inou. That's it.

+
    +
  • We won't email them again
  • +
  • We won't store their email if they don't sign up
  • +
  • We won't share their email with anyone
  • +
  • We'll send you a copy of the email, so you see exactly what we sent
  • +
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing.tmpl b/templates/landing.tmpl new file mode 100644 index 0000000..c4909c4 --- /dev/null +++ b/templates/landing.tmpl @@ -0,0 +1,423 @@ +{{define "landing"}} + + +
+ +
+
+
inou organizes and shares your health dossier with your AI — securely and privately.
+
Your health, understood.
+
+ {{if .Dossier}}Invite a friend{{else}}Sign in{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

You need AI for your health

+ +
+

Your health data lives in a dozen different places — with your cardiologist, your neurologist, your lab, your watch, your apps, your 23andMe. And only you know the rest: what you eat, what you drink, what supplements you take. Your exercise routine. Your symptoms. Your goals — whether you're trying to get pregnant, training for a marathon, or just trying to feel less exhausted.

+ +

Whether you're healthy and want to stay that way, navigating a difficult diagnosis, or caring for a family member who can't advocate for themselves — no single doctor sees the full picture. No system connects it.

+ +

But you have access to all of it. You just don't have the expertise to make sense of it all.

+ +

Your AI does. inou gives it the full picture.

+
+
+
+
+
+

The challenge

+
+
Your MRI has 4,000 slices.
+
It was read in 10 minutes.
+
+ +
+
Your genome has millions of variants.
+
All you learned was your eye color and where your ancestors came from.
+
+ +
+
Your blood work has dozens of markers.
+
Your doctor said "everything looks fine."
+
+ +
+
Your watch tracked 10,000 hours of sleep.
+
Your trainer doesn't know it exists.
+
+ +
+
You've tried a hundred different supplements.
+
Nobody asked which ones.
+
+ +
+ The connections are there.
+ They are just too complex for any one person to grasp. +
+ +
+ Nobody knows how your body processes Warfarin — not even you. + But the answer might already be hiding in your 23andMe. + That 'unremarkable' on your MRI — did anyone look closely at all 4,000 slices? + Your thyroid is 'in range' — but nobody connected it to your fatigue, your weight, always being cold. +
+ +
+ Nobody is connecting your afternoon caffeine to your sleep scores. + Your iron levels to your workout fatigue. + Your genetics to your brain fog. +
+ +
+ Your AI doesn't forget. + Doesn't rush. + Finds what was missed. + Doesn't specialize — sees the complete you. +
+ +
inou lets your AI take it all into account — every slice, every marker, every variant — connect it all and finally give you answers no one else could.
+
+
+ + +
+
+

Why we built this

+ +

You've collected years of health data. Scans from the hospital. Blood work from the lab. Results from your doctor's portal. Data from your watch. Maybe even your DNA.

+ +

And then there's everything only you know — your weight, your blood pressure, your training schedule, the supplements you take, the symptoms you've been meaning to mention.

+ +

It's all there — but scattered across systems that don't talk to each other, held by specialists who only see their piece, or locked in your own head.

+ +

Your cardiologist doesn't know what your neurologist found. Your trainer hasn't seen your blood work. Your doctor has no idea what supplements you are taking. And none of them have time to sit with you and connect the dots.

+ +

AI finally can. It can pull together what no single expert sees — and actually explain it to you.

+ +

But this data doesn't fit in a chat window. And the last thing you want is your medical history on someone else's servers, training their models.

+ +

inou brings it all together — labs, imaging, genetics, vitals, medications, supplements — encrypted, private, and shared with absolutely no one. Your AI connects securely. Your data stays yours.

+ +
Your health, understood.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} + + diff --git a/templates/landing_da.tmpl b/templates/landing_da.tmpl new file mode 100644 index 0000000..d1e806b --- /dev/null +++ b/templates/landing_da.tmpl @@ -0,0 +1,121 @@ +{{define "landing_da"}} + +
+
+
+
inou organiserer og deler dit sundhedsdossier med din AI — sikkert og privat.
+
Dit helbred, forstået.
+
{{if .Dossier}}Inviter en ven{{else}}Log ind{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du har brug for AI til dit helbred

+
+

Dine sundhedsdata er spredt på dusinvis af steder — hos din kardiolog, din neurolog, laboratoriet, dit smartur, dine apps, dit 23andMe. Og kun du kender resten: hvad du spiser, hvad du drikker, hvilke kosttilskud du tager. Din træningsrutine. Dine symptomer. Dine mål — uanset om du prøver at blive gravid, træner til et maraton, eller bare prøver at føle dig mindre træt.

+

Uanset om du er sund og vil blive ved med det, navigerer en svær diagnose, eller passer på et familiemedlem der ikke kan tale for sig selv — ingen enkelt læge ser hele billedet. Ingen system forbinder det hele.

+

Men du har adgang til alt. Du mangler bare ekspertisen til at forstå det.

+

Din AI har den. inou giver den hele billedet.

+
+
+
+
+
+

Udfordringen

+
Din MR-scanning har 4.000 snit.
Den blev læst på 10 minutter.
+
Dit genom har millioner af varianter.
Du fik kun at vide din øjenfarve og hvor dine forfædre kom fra.
+
Dine blodprøver har dusinvis af markører.
Din læge sagde "alt ser fint ud."
+
Dit ur har registreret 10.000 timers søvn.
Din træner ved ikke at det eksisterer.
+
Du har prøvet hundrede forskellige kosttilskud.
Ingen spurgte hvilke.
+
Forbindelserne er der.
De er bare for komplekse for én person.
+
+ Ingen ved hvordan din krop behandler Warfarin — ikke engang dig. + Men svaret gemmer sig måske allerede i dit 23andMe. + Det "uden bemærkninger" på din MR — kiggede nogen virkelig grundigt på alle 4.000 snit? + Din skjoldbruskkirtel er "inden for normalområdet" — men ingen forbandt det med din træthed, din vægt, at du altid fryser. +
+
+ Ingen forbinder din eftermiddagskaffe med din søvnkvalitet. + Dine jernværdier med din træthed under træning. + Din genetik med din hjernetåge. +
+
+ Din AI glemmer ikke. + Har ikke travlt. + Finder det der blev overset. + Specialiserer sig ikke — ser dig som helhed. +
+
inou lader din AI tage alt i betragtning — hvert snit, hver markør, hver variant — forbinder det hele og giver dig endelig svar som ingen andre kunne give.
+
+
+
+
+

Hvorfor vi byggede dette

+

Du har samlet års sundhedsdata. Undersøgelser fra hospitalet. Prøver fra laboratoriet. Resultater fra patientportalen. Data fra dit ur. Måske endda dit DNA.

+

Og så er der alt det som kun du ved — din vægt, dit blodtryk, dit træningsprogram, de tilskud du tager, de symptomer du altid glemmer at nævne.

+

Det er alt sammen der — men spredt i systemer der ikke taler sammen, hos specialister der kun ser deres del, eller låst inde i dit eget hoved.

+

Din kardiolog ved ikke hvad din neurolog fandt. Din træner har ikke set dine blodprøver. Din læge aner ikke hvilke tilskud du tager. Og ingen af dem har tid til at sætte sig ned med dig og forbinde prikkerne.

+

AI kan endelig gøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for dig oven i købet.

+

Men disse data passer ikke i et chatvindue. Og det sidste du vil er din sygehistorie på andres servere, til at træne deres modeller.

+

inou samler alt — lab, billeddiagnostik, genetik, vitale tegn, medicin, tilskud — krypteret, privat, og delt med absolut ingen. Din AI forbinder sikkert. Dine data forbliver dine.

+
Dit helbred, forstået.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_de.tmpl b/templates/landing_de.tmpl new file mode 100644 index 0000000..430682d --- /dev/null +++ b/templates/landing_de.tmpl @@ -0,0 +1,121 @@ +{{define "landing_de"}} + +
+
+
+
inou organisiert und teilt Ihre Gesundheitsakte mit Ihrer KI — sicher und privat.
+
Ihre Gesundheit, verstanden.
+
{{if .Dossier}}Freund einladen{{else}}Anmelden{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Sie brauchen KI für Ihre Gesundheit

+
+

Ihre Gesundheitsdaten sind über Dutzende von Orten verstreut — bei Ihrem Kardiologen, Ihrem Neurologen, im Labor, in Ihrer Smartwatch, Ihren Apps, Ihrem 23andMe. Und nur Sie kennen den Rest: was Sie essen, was Sie trinken, welche Nahrungsergänzungsmittel Sie nehmen. Ihr Trainingsplan. Ihre Symptome. Ihre Ziele — ob Sie schwanger werden möchten, für einen Marathon trainieren oder einfach weniger müde sein wollen.

+

Ob Sie gesund sind und es bleiben wollen, mit einer schwierigen Diagnose kämpfen oder sich um ein Familienmitglied kümmern, das sich nicht selbst vertreten kann — kein einzelner Arzt sieht das vollständige Bild. Kein System verbindet alles.

+

Aber Sie haben Zugang zu allem. Ihnen fehlt nur die Expertise, um alles zu verstehen.

+

Ihre KI hat sie. inou gibt ihr das vollständige Bild.

+
+
+
+
+
+

Die Herausforderung

+
Ihr MRT hat 4.000 Schichten.
Es wurde in 10 Minuten ausgewertet.
+
Ihr Genom hat Millionen von Varianten.
Sie haben nur Ihre Augenfarbe und Ihre Herkunft erfahren.
+
Ihr Blutbild hat Dutzende von Markern.
Ihr Arzt sagte "alles sieht gut aus."
+
Ihre Uhr hat 10.000 Stunden Schlaf aufgezeichnet.
Ihr Trainer weiß nicht, dass sie existiert.
+
Sie haben hundert verschiedene Nahrungsergänzungsmittel ausprobiert.
Niemand hat gefragt, welche.
+
Die Verbindungen sind da.
Sie sind nur zu komplex für eine einzelne Person.
+
+ Niemand weiß, wie Ihr Körper Warfarin verarbeitet — nicht einmal Sie. + Aber die Antwort könnte bereits in Ihrem 23andMe versteckt sein. + Dieses "unauffällig" in Ihrem MRT — hat jemand wirklich alle 4.000 Schichten genau angesehen? + Ihre Schilddrüse ist "im Normbereich" — aber niemand hat sie mit Ihrer Müdigkeit, Ihrem Gewicht, dass Ihnen immer kalt ist, verbunden. +
+
+ Niemand verbindet Ihren Nachmittagskaffee mit Ihrer Schlafqualität. + Ihren Eisenspiegel mit Ihrer Trainingsmüdigkeit. + Ihre Genetik mit Ihrem Gehirnnebel. +
+
+ Ihre KI vergisst nicht. + Hetzt nicht. + Findet, was übersehen wurde. + Spezialisiert sich nicht — sieht Sie als Ganzes. +
+
inou lässt Ihre KI alles berücksichtigen — jede Schicht, jeden Marker, jede Variante — verbindet alles und gibt Ihnen endlich Antworten, die niemand sonst geben konnte.
+
+
+
+
+

Warum wir das gebaut haben

+

Sie haben jahrelang Gesundheitsdaten gesammelt. Scans aus dem Krankenhaus. Blutwerte aus dem Labor. Ergebnisse aus dem Patientenportal. Daten von Ihrer Uhr. Vielleicht sogar Ihre DNA.

+

Und dann gibt es alles, was nur Sie wissen — Ihr Gewicht, Ihr Blutdruck, Ihr Trainingsplan, die Nahrungsergänzungsmittel, die Sie nehmen, die Symptome, die Sie immer vergessen zu erwähnen.

+

Es ist alles da — aber verstreut über Systeme, die nicht miteinander kommunizieren, bei Spezialisten, die nur ihren Teil sehen, oder in Ihrem eigenen Kopf eingeschlossen.

+

Ihr Kardiologe weiß nicht, was Ihr Neurologe gefunden hat. Ihr Trainer hat Ihre Blutwerte nicht gesehen. Ihr Arzt hat keine Ahnung, welche Nahrungsergänzungsmittel Sie nehmen. Und keiner von ihnen hat Zeit, sich mit Ihnen hinzusetzen und die Punkte zu verbinden.

+

KI kann das endlich. Sie kann zusammenführen, was kein einzelner Experte sieht — und es Ihnen auch noch erklären.

+

Aber diese Daten passen nicht in ein Chat-Fenster. Und das Letzte, was Sie wollen, ist Ihre Krankengeschichte auf fremden Servern, die deren Modelle trainiert.

+

inou bringt alles zusammen — Labor, Bildgebung, Genetik, Vitalwerte, Medikamente, Nahrungsergänzungsmittel — verschlüsselt, privat und mit niemandem geteilt. Ihre KI verbindet sich sicher. Ihre Daten bleiben Ihre.

+
Ihre Gesundheit, verstanden.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_es.tmpl b/templates/landing_es.tmpl new file mode 100644 index 0000000..525d4d6 --- /dev/null +++ b/templates/landing_es.tmpl @@ -0,0 +1,121 @@ +{{define "landing_es"}} + +
+
+
+
inou organiza y comparte tu expediente de salud con tu IA — de forma segura y privada.
+
Tu salud, comprendida.
+
{{if .Dossier}}Invitar a un amigo{{else}}Iniciar sesión{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Necesitas IA para tu salud

+
+

Tus datos de salud están dispersos en docenas de lugares — con tu cardiólogo, tu neurólogo, el laboratorio, tu reloj inteligente, tus apps, tu 23andMe. Y solo tú conoces el resto: qué comes, qué bebes, qué suplementos tomas. Tu rutina de ejercicio. Tus síntomas. Tus objetivos — ya sea que estés intentando quedar embarazada, entrenando para un maratón, o simplemente tratando de sentirte menos cansado.

+

Ya sea que estés sano y quieras seguir así, navegando un diagnóstico difícil, o cuidando a un familiar que no puede defenderse solo — ningún médico ve el panorama completo. Ningún sistema lo conecta.

+

Pero tú tienes acceso a todo. Solo te falta la experiencia para entenderlo todo.

+

Tu IA la tiene. inou le da el panorama completo.

+
+
+
+
+
+

El desafío

+
Tu resonancia tiene 4.000 cortes.
Se leyó en 10 minutos.
+
Tu genoma tiene millones de variantes.
Solo aprendiste el color de tus ojos y de dónde vienen tus ancestros.
+
Tu análisis de sangre tiene docenas de marcadores.
Tu médico dijo "todo se ve bien."
+
Tu reloj registró 10.000 horas de sueño.
Tu entrenador no sabe que existe.
+
Has probado cien suplementos diferentes.
Nadie preguntó cuáles.
+
Las conexiones están ahí.
Son demasiado complejas para una sola persona.
+
+ Nadie sabe cómo tu cuerpo procesa la Warfarina — ni siquiera tú. + Pero la respuesta podría estar escondida en tu 23andMe. + Ese "sin hallazgos" en tu resonancia — ¿alguien miró cuidadosamente los 4.000 cortes? + Tu tiroides está "dentro del rango" — pero nadie lo conectó con tu fatiga, tu peso, que siempre tienes frío. +
+
+ Nadie conecta tu café de la tarde con tu calidad de sueño. + Tus niveles de hierro con tu fatiga en el entrenamiento. + Tu genética con tu niebla mental. +
+
+ Tu IA no olvida. + No se apresura. + Encuentra lo que se pasó por alto. + No se especializa — te ve completo. +
+
inou permite que tu IA tome todo en cuenta — cada corte, cada marcador, cada variante — conecta todo y finalmente te da respuestas que nadie más podía dar.
+
+
+
+
+

Por qué construimos esto

+

Has recopilado años de datos de salud. Estudios del hospital. Análisis del laboratorio. Resultados del portal del médico. Datos de tu reloj. Quizás incluso tu ADN.

+

Y luego está todo lo que solo tú sabes — tu peso, tu presión arterial, tu programa de entrenamiento, los suplementos que tomas, los síntomas que siempre olvidas mencionar.

+

Todo está ahí — pero disperso en sistemas que no se comunican, con especialistas que solo ven su parte, o encerrado en tu propia cabeza.

+

Tu cardiólogo no sabe lo que encontró tu neurólogo. Tu entrenador no ha visto tus análisis de sangre. Tu médico no tiene idea de qué suplementos tomas. Y ninguno de ellos tiene tiempo para sentarse contigo y conectar los puntos.

+

La IA finalmente puede. Puede unir lo que ningún experto solo ve — y además explicártelo.

+

Pero estos datos no caben en una ventana de chat. Y lo último que quieres es tu historial médico en los servidores de alguien más, entrenando sus modelos.

+

inou lo une todo — laboratorio, imágenes, genética, signos vitales, medicamentos, suplementos — encriptado, privado, y sin compartir con absolutamente nadie. Tu IA se conecta de forma segura. Tus datos siguen siendo tuyos.

+
Tu salud, comprendida.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_fi.tmpl b/templates/landing_fi.tmpl new file mode 100644 index 0000000..78fbcc1 --- /dev/null +++ b/templates/landing_fi.tmpl @@ -0,0 +1,121 @@ +{{define "landing_fi"}} + +
+
+
+
inou järjestää ja jakaa terveystietosi tekoälysi kanssa — turvallisesti ja yksityisesti.
+
Terveytesi, ymmärrettynä.
+
{{if .Dossier}}Kutsu ystävä{{else}}Kirjaudu{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Tarvitset tekoälyä terveytesi tueksi

+
+

Terveystietosi ovat hajallaan kymmenissä paikoissa — kardiologillasi, neurologillasi, laboratoriossa, älykellossasi, sovelluksissasi, 23andMe:ssä. Ja vain sinä tiedät loput: mitä syöt, mitä juot, mitä lisäravinteita otat. Harjoitteluohjelmasi. Oireesi. Tavoitteesi — yrititpä tulla raskaaksi, harjoitella maratoniin tai vain yrität tuntea itsesi vähemmän väsyneeksi.

+

Olitpa terve ja haluat pysyä sellaisena, navigoit vaikeaa diagnoosia tai hoidat perheenjäsentä joka ei pysty puolustamaan itseään — yksikään lääkäri ei näe kokonaiskuvaa. Mikään järjestelmä ei yhdistä kaikkea.

+

Mutta sinulla on pääsy kaikkeen. Sinulta puuttuu vain asiantuntemus ymmärtääksesi kaiken.

+

Tekoälylläsi se on. inou antaa sille kokonaiskuvan.

+
+
+
+
+
+

Haaste

+
MRI-kuvauksessasi on 4 000 leikettä.
Se luettiin 10 minuutissa.
+
Genomissasi on miljoonia variantteja.
Sait tietää vain silmiesi värin ja mistä esi-isäsi tulivat.
+
Verikokeissasi on kymmeniä merkkiaineita.
Lääkärisi sanoi "kaikki näyttää hyvältä."
+
Kellosi on tallentanut 10 000 tuntia unta.
Valmentajasi ei tiedä sen olemassaolosta.
+
Olet kokeillut sataa eri lisäravinnetta.
Kukaan ei kysynyt mitä.
+
Yhteydet ovat siellä.
Ne ovat vain liian monimutkaisia yhdelle ihmiselle.
+
+ Kukaan ei tiedä miten kehosi käsittelee Warfariinia — et sinäkään. + Mutta vastaus saattaa piillä 23andMe:ssäsi. + Se "ei poikkeavaa" MRI:ssäsi — katsottiinko todella kaikki 4 000 leikettä huolellisesti? + Kilpirauhasesi on "viitearvoissa" — mutta kukaan ei yhdistänyt sitä väsymykseesi, painoosi, siihen että sinulla on aina kylmä. +
+
+ Kukaan ei yhdistä iltapäiväkahviasi unen laatuusi. + Rautatasojasi harjoitusväsymykseesi. + Genetiikkaasi aivosumuusi. +
+
+ Tekoälysi ei unohda. + Ei kiirehdi. + Löytää sen mikä jäi huomaamatta. + Ei erikoistu — näkee sinut kokonaisuutena. +
+
inou antaa tekoälysi ottaa kaiken huomioon — jokaisen leikkeen, jokaisen merkkiaineen, jokaisen variantin — yhdistää kaiken ja antaa sinulle vihdoin vastauksia joita kukaan muu ei voinut antaa.
+
+
+
+
+

Miksi rakensimme tämän

+

Olet kerännyt vuosien terveystietoja. Tutkimuksia sairaalasta. Kokeita laboratoriosta. Tuloksia potilasportaalista. Dataa kellostasi. Ehkä jopa DNA:si.

+

Ja sitten on kaikki mitä vain sinä tiedät — painosi, verenpaineesi, harjoitusohjelmasi, ottamasi lisäravinteet, oireet jotka unohdat aina mainita.

+

Kaikki on siellä — mutta hajallaan järjestelmissä jotka eivät keskustele keskenään, erikoislääkäreillä jotka näkevät vain oman osansa, tai lukittuna omaan päähäsi.

+

Kardiologisi ei tiedä mitä neurologisi löysi. Valmentajasi ei ole nähnyt verikokeittasi. Lääkärisi ei tiedä mitä lisäravinteita otat. Eikä kenelläkään heistä ole aikaa istua alas kanssasi ja yhdistää pisteitä.

+

Tekoäly vihdoin pystyy. Se voi koota yhteen sen mitä yksikään yksittäinen asiantuntija ei näe — ja selittää sen sinulle.

+

Mutta nämä tiedot eivät mahdu chat-ikkunaan. Ja viimeinen asia mitä haluat on sairaushistoriasi jonkun muun palvelimilla, kouluttamassa heidän mallejaan.

+

inou kokoaa kaiken yhteen — laboratorio, kuvantaminen, genetiikka, vitaalit, lääkkeet, lisäravinteet — salattuna, yksityisenä, eikä jaeta kenellekään. Tekoälysi yhdistää turvallisesti. Tietosi pysyvät sinun.

+
Terveytesi, ymmärrettynä.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_fr.tmpl b/templates/landing_fr.tmpl new file mode 100644 index 0000000..87740d1 --- /dev/null +++ b/templates/landing_fr.tmpl @@ -0,0 +1,121 @@ +{{define "landing_fr"}} + +
+
+
+
inou organise et partage votre dossier santé avec votre IA — en toute sécurité et confidentialité.
+
Votre santé, comprise.
+
{{if .Dossier}}Inviter un ami{{else}}Se connecter{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Vous avez besoin de l'IA pour votre santé

+
+

Vos données de santé sont dispersées dans des dizaines d'endroits — chez votre cardiologue, votre neurologue, le laboratoire, votre montre connectée, vos applications, votre 23andMe. Et vous seul connaissez le reste : ce que vous mangez, ce que vous buvez, quels compléments vous prenez. Votre programme d'entraînement. Vos symptômes. Vos objectifs — que vous essayiez de tomber enceinte, de vous préparer pour un marathon, ou simplement de vous sentir moins fatigué.

+

Que vous soyez en bonne santé et vouliez le rester, que vous naviguiez un diagnostic difficile, ou que vous vous occupiez d'un proche qui ne peut pas se défendre seul — aucun médecin ne voit le tableau complet. Aucun système ne connecte tout.

+

Mais vous avez accès à tout. Il vous manque juste l'expertise pour tout comprendre.

+

Votre IA l'a. inou lui donne le tableau complet.

+
+
+
+
+
+

Le défi

+
Votre IRM contient 4 000 coupes.
Elle a été lue en 10 minutes.
+
Votre génome contient des millions de variants.
Vous n'avez appris que la couleur de vos yeux et l'origine de vos ancêtres.
+
Votre bilan sanguin contient des dizaines de marqueurs.
Votre médecin a dit "tout va bien."
+
Votre montre a enregistré 10 000 heures de sommeil.
Votre coach ne sait pas qu'elle existe.
+
Vous avez essayé une centaine de compléments différents.
Personne n'a demandé lesquels.
+
Les connexions sont là.
Elles sont juste trop complexes pour une seule personne.
+
+ Personne ne sait comment votre corps métabolise la Warfarine — pas même vous. + Mais la réponse se cache peut-être déjà dans votre 23andMe. + Ce "sans particularité" sur votre IRM — quelqu'un a-t-il vraiment regardé les 4 000 coupes attentivement ? + Votre thyroïde est "dans les normes" — mais personne n'a fait le lien avec votre fatigue, votre poids, le fait que vous avez toujours froid. +
+
+ Personne ne relie votre café de l'après-midi à votre qualité de sommeil. + Votre taux de fer à votre fatigue à l'entraînement. + Votre génétique à votre brouillard mental. +
+
+ Votre IA n'oublie pas. + Ne se précipite pas. + Trouve ce qui a été manqué. + Ne se spécialise pas — vous voit dans votre globalité. +
+
inou permet à votre IA de tout prendre en compte — chaque coupe, chaque marqueur, chaque variant — de tout connecter et de vous donner enfin des réponses que personne d'autre ne pouvait donner.
+
+
+
+
+

Pourquoi nous avons créé ça

+

Vous avez collecté des années de données de santé. Des examens de l'hôpital. Des analyses du laboratoire. Des résultats du portail patient. Des données de votre montre. Peut-être même votre ADN.

+

Et puis il y a tout ce que vous seul savez — votre poids, votre tension, votre programme d'entraînement, les compléments que vous prenez, les symptômes que vous oubliez toujours de mentionner.

+

Tout est là — mais dispersé dans des systèmes qui ne communiquent pas entre eux, chez des spécialistes qui ne voient que leur partie, ou enfermé dans votre propre tête.

+

Votre cardiologue ne sait pas ce que votre neurologue a trouvé. Votre coach n'a pas vu vos analyses sanguines. Votre médecin n'a aucune idée des compléments que vous prenez. Et aucun d'entre eux n'a le temps de s'asseoir avec vous pour relier les points.

+

L'IA peut enfin le faire. Elle peut rassembler ce qu'aucun expert seul ne voit — et vous l'expliquer en plus.

+

Mais ces données ne tiennent pas dans une fenêtre de chat. Et la dernière chose que vous voulez, c'est votre historique médical sur les serveurs de quelqu'un d'autre, entraînant leurs modèles.

+

inou rassemble tout — analyses, imagerie, génétique, constantes, médicaments, compléments — chiffré, privé, et partagé avec absolument personne. Votre IA se connecte en toute sécurité. Vos données restent les vôtres.

+
Votre santé, comprise.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_it.tmpl b/templates/landing_it.tmpl new file mode 100644 index 0000000..91da4c0 --- /dev/null +++ b/templates/landing_it.tmpl @@ -0,0 +1,121 @@ +{{define "landing_it"}} + +
+
+
+
inou organizza e condivide il tuo dossier sanitario con la tua IA — in modo sicuro e privato.
+
La tua salute, compresa.
+
{{if .Dossier}}Invita un amico{{else}}Accedi{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Hai bisogno dell'IA per la tua salute

+
+

I tuoi dati sanitari sono sparsi in decine di posti — dal cardiologo, dal neurologo, nel laboratorio, nel tuo smartwatch, nelle tue app, nel tuo 23andMe. E solo tu conosci il resto: cosa mangi, cosa bevi, quali integratori prendi. La tua routine di allenamento. I tuoi sintomi. I tuoi obiettivi — che tu stia cercando di rimanere incinta, allenarti per una maratona, o semplicemente sentirti meno stanco.

+

Che tu sia in salute e voglia restarlo, stia affrontando una diagnosi difficile, o ti stia prendendo cura di un familiare che non può difendersi da solo — nessun medico vede il quadro completo. Nessun sistema connette tutto.

+

Ma tu hai accesso a tutto. Ti manca solo l'esperienza per capire tutto.

+

La tua IA ce l'ha. inou le dà il quadro completo.

+
+
+
+
+
+

La sfida

+
La tua risonanza ha 4.000 sezioni.
È stata letta in 10 minuti.
+
Il tuo genoma ha milioni di varianti.
Hai scoperto solo il colore dei tuoi occhi e da dove vengono i tuoi antenati.
+
Le tue analisi del sangue hanno decine di marcatori.
Il tuo medico ha detto "tutto bene."
+
Il tuo orologio ha registrato 10.000 ore di sonno.
Il tuo trainer non sa che esiste.
+
Hai provato cento integratori diversi.
Nessuno ha chiesto quali.
+
Le connessioni ci sono.
Sono troppo complesse per una sola persona.
+
+ Nessuno sa come il tuo corpo metabolizza il Warfarin — nemmeno tu. + Ma la risposta potrebbe essere nascosta nel tuo 23andMe. + Quel "nella norma" nella tua risonanza — qualcuno ha davvero guardato tutte le 4.000 sezioni con attenzione? + La tua tiroide è "nei range" — ma nessuno l'ha collegata alla tua stanchezza, al tuo peso, al fatto che hai sempre freddo. +
+
+ Nessuno collega il tuo caffè pomeridiano alla qualità del tuo sonno. + I tuoi livelli di ferro alla tua stanchezza in allenamento. + La tua genetica alla tua nebbia mentale. +
+
+ La tua IA non dimentica. + Non ha fretta. + Trova quello che è stato trascurato. + Non si specializza — ti vede nella tua interezza. +
+
inou permette alla tua IA di considerare tutto — ogni sezione, ogni marcatore, ogni variante — connette tutto e finalmente ti dà risposte che nessun altro poteva dare.
+
+
+
+
+

Perché abbiamo costruito questo

+

Hai raccolto anni di dati sanitari. Esami dall'ospedale. Analisi dal laboratorio. Risultati dal portale del medico. Dati dal tuo orologio. Forse anche il tuo DNA.

+

E poi c'è tutto quello che solo tu sai — il tuo peso, la tua pressione, la tua routine di allenamento, gli integratori che prendi, i sintomi che dimentichi sempre di menzionare.

+

È tutto lì — ma sparso in sistemi che non comunicano, con specialisti che vedono solo la loro parte, o chiuso nella tua testa.

+

Il tuo cardiologo non sa cosa ha trovato il tuo neurologo. Il tuo trainer non ha visto le tue analisi del sangue. Il tuo medico non ha idea di quali integratori prendi. E nessuno di loro ha tempo di sedersi con te e collegare i punti.

+

L'IA finalmente può. Può unire quello che nessun esperto da solo vede — e spiegartelo anche.

+

Ma questi dati non entrano in una finestra di chat. E l'ultima cosa che vuoi è la tua storia clinica sui server di qualcun altro, ad addestrare i loro modelli.

+

inou unisce tutto — laboratorio, imaging, genetica, parametri vitali, farmaci, integratori — crittografato, privato, e non condiviso con assolutamente nessuno. La tua IA si connette in sicurezza. I tuoi dati restano tuoi.

+
La tua salute, compresa.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ja.tmpl b/templates/landing_ja.tmpl new file mode 100644 index 0000000..70c4b72 --- /dev/null +++ b/templates/landing_ja.tmpl @@ -0,0 +1,121 @@ +{{define "landing_ja"}} + +
+
+
+
inouはあなたの健康記録を整理し、AIと安全かつプライベートに共有します。
+
あなたの健康を、理解する。
+
{{if .Dossier}}友達を招待{{else}}ログイン{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

健康管理にAIが必要な理由

+
+

あなたの健康データは何十もの場所に散らばっています — 循環器科、神経科、検査機関、スマートウォッチ、アプリ、23andMe。そして残りを知っているのはあなただけ:何を食べ、何を飲み、どんなサプリを取っているか。運動習慣。症状。目標 — 妊娠を目指しているのか、マラソンのトレーニング中なのか、単に疲れを減らしたいだけなのか。

+

健康を維持したい人も、難しい診断と向き合っている人も、自分で主張できない家族の世話をしている人も — どの医師も全体像を見ていません。すべてをつなぐシステムはありません。

+

でも、あなたはすべてにアクセスできます。足りないのは、それを理解する専門知識だけです。

+

あなたのAIにはそれがあります。inouは全体像を与えます。

+
+
+
+
+
+

課題

+
あなたのMRIには4,000枚のスライスがあります。
10分で読影されました。
+
あなたのゲノムには数百万の変異があります。
わかったのは目の色と祖先のルーツだけでした。
+
あなたの血液検査には数十のマーカーがあります。
医師は「問題ありません」と言いました。
+
あなたの時計は10,000時間の睡眠を記録しています。
トレーナーはその存在を知りません。
+
あなたは100種類のサプリメントを試しました。
誰も何を試したか聞きませんでした。
+
つながりはそこにあります。
ただ、一人の人間には複雑すぎるのです。
+
+ あなたの体がワーファリンをどう代謝するか、誰も知りません — あなた自身も。 + でも答えは23andMeに隠れているかもしれません。 + MRIの「異常なし」— 誰かが4,000枚すべてを本当に注意深く見ましたか? + 甲状腺は「基準値内」— でも誰もそれをあなたの疲労、体重、いつも寒いことと結びつけていません。 +
+
+ 誰も午後のコーヒーと睡眠の質を結びつけていません。 + 鉄分レベルとトレーニング時の疲労を。 + 遺伝子とブレインフォグを。 +
+
+ あなたのAIは忘れません。 + 急ぎません。 + 見落とされたものを見つけます。 + 専門分野を持たず — あなたを全体として見ます。 +
+
inouはあなたのAIにすべてを考慮させます — すべてのスライス、すべてのマーカー、すべての変異 — すべてをつなぎ、他の誰も答えられなかった答えをついに提供します。
+
+
+
+
+

私たちがこれを作った理由

+

あなたは何年もの健康データを蓄積してきました。病院の検査。検査機関の結果。患者ポータルの記録。時計のデータ。DNAかもしれません。

+

そして、あなただけが知っていることがあります — 体重、血圧、トレーニングプログラム、飲んでいるサプリ、いつも言い忘れる症状。

+

すべてそこにあります — でも、互いに通信しないシステム、自分の専門分野しか見ない専門医、またはあなた自身の頭の中に閉じ込められています。

+

循環器科医は神経科医が何を見つけたか知りません。トレーナーは血液検査を見ていません。かかりつけ医はどんなサプリを飲んでいるか知りません。そして誰も、あなたと一緒に座って点をつなぐ時間がありません。

+

AIならついにできます。どの専門家単独では見えないものをまとめ — さらにそれを説明してくれます。

+

でも、このデータはチャットウィンドウに収まりません。そして最も避けたいのは、あなたの医療履歴が他人のサーバーに置かれ、そのモデルを訓練することです。

+

inouはすべてをまとめます — 検査、画像、遺伝子、バイタル、薬、サプリ — 暗号化され、プライベートで、誰とも共有されません。あなたのAIは安全に接続します。データはあなたのものです。

+
あなたの健康を、理解する。
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ko.tmpl b/templates/landing_ko.tmpl new file mode 100644 index 0000000..b49930b --- /dev/null +++ b/templates/landing_ko.tmpl @@ -0,0 +1,121 @@ +{{define "landing_ko"}} + +
+
+
+
inou는 당신의 건강 기록을 정리하고 AI와 안전하고 비공개로 공유합니다.
+
당신의 건강, 이해되다.
+
{{if .Dossier}}친구 초대{{else}}로그인{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

건강을 위해 AI가 필요한 이유

+
+

당신의 건강 데이터는 수십 곳에 흩어져 있습니다 — 심장내과, 신경과, 검사실, 스마트워치, 앱, 23andMe. 그리고 나머지는 당신만 알고 있습니다: 무엇을 먹고, 무엇을 마시고, 어떤 보충제를 복용하는지. 운동 루틴. 증상. 목표 — 임신을 원하든, 마라톤 훈련 중이든, 단순히 덜 피곤해지고 싶든.

+

건강하고 그 상태를 유지하고 싶든, 어려운 진단을 받았든, 스스로를 대변할 수 없는 가족을 돌보든 — 어떤 의사도 전체 그림을 보지 못합니다. 모든 것을 연결하는 시스템은 없습니다.

+

하지만 당신은 모든 것에 접근할 수 있습니다. 부족한 것은 이해하는 전문 지식뿐입니다.

+

당신의 AI는 그것을 가지고 있습니다. inou는 전체 그림을 제공합니다.

+
+
+
+
+
+

도전

+
당신의 MRI에는 4,000개의 슬라이스가 있습니다.
10분 만에 판독되었습니다.
+
당신의 게놈에는 수백만 개의 변이가 있습니다.
알게 된 것은 눈 색깔과 조상의 출신지뿐이었습니다.
+
당신의 혈액 검사에는 수십 개의 마커가 있습니다.
의사는 "다 괜찮아 보입니다"라고 했습니다.
+
당신의 시계는 10,000시간의 수면을 기록했습니다.
트레이너는 그 존재를 모릅니다.
+
당신은 백 가지 다른 보충제를 시도했습니다.
아무도 어떤 것인지 묻지 않았습니다.
+
연결고리는 거기 있습니다.
한 사람이 다루기엔 너무 복잡할 뿐입니다.
+
+ 당신의 몸이 와파린을 어떻게 처리하는지 아무도 모릅니다 — 당신 자신도. + 하지만 답은 23andMe에 숨겨져 있을 수 있습니다. + MRI의 그 "이상 없음" — 누군가 정말로 4,000개 슬라이스를 모두 주의 깊게 봤을까요? + 갑상선은 "정상 범위" — 하지만 아무도 그것을 피로, 체중, 항상 추운 것과 연결하지 않았습니다. +
+
+ 아무도 오후 커피와 수면의 질을 연결하지 않습니다. + 철분 수치와 운동 시 피로를. + 유전자와 브레인 포그를. +
+
+ 당신의 AI는 잊지 않습니다. + 서두르지 않습니다. + 놓친 것을 찾습니다. + 전문화되지 않습니다 — 당신을 전체로 봅니다. +
+
inou는 당신의 AI가 모든 것을 고려하게 합니다 — 모든 슬라이스, 모든 마커, 모든 변이 — 모든 것을 연결하고 마침내 다른 누구도 줄 수 없었던 답을 제공합니다.
+
+
+
+
+

우리가 이것을 만든 이유

+

당신은 수년간의 건강 데이터를 수집해 왔습니다. 병원 검사. 검사실 결과. 환자 포털의 기록. 시계의 데이터. 어쩌면 DNA까지.

+

그리고 당신만 아는 것들이 있습니다 — 체중, 혈압, 운동 프로그램, 복용하는 보충제, 항상 말하기를 잊는 증상들.

+

모든 것이 거기 있습니다 — 하지만 서로 소통하지 않는 시스템들, 자기 분야만 보는 전문의들, 또는 당신 머릿속에 갇혀 있습니다.

+

심장내과 의사는 신경과 의사가 무엇을 발견했는지 모릅니다. 트레이너는 혈액 검사를 보지 못했습니다. 주치의는 어떤 보충제를 복용하는지 모릅니다. 그리고 그들 중 누구도 당신과 함께 앉아서 점들을 연결할 시간이 없습니다.

+

AI는 마침내 할 수 있습니다. 어떤 전문가 혼자서도 볼 수 없는 것을 모으고 — 게다가 설명해 줍니다.

+

하지만 이 데이터는 채팅 창에 들어가지 않습니다. 그리고 가장 원하지 않는 것은 당신의 의료 기록이 다른 사람의 서버에서 그들의 모델을 훈련시키는 것입니다.

+

inou는 모든 것을 모읍니다 — 검사, 영상, 유전자, 활력징후, 약물, 보충제 — 암호화되고, 비공개이며, 누구와도 공유되지 않습니다. 당신의 AI는 안전하게 연결됩니다. 데이터는 당신의 것입니다.

+
당신의 건강, 이해되다.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_nl.tmpl b/templates/landing_nl.tmpl new file mode 100644 index 0000000..8a74f50 --- /dev/null +++ b/templates/landing_nl.tmpl @@ -0,0 +1,343 @@ +{{define "landing_nl"}} + + +
+ +
+
+
inou organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.
+
Je gezondheid, begrepen.
+
+ {{if .Dossier}}Vriend uitnodigen{{else}}Inloggen{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

Je hebt AI nodig voor je gezondheid

+ +
+

Je gezondheidsgegevens liggen verspreid over tientallen plekken — bij je cardioloog, je neuroloog, het lab, je smartwatch, je apps, je 23andMe. En dan is er nog alles wat alleen jij weet: wat je eet, wat je drinkt, welke supplementen je slikt. Je trainingsschema. Je klachten. Je doelen — of je nu zwanger wilt worden, traint voor een marathon, of gewoon minder moe wilt zijn.

+ +

Of je nu gezond bent en dat wilt blijven, worstelt met een lastige diagnose, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het complete plaatje. Geen enkel systeem verbindt het.

+ +

Maar jij hebt toegang tot alles. Je mist alleen de expertise om er iets van te maken.

+ +

Je AI wel. inou geeft het het complete plaatje.

+
+
+
+ +
+
+

De uitdaging

+
+
Je MRI heeft 4.000 beelden.
+
Die werd in 10 minuten beoordeeld.
+
+ +
+
Je genoom heeft miljoenen varianten.
+
Je leerde alleen je oogkleur en waar je voorouders vandaan kwamen.
+
+ +
+
Je bloedonderzoek heeft tientallen markers.
+
Je arts zei "alles ziet er goed uit."
+
+ +
+
Je horloge heeft 10.000 uur slaap bijgehouden.
+
Je trainer weet niet dat het bestaat.
+
+ +
+
Je hebt honderd verschillende supplementen geprobeerd.
+
Niemand vroeg welke.
+
+ +
+ De verbanden zijn er.
+ Ze zijn alleen te complex voor één persoon om te overzien. +
+ +
+ Niemand weet hoe jouw lichaam Warfarine verwerkt — jijzelf ook niet. + Maar het antwoord zit misschien al in je 23andMe. + Die 'geen bijzonderheden' op je MRI — heeft iemand echt alle 4.000 beelden bekeken? + Je schildklier is 'binnen de norm' — maar niemand legde het verband met je vermoeidheid, je gewicht, dat je het altijd koud hebt. +
+ +
+ Niemand verbindt je middagkoffie aan je slaapkwaliteit. + Je ijzergehalte aan je sportvermoeidheid. + Je genetica aan je brain fog. +
+ +
+ Je AI vergeet niet. + Haast niet. + Vindt wat gemist werd. + Specialiseert niet — ziet de complete jij. +
+ +
inou laat je AI alles meewegen — elk beeld, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.
+
+
+ +
+
+

Waarom we dit bouwden

+ +

Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Uitslagen uit het patiëntenportaal. Data van je horloge. Misschien zelfs je DNA.

+ +

En dan is er nog alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je slikt, de klachten die je steeds vergeet te noemen.

+ +

Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, bij specialisten die alleen hun eigen stukje zien, of opgesloten in je eigen hoofd.

+ +

Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je huisarts heeft geen idee welke supplementen je slikt. En geen van hen heeft tijd om met je te zitten en de puzzel te leggen.

+ +

AI kan dat eindelijk. Het kan samenbrengen wat geen enkele expert ziet — en het je ook nog uitleggen.

+ +

Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, gebruikt om hun modellen te trainen.

+ +

inou brengt alles samen — labs, beeldvorming, genetica, vitals, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.

+ +
Je gezondheid, begrepen.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing_no.tmpl b/templates/landing_no.tmpl new file mode 100644 index 0000000..b989ceb --- /dev/null +++ b/templates/landing_no.tmpl @@ -0,0 +1,121 @@ +{{define "landing_no"}} + +
+
+
+
inou organiserer og deler helsedossieren din med din AI — sikkert og privat.
+
Din helse, forstått.
+
{{if .Dossier}}Inviter en venn{{else}}Logg inn{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du trenger AI for helsen din

+
+

Helsedataene dine er spredt på dusinvis av steder — hos kardiologen din, nevrologen din, labben, smartklokken din, appene dine, 23andMe. Og bare du kjenner resten: hva du spiser, hva du drikker, hvilke kosttilskudd du tar. Treningsrutinen din. Symptomene dine. Målene dine — enten du prøver å bli gravid, trener til et maraton, eller bare prøver å føle deg mindre sliten.

+

Enten du er frisk og vil forbli det, navigerer en vanskelig diagnose, eller tar vare på et familiemedlem som ikke kan tale for seg selv — ingen enkelt lege ser hele bildet. Ingen system kobler alt sammen.

+

Men du har tilgang til alt. Du mangler bare ekspertisen til å forstå det.

+

Din AI har den. inou gir den hele bildet.

+
+
+
+
+
+

Utfordringen

+
MR-en din har 4 000 snitt.
Den ble lest på 10 minutter.
+
Genomet ditt har millioner av varianter.
Du fikk bare vite øyenfargen din og hvor forfedrene dine kom fra.
+
Blodprøvene dine har dusinvis av markører.
Legen din sa "alt ser bra ut."
+
Klokken din har registrert 10 000 timer søvn.
Treneren din vet ikke at den finnes.
+
Du har prøvd hundre forskjellige kosttilskudd.
Ingen spurte hvilke.
+
Koblingene er der.
De er bare for komplekse for én person.
+
+ Ingen vet hvordan kroppen din prosesserer Warfarin — ikke engang du. + Men svaret kan ligge gjemt i 23andMe. + Det "uten anmerkning" på MR-en din — så noen virkelig nøye på alle 4 000 snittene? + Skjoldbruskkjertelen din er "innenfor normalverdier" — men ingen koblet det til trettheten din, vekten din, at du alltid fryser. +
+
+ Ingen kobler ettermiddagskaffen din til søvnkvaliteten din. + Jernverdiene dine til trettheten din under trening. + Genetikken din til hjernetåken din. +
+
+ Din AI glemmer ikke. + Stresser ikke. + Finner det som ble oversett. + Spesialiserer seg ikke — ser deg som helhet. +
+
inou lar din AI ta hensyn til alt — hvert snitt, hver markør, hver variant — kobler alt sammen og gir deg endelig svar som ingen andre kunne gi.
+
+
+
+
+

Hvorfor vi bygde dette

+

Du har samlet år med helsedata. Undersøkelser fra sykehuset. Prøver fra labben. Resultater fra pasientportalen. Data fra klokken din. Kanskje til og med DNA-et ditt.

+

Og så er det alt som bare du vet — vekten din, blodtrykket ditt, treningsprogrammet ditt, tilskuddene du tar, symptomene du alltid glemmer å nevne.

+

Alt er der — men spredt i systemer som ikke snakker sammen, hos spesialister som bare ser sin del, eller låst inne i ditt eget hode.

+

Kardiologen din vet ikke hva nevrologen din fant. Treneren din har ikke sett blodprøvene dine. Legen din aner ikke hvilke tilskudd du tar. Og ingen av dem har tid til å sette seg ned med deg og koble prikkene.

+

AI kan endelig gjøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for deg i tillegg.

+

Men disse dataene får ikke plass i et chatvindu. Og det siste du vil er sykehistorikken din på andres servere, som trener modellene deres.

+

inou samler alt — lab, bildediagnostikk, genetikk, vitale tegn, medisiner, tilskudd — kryptert, privat, og delt med absolutt ingen. Din AI kobler seg til sikkert. Dataene dine forblir dine.

+
Din helse, forstått.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_pt.tmpl b/templates/landing_pt.tmpl new file mode 100644 index 0000000..6eb4923 --- /dev/null +++ b/templates/landing_pt.tmpl @@ -0,0 +1,121 @@ +{{define "landing_pt"}} + +
+
+
+
inou organiza e compartilha seu dossiê de saúde com sua IA — de forma segura e privada.
+
Sua saúde, compreendida.
+
{{if .Dossier}}Convidar um amigo{{else}}Entrar{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Você precisa de IA para sua saúde

+
+

Seus dados de saúde estão espalhados em dezenas de lugares — com seu cardiologista, seu neurologista, o laboratório, seu relógio inteligente, seus apps, seu 23andMe. E só você sabe o resto: o que você come, o que bebe, quais suplementos toma. Sua rotina de exercícios. Seus sintomas. Seus objetivos — seja tentando engravidar, treinando para uma maratona, ou apenas tentando se sentir menos cansado.

+

Seja você saudável e querendo continuar assim, navegando um diagnóstico difícil, ou cuidando de um familiar que não pode se defender sozinho — nenhum médico vê o quadro completo. Nenhum sistema conecta tudo.

+

Mas você tem acesso a tudo. Só falta a expertise para entender tudo.

+

Sua IA tem. inou dá a ela o quadro completo.

+
+
+
+
+
+

O desafio

+
Sua ressonância tem 4.000 cortes.
Foi lida em 10 minutos.
+
Seu genoma tem milhões de variantes.
Você só descobriu a cor dos seus olhos e de onde vieram seus ancestrais.
+
Seu exame de sangue tem dezenas de marcadores.
Seu médico disse "está tudo bem."
+
Seu relógio registrou 10.000 horas de sono.
Seu treinador não sabe que ele existe.
+
Você já tentou cem suplementos diferentes.
Ninguém perguntou quais.
+
As conexões estão lá.
São complexas demais para uma pessoa só.
+
+ Ninguém sabe como seu corpo processa a Varfarina — nem você. + Mas a resposta pode estar escondida no seu 23andMe. + Aquele "sem alterações" na sua ressonância — alguém realmente olhou os 4.000 cortes com atenção? + Sua tireoide está "dentro do normal" — mas ninguém conectou com sua fadiga, seu peso, você sempre sentir frio. +
+
+ Ninguém conecta seu café da tarde com sua qualidade de sono. + Seus níveis de ferro com sua fadiga no treino. + Sua genética com sua névoa mental. +
+
+ Sua IA não esquece. + Não tem pressa. + Encontra o que foi perdido. + Não se especializa — vê você por inteiro. +
+
inou permite que sua IA considere tudo — cada corte, cada marcador, cada variante — conecta tudo e finalmente te dá respostas que ninguém mais conseguia dar.
+
+
+
+
+

Por que construímos isso

+

Você coletou anos de dados de saúde. Exames do hospital. Análises do laboratório. Resultados do portal do médico. Dados do seu relógio. Talvez até seu DNA.

+

E depois tem tudo que só você sabe — seu peso, sua pressão, sua rotina de treino, os suplementos que você toma, os sintomas que você sempre esquece de mencionar.

+

Está tudo lá — mas espalhado em sistemas que não conversam, com especialistas que só veem sua parte, ou preso na sua própria cabeça.

+

Seu cardiologista não sabe o que seu neurologista encontrou. Seu treinador não viu seus exames de sangue. Seu médico não faz ideia de quais suplementos você toma. E nenhum deles tem tempo para sentar com você e conectar os pontos.

+

A IA finalmente pode. Ela pode unir o que nenhum especialista sozinho vê — e ainda explicar para você.

+

Mas esses dados não cabem numa janela de chat. E a última coisa que você quer é seu histórico médico nos servidores de outra pessoa, treinando os modelos deles.

+

inou une tudo — laboratório, imagens, genética, sinais vitais, medicamentos, suplementos — criptografado, privado, e sem compartilhar com absolutamente ninguém. Sua IA se conecta com segurança. Seus dados continuam sendo seus.

+
Sua saúde, compreendida.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ru.tmpl b/templates/landing_ru.tmpl new file mode 100644 index 0000000..5258d43 --- /dev/null +++ b/templates/landing_ru.tmpl @@ -0,0 +1,343 @@ +{{define "landing_ru"}} + + +
+ +
+
+
inou организует и передаёт ваше медицинское досье вашему ИИ — безопасно и конфиденциально.
+
Ваше здоровье — понятно.
+
+ {{if .Dossier}}Пригласить друга{{else}}Войти{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

Вам нужен ИИ для вашего здоровья

+ +
+

Ваши медицинские данные разбросаны по десяткам мест — у кардиолога, невролога, в лаборатории, в умных часах, приложениях, в 23andMe. И только вы знаете остальное: что вы едите, что пьёте, какие добавки принимаете. Ваш режим тренировок. Ваши симптомы. Ваши цели — хотите ли вы забеременеть, готовитесь к марафону или просто хотите меньше уставать.

+ +

Здоровы ли вы и хотите таким остаться, справляетесь со сложным диагнозом или заботитесь о близком, который не может сам за себя постоять — ни один врач не видит полную картину. Ни одна система не связывает всё воедино.

+ +

Но у вас есть доступ ко всему. Вам просто не хватает экспертизы, чтобы во всём разобраться.

+ +

У вашего ИИ она есть. inou даёт ему полную картину.

+
+
+
+ +
+
+

Проблема

+
+
В вашем МРТ 4 000 снимков.
+
Его прочитали за 10 минут.
+
+ +
+
В вашем геноме миллионы вариантов.
+
Вы узнали только цвет глаз и откуда ваши предки.
+
+ +
+
В вашем анализе крови десятки показателей.
+
Врач сказал «всё в норме».
+
+ +
+
Ваши часы отследили 10 000 часов сна.
+
Ваш тренер не знает, что они существуют.
+
+ +
+
Вы перепробовали сотню разных добавок.
+
Никто не спросил какие.
+
+ +
+ Связи есть.
+ Они просто слишком сложны для одного человека. +
+ +
+ Никто не знает, как ваш организм усваивает Варфарин — даже вы сами. + Но ответ, возможно, уже есть в вашем 23andMe. + Та «норма» в вашем МРТ — кто-нибудь внимательно посмотрел все 4 000 снимков? + Ваша щитовидка «в пределах нормы» — но никто не связал это с усталостью, весом, тем что вам всегда холодно. +
+ +
+ Никто не связывает ваш послеобеденный кофе с качеством сна. + Уровень железа с усталостью на тренировках. + Вашу генетику с туманом в голове. +
+ +
+ Ваш ИИ не забывает. + Не торопится. + Находит упущенное. + Не специализируется — видит вас целиком. +
+ +
inou позволяет вашему ИИ учесть всё — каждый снимок, каждый показатель, каждый вариант — связать всё воедино и наконец дать ответы, которые никто другой дать не мог.
+
+
+ +
+
+

Почему мы это создали

+ +

Вы годами собирали медицинские данные. Снимки из больницы. Анализы из лаборатории. Результаты с портала врача. Данные с часов. Может быть, даже ДНК.

+ +

И ещё всё то, что знаете только вы — ваш вес, давление, график тренировок, добавки, симптомы, о которых всё забываете упомянуть.

+ +

Всё это есть — но разбросано по системам, которые не общаются друг с другом, у специалистов, которые видят только свой кусочек, или заперто у вас в голове.

+ +

Ваш кардиолог не знает, что нашёл невролог. Ваш тренер не видел анализы крови. Ваш врач понятия не имеет, какие добавки вы принимаете. И ни у кого из них нет времени сесть с вами и собрать пазл.

+ +

ИИ наконец может. Он способен собрать воедино то, чего не видит ни один эксперт — и ещё и объяснить вам.

+ +

Но эти данные не помещаются в окно чата. И меньше всего вам нужно, чтобы ваша медицинская история оказалась на чужих серверах, обучая чужие модели.

+ +

inou собирает всё вместе — анализы, снимки, генетику, показатели, лекарства, добавки — зашифровано, конфиденциально, ни с кем не делится. Ваш ИИ подключается безопасно. Ваши данные остаются вашими.

+ +
Ваше здоровье — понятно.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing_sv.tmpl b/templates/landing_sv.tmpl new file mode 100644 index 0000000..ddf5908 --- /dev/null +++ b/templates/landing_sv.tmpl @@ -0,0 +1,121 @@ +{{define "landing_sv"}} + +
+
+
+
inou organiserar och delar din hälsodossier med din AI — säkert och privat.
+
Din hälsa, förstådd.
+
{{if .Dossier}}Bjud in en vän{{else}}Logga in{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du behöver AI för din hälsa

+
+

Dina hälsodata finns utspridda på dussintals ställen — hos din kardiolog, din neurolog, labbet, din smartklocka, dina appar, ditt 23andMe. Och bara du vet resten: vad du äter, vad du dricker, vilka kosttillskott du tar. Din träningsrutin. Dina symtom. Dina mål — oavsett om du försöker bli gravid, tränar för ett maraton, eller bara försöker känna dig mindre trött.

+

Oavsett om du är frisk och vill förbli det, navigerar en svår diagnos, eller tar hand om en familjemedlem som inte kan föra sin egen talan — ingen läkare ser hela bilden. Inget system kopplar ihop allt.

+

Men du har tillgång till allt. Du saknar bara expertisen att förstå allt.

+

Din AI har den. inou ger den hela bilden.

+
+
+
+
+
+

Utmaningen

+
Din MR har 4 000 snitt.
Den lästes på 10 minuter.
+
Ditt genom har miljontals varianter.
Du fick bara veta din ögonfärg och var dina förfäder kom ifrån.
+
Ditt blodprov har dussintals markörer.
Din läkare sa "allt ser bra ut."
+
Din klocka har registrerat 10 000 timmars sömn.
Din tränare vet inte att den finns.
+
Du har provat hundra olika kosttillskott.
Ingen frågade vilka.
+
Kopplingarna finns där.
De är bara för komplexa för en enda person.
+
+ Ingen vet hur din kropp metaboliserar Warfarin — inte ens du. + Men svaret kan redan vara gömt i ditt 23andMe. + Det där "utan anmärkning" på din MR — tittade någon verkligen noggrant på alla 4 000 snitt? + Din sköldkörtel är "inom normalvärden" — men ingen kopplade det till din trötthet, din vikt, att du alltid fryser. +
+
+ Ingen kopplar ditt eftermiddagskaffe till din sömnkvalitet. + Dina järnnivåer till din trötthet vid träning. + Din genetik till din hjärndimma. +
+
+ Din AI glömmer inte. + Stressar inte. + Hittar det som missades. + Specialiserar sig inte — ser dig som helhet. +
+
inou låter din AI ta hänsyn till allt — varje snitt, varje markör, varje variant — kopplar ihop allt och ger dig äntligen svar som ingen annan kunde ge.
+
+
+
+
+

Varför vi byggde detta

+

Du har samlat år av hälsodata. Undersökningar från sjukhuset. Prover från labbet. Resultat från patientportalen. Data från din klocka. Kanske till och med ditt DNA.

+

Och sedan finns allt som bara du vet — din vikt, ditt blodtryck, din träningsrutin, kosttillskotten du tar, symtomen du alltid glömmer nämna.

+

Allt finns där — men utspritt i system som inte pratar med varandra, hos specialister som bara ser sin del, eller låst i ditt eget huvud.

+

Din kardiolog vet inte vad din neurolog hittade. Din tränare har inte sett dina blodprover. Din läkare har ingen aning om vilka kosttillskott du tar. Och ingen av dem har tid att sitta ner med dig och koppla ihop punkterna.

+

AI kan äntligen göra det. Den kan samla det som ingen enskild expert ser — och dessutom förklara det för dig.

+

Men dessa data får inte plats i ett chattfönster. Och det sista du vill är din sjukdomshistorik på någon annans servrar, för att träna deras modeller.

+

inou samlar allt — labb, bilddiagnostik, genetik, vitalparametrar, mediciner, kosttillskott — krypterat, privat, och inte delat med absolut någon. Din AI ansluter säkert. Dina data förblir dina.

+
Din hälsa, förstådd.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_zh.tmpl b/templates/landing_zh.tmpl new file mode 100644 index 0000000..238ad5a --- /dev/null +++ b/templates/landing_zh.tmpl @@ -0,0 +1,121 @@ +{{define "landing_zh"}} + +
+
+
+
inou整理并与您的AI安全私密地共享您的健康档案。
+
您的健康,被理解。
+
{{if .Dossier}}邀请朋友{{else}}登录{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

您需要AI来管理健康

+
+

您的健康数据分散在数十个地方——心内科、神经科、检验科、智能手表、应用程序、23andMe。只有您知道其余的:吃什么、喝什么、服用什么补充剂。您的运动习惯。您的症状。您的目标——无论是想怀孕、为马拉松训练,还是只想少些疲惫。

+

无论您是健康并想保持健康,正在应对困难的诊断,还是在照顾无法为自己发声的家人——没有一位医生能看到全貌。没有系统能连接一切。

+

但您可以获取所有信息。您只是缺乏理解它的专业知识。

+

您的AI有这个能力。inou给它完整的画面。

+
+
+
+
+
+

挑战

+
您的MRI有4000张切片。
10分钟内被阅读完毕。
+
您的基因组有数百万个变异。
您只知道了眼睛颜色和祖先来源。
+
您的血液检查有数十个指标。
医生说一切看起来都好。
+
您的手表记录了10000小时的睡眠。
您的教练不知道它的存在。
+
您尝试过一百种不同的补充剂。
没人问过是哪些。
+
关联就在那里。
只是对一个人来说太复杂了。
+
+ 没人知道您的身体如何代谢华法林——包括您自己。 + 但答案可能就藏在您的23andMe里。 + MRI上那个未见异常——真的有人仔细看过所有4000张切片吗? + 您的甲状腺在正常范围内——但没人把它与您的疲劳、体重、总是怕冷联系起来。 +
+
+ 没人把您的下午咖啡与睡眠质量联系起来。 + 您的铁含量与训练疲劳。 + 您的基因与脑雾。 +
+
+ 您的AI不会忘记。 + 不会匆忙。 + 找到被遗漏的。 + 不专科——整体地看待您。 +
+
inou让您的AI考虑一切——每张切片、每个指标、每个变异——将所有连接起来,终于给您其他人无法给出的答案。
+
+
+
+
+

我们为什么创建这个

+

您收集了多年的健康数据。医院的检查。检验科的结果。患者门户的记录。手表的数据。也许还有您的DNA。

+

还有只有您知道的一切——您的体重、血压、训练计划、服用的补充剂、总是忘记提到的症状。

+

一切都在那里——但分散在互不沟通的系统中、只看自己领域的专科医生那里,或者锁在您自己的脑海中。

+

心内科医生不知道神经科医生发现了什么。您的教练没看过您的血液检查。您的医生不知道您服用什么补充剂。他们中没有一个人有时间坐下来与您一起连接这些点。

+

AI终于可以做到。它可以汇集任何单一专家都看不到的——还能向您解释。

+

但这些数据放不进聊天窗口。您最不想要的是您的病历在别人的服务器上,训练他们的模型。

+

inou将一切汇集——检验、影像、基因、生命体征、药物、补充剂——加密、私密,不与任何人共享。您的AI安全连接。数据仍然是您的。

+
您的健康,被理解。
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+ + inou health +
+
+{{end}} diff --git a/templates/login.tmpl b/templates/login.tmpl new file mode 100644 index 0000000..e264416 --- /dev/null +++ b/templates/login.tmpl @@ -0,0 +1,32 @@ +{{define "login"}} +
+ +
+
+
inou health

Sign in

+

Enter your email to sign in

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+ + +
+ +
+ +

+ We'll send you a verification code.
No password needed. +

+
+
+ + {{template "footer"}} + +
+ +{{end}} diff --git a/templates/minor_error.tmpl b/templates/minor_error.tmpl new file mode 100644 index 0000000..e8e167f --- /dev/null +++ b/templates/minor_error.tmpl @@ -0,0 +1,14 @@ +{{define "minor_error"}} +
+

{{.T.must_be_18}}

+ +
+

{{.T.minor_explanation}}

+

{{.T.minor_next_steps}}

+
+ + ← {{.T.use_different_dob}} + + {{template "footer"}} +
+{{end}} diff --git a/templates/onboard.tmpl b/templates/onboard.tmpl new file mode 100644 index 0000000..5dcd805 --- /dev/null +++ b/templates/onboard.tmpl @@ -0,0 +1,60 @@ +{{define "onboard"}} +
+ +
+
+
inou health
+

{{.T.create_dossier}}

+

{{.T.tell_us}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+ +
+
+
+ + {{template "footer"}} + +
+ +{{end}} \ No newline at end of file diff --git a/templates/permissions.tmpl b/templates/permissions.tmpl new file mode 100644 index 0000000..c67ad06 --- /dev/null +++ b/templates/permissions.tmpl @@ -0,0 +1,150 @@ +{{define "permissions"}} +
+ +
+
+
+
+

{{.T.permissions_title}}

+

{{.T.permissions_subtitle}}

+
+ ← {{.T.back}} +
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .Success}} +
{{.Success}}
+ {{end}} + + +
+

{{.T.current_access}}

+ +
+ {{if .Grantees}} + {{range .Grantees}} +
+
+ {{.Name}} + {{.Role}} + {{.Ops}} +
+
+ Edit +
+ + + +
+
+
+ {{end}} + {{else}} +

{{.T.no_grantees}}

+ {{end}} +
+
+ + +
+

{{.T.grant_access}}

+ +
+ + +
+ + + {{.T.person_email_hint}} +
+ +
+ + +
+ +
+ + +
+ + + +
+ {{.T.cancel}} + +
+
+
+ + +
+

{{.T.role_descriptions}}

+
+ {{range .Roles}} +
+
+ {{.Name}} + {{.Ops}} +
+

{{.Description}}

+
+ {{end}} +
+ +
+

{{.T.ops_legend}}

+

+ r = {{.T.op_read_desc}}  ·  + w = {{.T.op_write_desc}}  ·  + d = {{.T.op_delete_desc}}  ·  + m = {{.T.op_manage_desc}} +

+
+
+
+
+
+ + +{{end}} diff --git a/templates/pricing.tmpl b/templates/pricing.tmpl new file mode 100644 index 0000000..7638361 --- /dev/null +++ b/templates/pricing.tmpl @@ -0,0 +1,274 @@ +{{define "pricing"}} + + +
+
+

Pricing

+

Choose the plan that fits your health journey

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Monitor
+
Free
+
+
Optimize
+
$12/mo
+
+
Research
+
$35/mo
+
Health Data
Vitals (BP, HR, weight, temp)
Symptoms & conditions
Medications
Exercise & activity
Family history
Lab results
Consumer genome (23andMe)
Medical imaging (MRI, CT, X-ray)
Clinical genome sequencing
AI Features
MCP integration (Claude, ChatGPT)
Personalized AI answersLimited
Health trend analysis
Storage & Access
Multi-dossier support (family)
FIPS 140-3 encryption
Data export
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/privacy.tmpl b/templates/privacy.tmpl new file mode 100644 index 0000000..6d17423 --- /dev/null +++ b/templates/privacy.tmpl @@ -0,0 +1,232 @@ +{{define "privacy"}} + + +
+ +
+

Your data. Your rules.

+

We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.

+
+ +
+

What we collect

+ +

Account information.

+

Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.

+ +

Medical files you upload.

+

DICOM images (MRI, CT, X-ray), lab results, genetic data, and any other health information you choose to share — photos, measurements, symptoms, or anything else you want to track or discuss with your AI.

+ +

Security logs.

+

IP addresses, for security purposes only. We do not collect physical addresses or phone numbers. Payment is handled by third-party processors — we never see your card details.

+
+ +
+

How we use it

+

Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.

+
+ +
+

What we promise

+ +

We never share your data.

+

Not with advertisers. Not with partners. Not with anyone. We will comply with lawful requests from authorities (such as court orders or subpoenas), but nothing else. In the event of a company acquisition, your data would not be sold — it would either transfer under the same privacy terms or be deleted.

+ +

We never train AI on your data.

+

Your scans, your labs, your DNA — none of it feeds any model. Period.

+ +

We never sell your data.

+

There is no business model that involves your information. You are the customer, not the product.

+ +

We never track you.

+

No Google Analytics. No Meta pixels. No tracking scripts. We have no idea what you click, where you came from, or where you go next.

+ +

We never look at your data.

+

Access requires your explicit request, is restricted to senior staff, and is logged in both your audit trail and ours.

+ +

One cookie.

+

We use one cookie to keep you logged in. Your language preference is stored in your account. No tracking, no analytics, no third parties.

+
+ +
+

How we protect it

+ +

HIPAA-grade security.

+

HIPAA is the US law that governs how medical records must be protected. We follow those same standards.

+ +

FIPS 140-3 encryption.

+

FIPS 140-3 is the US government standard for cryptographic security. Your files are encrypted using FIPS 140-3 validated cryptography — tested, audited, and certified by independent labs.

+ +

Independent infrastructure.

+

We don't run on Big Tech clouds. No Google. No Amazon. No Microsoft. Data is stored on servers in the United States. If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.

+
+ +
+

What you control

+ +

See everything.

+

Request a full export of everything we store — in a format you can actually use.

+ +

Fix anything.

+

Found a mistake? You can correct it yourself, or ask us to help.

+ +

Delete everything.

+

One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster — we do not offer restores of individual accounts or deleted data.

+ +

Take it with you.

+

Want to move to another service? We'll export your data in standard formats. You're never locked in.

+ +

Change your mind.

+

Gave us permission for something? Revoke it anytime. We stop immediately.

+
+ +
+

About your AI

+ +

When you connect your AI to inou, your data travels through an encrypted bridge directly to your AI session.

+ +

What we control: keeping your data encrypted, secure, and private on our side.

+ +

What we can't control: what happens once your AI processes it. Each AI provider has their own privacy policy. We encourage you to read it.

+ +

We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.

+
+ +
+

Children's privacy

+

inou is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.

+
+ +
+

The legal stuff

+

We comply with FADP (Swiss data protection), GDPR (European data protection), and HIPAA (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.

+

We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.

+

Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.

+

Questions, concerns, or requests: privacy@inou.com

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/prompts.tmpl b/templates/prompts.tmpl new file mode 100644 index 0000000..555208b --- /dev/null +++ b/templates/prompts.tmpl @@ -0,0 +1,760 @@ +{{define "prompts"}} +
+

{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in

+

Track daily measurements and observations

+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + + {{if .Prompts}} +
+ +
+
+ +
+ +
+ {{range .Entries}} +
+ +
+ {{.Question}} + {{.TimeFormatted}} +
+
+ {{range .Fields}} +
+ {{if eq .Type "number"}} + {{.Value}} + {{if .Unit}}{{.Unit}}{{end}} + {{else if eq .Type "checkbox"}} + {{if .Value}}✓{{else}}—{{end}} + {{if .Label}}{{.Label}}{{end}} + {{else}} + {{.Value}} + {{end}} +
+ {{end}} +
+ + {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} +
+ {{end}} + {{range .Prompts}} + {{$prompt := .}} +
+ + +
+ Stop tracking? + Yes + No +
+ + +
+
+ {{.Question}} + {{.LastResponseRaw}} +
+ + {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} +
+ + + +
+ {{end}} +
+
+ {{else}} +
+

All caught up! No items due right now.

+ View all items +
+ {{end}} + + + + {{template "footer"}} +
+ + + + +{{end}} diff --git a/templates/security.tmpl b/templates/security.tmpl new file mode 100644 index 0000000..330437f --- /dev/null +++ b/templates/security.tmpl @@ -0,0 +1,180 @@ +{{define "security"}} + + +
+ +
+

How we protect your health dossier.

+

Security isn't a feature we added. It's how we built inou from day one.

+
+ +
+

Your data never shares a server.

+

Most services run on shared cloud infrastructure — your files sitting next to thousands of strangers. Not here. inou runs on dedicated, single-tenant hardware. Your data lives on machines that exist solely for this purpose.

+
+ +
+

Encryption you can trust.

+

FIPS 140-3 is the US government standard for cryptographic security — the same bar the military uses. Your files are encrypted in flight with TLS 1.3, encrypted again at the application layer before they touch the database, and stay encrypted at rest. Three layers deep.

+
+ +
+

Power doesn't go out.

+

Servers run on uninterruptible power, backed by a natural gas generator. Not a battery that buys you fifteen minutes — a generator with fuel supply independent of the grid. If the power company fails, we don't.

+
+ +
+

Drives fail. Data doesn't.

+

Storage runs on ZFS with RAID-Z2 — enterprise technology that survives the simultaneous failure of any two drives without losing a byte. Backups happen automatically. (Our founder spent two decades building backup systems for a living. We take this seriously.)

+
+ +
+

The internet has a backup too.

+

Primary connectivity is dedicated fiber. If that fails, satellite kicks in. Terrestrial and space-based redundancy — because your access matters.

+
+ +
+

We watch. We act.

+

Continuous uptime monitoring, automated alerting, 24/7. If something blinks wrong, we know — and our systems respond before you'd ever notice.

+
+ +
+

We keep attackers out.

+

Firewall rules block malicious traffic at the edge. Tarpits slow down scanners and bots, wasting their time instead of ours. Role-based access control ensures every request is authenticated and authorized — no exceptions.

+
+ +
+

Built with intention.

+

Most software is assembled from open source libraries — code written by strangers, maintained by volunteers, used by millions. When a vulnerability is discovered, every application using that code is at risk.

+

We made a different choice. inou is built entirely from proprietary code. We wrote every line ourselves. No third-party frameworks, no borrowed components, no dependency trees stretching into code we've never reviewed.

+

This means we know exactly what's running and exactly what's exposed. A minimal risk surface — not because we added security on top, but because we designed it that way from the beginning.

+
+ +
+

Physical security.

+

Hardware is housed in secured, access-controlled enclosures. Entry restricted to authorized inou personnel only. Climate-controlled, generator-backed, sited above regional flood levels.

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/share.tmpl b/templates/share.tmpl new file mode 100644 index 0000000..7b464f3 --- /dev/null +++ b/templates/share.tmpl @@ -0,0 +1,60 @@ +{{define "share"}} +
+ +
+
+

{{.T.share_access}}

+

{{.T.share_access_intro}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ {{.T.cancel}} + +
+
+
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/styleguide.tmpl b/templates/styleguide.tmpl new file mode 100644 index 0000000..64b8a8b --- /dev/null +++ b/templates/styleguide.tmpl @@ -0,0 +1,496 @@ +{{define "styleguide"}} + +
+ +

Style Guide

+

Design system components for inou

+ + +
+
+
+
+

Text Blocks

+
+
+
+

Your data. Your rules.

+

+ We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought. +

+

What we collect

+

Account information.

+

+ Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old. +

+
+
+ + +
+
+
+
+

Typography Scale

+
+
+
+
Page Title
2.5rem / 700
+
Section Title
1.4rem / 600
+
Subsection Title
1.1rem / 600
+
LABEL / CATEGORY
0.75rem / 600 / caps
+
Intro text — larger, lighter
1.15rem / 300
+
Body light — long-form
1rem / 300
+
Body regular — UI labels
1rem / 400
+
Mono: 1,234,567.89
SF Mono
+
+
+ + +
+
+
+
+

Colors

+
+
+
+
Accent
#B45309
+
Text
#1C1917
+
Text Muted
#78716C
+
Background
#F8F7F6
+
Success
#059669
+
Danger
#DC2626
+
+
+ + +
+
+
+
+

Buttons

+
+
+
+ + + + + +
+
+ + +
+
+
+
+

Badges

+
+
+
+ default + care + COMING SOON + processing +
+
+ + +
+
+
+
+

Messages

+
+
+
+
Error message — something went wrong.
+
Info message — here's some useful information.
+
Success message — operation completed.
+
+
+ + +
+
+
+
+

Form Elements

+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+

SETTINGS

+ Preferences +
+
+
+
+
+
Primary AI Assistant
+
Used for "Ask AI" prompts and analysis
+
+
+ + + +
+
+
+
+
Units
+
Measurement system for vitals
+
+
+ +
+
+
+
+ + +
+
+
+
+

Profile Cards

+
+
+
+
+
+

Johan Jongsma

+

you

+

Born: 1985-03-15 · Male

+
📷 3 studies🧪 12 labs🧬 genome
+ +
+
+

Sophia

+

my role: Parent · care

+

Born: 2017-01-01 · Female

+
📷 16 studies🧪 0 labs
+ +
+ +Add dossier +
+
+
+ + +
+
+
+
+

IMAGING

+ 16 studies · 4113 slices +
+ Open viewer +
+
+ +
+
AX T1
24 slices
+
AX T2 FLAIR
24 slices
+
SAG T1
20 slices
+
+
XR CHEST AP ONLY
5/6/2022
+
+
+ + +
+
+
+
+

LABS

+ 4 panels · 23 results +
+
+
+ +
+
Hemoglobin
14.2 g/dL12.0–16.0
+
White Blood Cells
7.8 K/µL4.5–11.0
+
Platelets
142 K/µL150–400
+
+
+
+ + +
+
+
+
+

GENETICS

+ 23andMe · 847 variants analyzed +
+
+
+ +
+
+
+
+
CYP2C19rs4244285
+
G;Aintermediate
+
+
Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.
+
+
+
+
Show all 47 variants in Medication Response →
+
+ + +
+
+ + +
+
+
+
+

VITALS

+ Self-reported measurements +
+ + Add +
+
+ +
+
+
+
+
+
+
+
+
+
Today, 8:30 AM37.2 °C
+
Yesterday, 8:15 AM36.8 °C
+
Dec 24, 7:45 AM37.0 °C
+
+
+ + +
+
+ + +
+
+
+
+

NOTES

+ Photos, observations, symptoms +
+ + Add +
+
+ +
+
+
+
+
🦵
+
Dec 20, 3:45 PM
+
+
+
🦵
+
Dec 22, 10:20 AM
+
+
+
🦵
+
Dec 26, 9:15 AM
+
+
+
+
+
Add photo
+
+
+
+
+ Dec 20, 3:45 PM + Jim fell on his knee at soccer practice. Swelling visible, applied ice. +
+
+ Dec 22, 10:20 AM + Swelling reduced. Still some bruising. Can walk without pain. +
+
+ Dec 26, 9:15 AM + Almost fully healed. Light bruise remaining. +
+
+
+ + +
+
+
+ +
+
+ + +
+
+
+
+

SUPPLEMENTS

+ Daily stack +
+ + Add +
+
+
+
+ Vitamin D3 + 1 capsule + · 5000 IU +
+
morning, with food
+
+
+
+ Omega-3 Fish Oil + 2 capsules + · 2000 mg EPA/DHA +
+
morning, with food
+
+
+
+ Magnesium Glycinate + 2 capsules + · 400 mg +
+
evening
+
+
+
+ Liquid B12 + 5 ml + · 1000 mcg +
+
morning
+
+
+
+ + +
+
+
+
+

PEPTIDES

+ Current & past protocols +
+ + Add +
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+ until Jan 23, 2025 +
+
active
+
+
+
+
TB-5002.5 mg subQ · 2x weekly
+ until Feb 5, 2025 +
+
active
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+ Aug 15 – Sep 7, 2025 +
+
completed
+
+
+
+ + +
+
+
+
+

Upload Area

+
+
+
+
+
+

Click or drag files here

+

DICOM, PDF, CSV, VCF, and more

+
+
+
+ + +
+
+
+
+

Empty State

+
+
+
+
No lab data
+
+
+ + {{template "footer"}} + +
+ + +
+
+

Ask AI about CYP2C19

+
I have a genetic variant in CYP2C19 (rs4244285) with genotype G;A. + +This makes me an intermediate metabolizer. + +What medications are affected by this? What should I discuss with my doctor?
+
+ + +
+
+
+ +{{end}} diff --git a/templates/upload.tmpl b/templates/upload.tmpl new file mode 100644 index 0000000..87dec91 --- /dev/null +++ b/templates/upload.tmpl @@ -0,0 +1,187 @@ +{{define "upload"}} +
+

← Back to {{.TargetDossier.Name}}

+ +

Upload health data

+

Files are automatically deleted after 7 days

+ +
+ + +
+ +
+
+ + + +
+

Click or drag files here

+

DICOM, PDF, CSV, VCF, and more

+
+ + + {{if .UploadList}} + +
+ {{range .UploadList}} +
+
+ {{.FileName}} + + {{if and (not .Deleted) (eq .Status "uploaded")}} + + {{else}} + {{.Category}} + {{end}} + · {{.SizeHuman}} · {{.UploadedAt}} + +
+
+ {{if .Deleted}} + {{.DeletedReason}} + {{else}} + {{if ne .Status "uploaded"}}{{.Status}}{{end}} + Expires {{.ExpiresAt}} + + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ No files uploaded yet +
+ {{end}} +
+ +
+
+

Uploading...

+
+
+
+

+
+
+ + +{{end}} diff --git a/templates/verify.tmpl b/templates/verify.tmpl new file mode 100644 index 0000000..d3cda49 --- /dev/null +++ b/templates/verify.tmpl @@ -0,0 +1,33 @@ +{{define "verify"}} +
+ +
+
+
inou health
+

{{.T.check_email}}

+

{{.T.code_sent_to}}
{{.Email}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+ + +
+ +
+ +

+ {{.T.use_different_email}} +

+
+
+ + {{template "footer"}} + +
+{{end}}