Merge remote 'Initial commit from dev' with local master

Conflicts resolved:
- soc2 docs: used remote (updated versions)
- go.mod/go.sum: kept local (full dependencies)
- lib/*.go: kept local (production FIPS, no hardcoded keys)
- .gitignore: kept local (comprehensive)
- test/*.sh: kept local (executable permissions)

Includes: Flutter app, design system, templates, static assets
This commit is contained in:
Johan 2026-02-01 04:00:45 -05:00
commit 9190ca1443
320 changed files with 41610 additions and 10 deletions

BIN
._.DS_Store Normal file

Binary file not shown.

BIN
._inou.db Normal file

Binary file not shown.

BIN
._start.sh Normal file

Binary file not shown.

BIN
._status.sh Normal file

Binary file not shown.

BIN
._stop.sh Normal file

Binary file not shown.

43
app/.gitignore vendored Normal file
View File

@ -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

45
app/.metadata Normal file
View File

@ -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'

105
app/FLUTTER_TASK.md Normal file
View File

@ -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

16
app/README.md Normal file
View File

@ -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.

28
app/analysis_options.yaml Normal file
View File

@ -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

13
app/android/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="inou_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.inou.inou_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

18
app/android/build.gradle Normal file
View File

@ -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
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@ -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

View File

@ -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"

BIN
app/fonts/Sora-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
app/fonts/Sora-Light.ttf Normal file

Binary file not shown.

BIN
app/fonts/Sora-Regular.ttf Normal file

Binary file not shown.

BIN
app/fonts/Sora-SemiBold.ttf Normal file

Binary file not shown.

BIN
app/fonts/Sora-Thin.ttf Normal file

Binary file not shown.

34
app/ios/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -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)
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -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.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
app/ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Inou App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>inou_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -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.
}
}

4
app/l10n.yaml Normal file
View File

@ -0,0 +1,4 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations

View File

@ -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<Locale> supportedLocales = [
Locale('en'), // English
Locale('nl'), // Dutch
Locale('ru'), // Russian
];
/// Locale display names
static const Map<String, String> localeNames = {
'en': 'English',
'nl': 'Nederlands',
'ru': 'Русский',
};
/// Short codes for display in header
static const Map<String, String> localeCodes = {
'en': 'EN',
'nl': 'NL',
'ru': 'RU',
};
LocaleProvider() {
_loadLocale();
}
/// Load saved locale from preferences
Future<void> _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<void> 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';
}

116
app/lib/core/router.dart Normal file
View File

@ -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<dynamic>? generateRoute(RouteSettings settings) {
// Keep for backwards compatibility if needed
return MaterialPageRoute(
builder: (_) => const LandingPage(),
settings: settings,
);
}

View File

@ -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<InlineSpan> 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
);
}
}

View File

@ -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,
),
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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});
}

View File

@ -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(),
);
}
}

View File

@ -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<ProfileStat> 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),
),
],
),
),
);
}
}

View File

@ -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<Widget>? 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<InouDataRow> createState() => _InouDataRowState();
}
class _InouDataRowState extends State<InouDataRow> {
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)),
);
}
}

View File

@ -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<FooterLink> 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<FooterLinkGroup> 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);
}
}

View File

@ -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<NavItem> 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<Locale>(
valueListenable: localeNotifier,
builder: (context, locale, _) {
final currentCode = LocaleProvider.localeCodes[locale.languageCode] ?? 'EN';
return PopupMenuButton<Locale>(
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<Locale>(
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),
],
],
),
),
],
);
},
);
}
}

View File

@ -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<String>? onChanged;
final FormFieldValidator<String>? validator;
final Widget? suffixIcon;
final Iterable<String>? 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<T> extends StatelessWidget {
final String? label;
final T? value;
final List<InouSelectOption<T>> options;
final ValueChanged<T?>? 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<T>(
value: value,
isExpanded: true,
items: options
.map((o) => DropdownMenuItem(
value: o.value,
child: Text(o.label),
))
.toList(),
onChanged: onChanged,
),
),
),
],
);
}
}
class InouSelectOption<T> {
final T value;
final String label;
const InouSelectOption({required this.value, required this.label});
}
/// Radio group
class InouRadioGroup<T> extends StatelessWidget {
final String? label;
final String? hint;
final T? value;
final List<InouRadioOption<T>> options;
final ValueChanged<T?>? 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<Widget> _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<T>(
value: option.value,
groupValue: value,
onChanged: onChanged,
activeColor: InouTheme.accent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
Text(option.label, style: InouText.body),
],
),
),
);
}).toList();
}
}
class InouRadioOption<T> {
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<bool?>? 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),
),
),
],
),
);
}
}

View File

@ -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,
});
}

View File

@ -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<NavItem>? 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<Widget>? 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<String>(
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,
],
),
),
),
),
),
);
}
}

View File

@ -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';

View File

@ -0,0 +1,3 @@
// Barrel file for auth pages
export 'login_page.dart';
export 'signup_page.dart';

View File

@ -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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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<void> _handleBiometricLogin() async {
// TODO: Implement biometric authentication using local_auth
// On success, retrieve stored credentials and login
}
Future<void> _handleForgotPassword() async {
// Navigate to forgot password flow
Navigator.pushNamed(context, '/forgot-password');
}
Future<void> _offerBiometricEnrollment() async {
final shouldEnroll = await showDialog<bool>(
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
}
}
}

View File

@ -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<SignupPage> createState() => _SignupPageState();
}
class _SignupPageState extends State<SignupPage> {
final _formKey = GlobalKey<FormState>();
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<String>(
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<void> _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<void> _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);
}
}
}
}

View File

@ -0,0 +1,4 @@
export 'models.dart';
export 'mock_data.dart';
export 'dashboard_page.dart';
export 'dossier_page.dart';

View File

@ -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;
}

View File

@ -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<ImagingStudy> studies;
final String dossierId;
const _ImagingSection({required this.studies, required this.dossierId});
@override
Widget build(BuildContext context) {
final totalSlices = studies.fold<int>(0, (sum, s) => sum + s.series.fold<int>(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<DataItem> 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<DataItem> 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<DataItem> 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<DataItem> 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<GeneticCategory> categories;
const _GeneticsSection({required this.categories});
@override
Widget build(BuildContext context) {
final totalShown = categories.fold<int>(0, (sum, c) => sum + c.shown);
final totalHidden = categories.fold<int>(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<DataItem> 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<DataItem> 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<DataItem> 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<DataItem> 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<AccessEntry> 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),
),
),
);
}
}

View File

@ -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;
}
}

View File

@ -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<ImagingSeries> 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<ImagingStudy> studies;
final List<DataItem> labs;
final List<DataItem> documents;
final List<DataItem> procedures;
final List<DataItem> assessments;
final List<DataItem> medications;
final List<DataItem> symptoms;
final List<DataItem> hospitalizations;
final List<DataItem> therapies;
final List<GeneticCategory> geneticCategories;
final List<AccessEntry> 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,
});
}

View File

@ -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<ConnectPage> createState() => _ConnectPageState();
}
class _ConnectPageState extends State<ConnectPage> {
final _formKey = GlobalKey<FormState>();
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<String>(
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<void> _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();
}
}
}

Some files were not shown because too many files have changed in this diff Show More