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
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.inou.inou_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
|
|
@ -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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
#import "GeneratedPluginRegistrant.h"
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Barrel file for auth pages
|
||||
export 'login_page.dart';
|
||||
export 'signup_page.dart';
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:inou_app/design/inou_theme.dart';
|
||||
import 'package:inou_app/design/inou_text.dart';
|
||||
import 'package:inou_app/design/widgets/widgets.dart';
|
||||
|
||||
/// Login page with biometric support
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export 'models.dart';
|
||||
export 'mock_data.dart';
|
||||
export 'dashboard_page.dart';
|
||||
export 'dossier_page.dart';
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:inou_app/design/inou_theme.dart';
|
||||
import 'package:inou_app/design/inou_text.dart';
|
||||
import 'package:inou_app/design/widgets/widgets.dart';
|
||||
import 'models.dart';
|
||||
import 'mock_data.dart';
|
||||
|
||||
class DashboardPage extends StatelessWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InouAuthPage(
|
||||
userName: 'Johan', // TODO: get from auth state
|
||||
onProfileTap: () => context.go('/profile'),
|
||||
onLogoutTap: () => context.go('/login'),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Text('Dossiers', style: InouText.pageTitle),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Manage your health dossiers and those shared with you.',
|
||||
style: InouText.intro,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Dossier grid
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = constraints.maxWidth > 900
|
||||
? 3
|
||||
: constraints.maxWidth > 600
|
||||
? 2
|
||||
: 1;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.6,
|
||||
),
|
||||
itemCount: mockDossiers.length + 1, // +1 for add card
|
||||
itemBuilder: (context, index) {
|
||||
if (index == mockDossiers.length) {
|
||||
return _AddDossierCard();
|
||||
}
|
||||
return _DossierCard(dossier: mockDossiers[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
const InouFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DossierCard extends StatelessWidget {
|
||||
final DossierSummary dossier;
|
||||
|
||||
const _DossierCard({required this.dossier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => context.go('/dossier/${dossier.id}'),
|
||||
borderRadius: InouTheme.borderRadiusLg,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: InouTheme.bgCard,
|
||||
border: Border.all(color: InouTheme.border),
|
||||
borderRadius: InouTheme.borderRadiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row with name and edit
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dossier.name,
|
||||
style: InouText.h3.copyWith(fontSize: 20),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (dossier.canEdit)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
color: InouTheme.textMuted,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
// TODO: navigate to edit
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Relation/role
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
dossier.isSelf
|
||||
? 'you'
|
||||
: 'my role: ${dossier.relation ?? "Unknown"}',
|
||||
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
|
||||
),
|
||||
if (dossier.isCareReceiver) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: InouTheme.successLight,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'care',
|
||||
style: InouText.bodySmall.copyWith(
|
||||
color: InouTheme.success,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// DOB & Sex
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
[
|
||||
if (dossier.dateOfBirth != null) dossier.dateOfBirth,
|
||||
if (dossier.sex != null) dossier.sex,
|
||||
].join(' · '),
|
||||
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Stats row
|
||||
if (dossier.stats.hasAnyData) ...[
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (dossier.stats.imaging > 0)
|
||||
_StatChip(
|
||||
'📷 ${dossier.stats.imaging} ${dossier.stats.imaging == 1 ? 'study' : 'studies'}',
|
||||
),
|
||||
if (dossier.stats.labs > 0)
|
||||
_StatChip(
|
||||
'🧪 ${dossier.stats.labs} ${dossier.stats.labs == 1 ? 'lab' : 'labs'}',
|
||||
),
|
||||
if (dossier.stats.genome) const _StatChip('🧬 genome'),
|
||||
if (dossier.stats.documents > 0)
|
||||
_StatChip(
|
||||
'📄 ${dossier.stats.documents} ${dossier.stats.documents == 1 ? 'doc' : 'docs'}',
|
||||
),
|
||||
if (dossier.stats.medications > 0)
|
||||
_StatChip('💊 ${dossier.stats.medications} meds'),
|
||||
if (dossier.stats.supplements > 0)
|
||||
_StatChip('🌿 ${dossier.stats.supplements} supps'),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
'No data yet',
|
||||
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// View button
|
||||
InouButton(
|
||||
text: 'View',
|
||||
size: ButtonSize.small,
|
||||
onPressed: () => context.go('/dossier/${dossier.id}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatChip extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _StatChip(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: InouText.bodySmall.copyWith(
|
||||
color: InouTheme.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddDossierCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// TODO: navigate to add dossier
|
||||
},
|
||||
borderRadius: InouTheme.borderRadiusLg,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: InouTheme.border,
|
||||
width: 2,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: InouTheme.borderRadiusLg,
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: _DashedBorderPainter(),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'+',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: InouTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Add dossier',
|
||||
style: InouText.body.copyWith(color: InouTheme.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Empty - we use the container border for now
|
||||
// Could implement dashed border if needed
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||