commit a899479dc9b7796d2fd4a75a21df1d5f16a6f707 Author: = Date: Mon Sep 21 16:33:51 2026 -0400 first commit diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..06bcdee --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.41.0" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c9aee --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +scratch/ + +# Symbol processing related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will create this file on each build +AndroidStudioReverseSearch.index + +# FVM +.fvm/flutter_sdk +.fvm/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..09f416b --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: android + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: ios + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: linux + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: macos + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: web + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: windows + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..abab840 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# hidalyx_drluiscastro + +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: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..cea638a --- /dev/null +++ b/agent.md @@ -0,0 +1,57 @@ +# REGLAS Y DIRECTRICES DEL AGENTE + +## 1. REGLA FUNDAMENTAL DE SEGURIDAD Y CONTROL (CRÍTICA) +> **NUNCA realizar modificaciones ni en Directus (creación/edición de tablas, campos, usuarios, registros, etc.) ni en la futura aplicación (código, dependencias, estructura) sin la autorización EXPLÍCITA del usuario.** +> +> El rol del agente ante cualquier cambio es: +> 1. Realizar análisis técnicos exhaustivos. +> 2. Presentar reportes detallados, planes de mejora o planes de migración/creación paso a paso. +> 3. Esperar la aprobación y confirmación explícita del usuario antes de ejecutar cualquier acción que modifique el estado o los datos. + +--- + +## 2. CONTEXTO DEL PROYECTO +- **Nombre oficial del sistema:** Hidalyx Med (antiguo AlfaMedical) +- **Backend / API:** Directus (https://api.hidalyx.com/) +- **Frontend Web:** Flutter Web +- **Fuente de datos original:** SQLite local ubicado en C:\Users\luism\Downloads\alfaMedical\data.sqlite +- **Alcance inicial de migración:** + - Estructura y datos de 2 tablas: pacientes y configuracion. + - Creación del usuario del doctor en directus_users. + +--- + +## 3. REGLA DE RESPONSIVIDAD NATURAL (WIDGETS FLEXIBLES) +> **El diseño responsive debe ser natural y fluido, basado en widgets flexibles y adaptativos nativos de Flutter.** +> +> - **Prohibición:** NO crear clases utilitarias rígidas de `MediaQuery` ni estructuras con condicionales forzados de breakpoints (`isMobile`, `isTablet`, `isDesktop`). +> - **Mandato:** Utilizar widgets que gestionen su espacio de forma intrínseca y fluida: +> - `Flexible` y `Expanded` +> - `Wrap` (para fluidez y salto de línea automático de elementos y chips) +> - `LayoutBuilder` (cuando los constraints directos del padre lo demanden) +> - `ConstrainedBox` y `BoxConstraints` (para fijar anchos máximos de lectura o formularios sin desbordes) +> - `FittedBox` y `FractionallySizedBox` +> - `SingleChildScrollView` y grids adaptativos (`GridView.extent`) + +--- + +## 4. ESTRUCTURA ESTÁNDAR POR CARACTERÍSTICA (FEATURE) +> **Toda característica dentro de `lib/features/` debe organizarse obligatoriamente bajo la siguiente estructura:** +> ```text +> features// +> ├── data/ +> │ ├── models/ # Modelos y clases para serializar o deserializar data +> │ └── services/ # Clase que engloba los endpoints en métodos listos para consumo +> └── presentation/ +> ├── store/ # Clases del gestor de estado MobX +> ├── views/ # Vistas de la característica +> └── widgets/ # Widgets de la característica +> ``` + +--- + +## 5. CONSULTA OBLIGATORIA DEL SISTEMA ANTERIOR (DESKTOP) +> **Antes de implementar o modificar cualquier lógica, métrica, condición de negocio, consulta o flujo de trabajo, el agente DEBE consultar y analizar el código fuente del sistema anterior ubicado en `C:\Users\luism\Downloads\alfaMedical\` (especialmente `index.py`, `index2.py` y `data.sqlite`).** +> - Se debe verificar exactamente cómo se estructuraban las consultas SQL originales, qué campos y tablas intervenían, y cuáles eran las condiciones y reglas de negocio para replicarlas con fidelidad técnica total. + + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..eb483be --- /dev/null +++ b/android/app/build.gradle.kts @@ -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.hidalyx.hidalyx_drluiscastro" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.hidalyx.hidalyx_drluiscastro" + // 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.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a002798 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/hidalyx/hidalyx_drluiscastro/MainActivity.kt b/android/app/src/main/kotlin/com/hidalyx/hidalyx_drluiscastro/MainActivity.kt new file mode 100644 index 0000000..54475ba --- /dev/null +++ b/android/app/src/main/kotlin/com/hidalyx/hidalyx_drluiscastro/MainActivity.kt @@ -0,0 +1,5 @@ +package com.hidalyx.hidalyx_drluiscastro + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + 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.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/fonts/Poppins-Bold.ttf b/assets/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/assets/fonts/Poppins-Bold.ttf differ diff --git a/assets/fonts/Poppins-Medium.ttf b/assets/fonts/Poppins-Medium.ttf new file mode 100644 index 0000000..a590f5c Binary files /dev/null and b/assets/fonts/Poppins-Medium.ttf differ diff --git a/assets/fonts/Poppins-Regular.ttf b/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/assets/fonts/Poppins-Regular.ttf differ diff --git a/assets/fonts/Poppins-SemiBold.ttf b/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/assets/images/hidalyx_logo_blue.svg b/assets/images/hidalyx_logo_blue.svg new file mode 100644 index 0000000..93598ae --- /dev/null +++ b/assets/images/hidalyx_logo_blue.svg @@ -0,0 +1,97 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2009bf7 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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 */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.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.hidalyx.hidalyxDrluiscastro; + 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.hidalyx.hidalyxDrluiscastro.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.hidalyx.hidalyxDrluiscastro.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.hidalyx.hidalyxDrluiscastro.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 = 13.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 = 13.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.hidalyx.hidalyxDrluiscastro; + 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.hidalyx.hidalyxDrluiscastro; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..3dc4c3b --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hidalyx Drluiscastro + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + hidalyx_drluiscastro + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart new file mode 100644 index 0000000..fd8b436 --- /dev/null +++ b/lib/core/network/api_client.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; + +typedef TokenRefreshCallback = Future Function(); + +class ApiClient { + static const String baseUrl = 'https://api.hidalyx.com'; + static final ApiClient _instance = ApiClient._internal(); + + factory ApiClient() => _instance; + + final Dio dio; + TokenRefreshCallback? onTokenRefresh; + + ApiClient._internal({Dio? customDio}) + : dio = customDio ?? + Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + }, + ), + ) { + _setupInterceptors(); + } + + ApiClient.withDio(this.dio) { + _setupInterceptors(); + } + + void _setupInterceptors() { + dio.interceptors.add( + QueuedInterceptorsWrapper( + onRequest: (RequestOptions options, RequestInterceptorHandler handler) { + final path = options.path; + if (path.contains('/auth/login')) { + // NUNCA enviar Authorization header en el endpoint de login + options.headers.remove('Authorization'); + } + return handler.next(options); + }, + onError: (DioException err, ErrorInterceptorHandler handler) async { + final statusCode = err.response?.statusCode; + final path = err.requestOptions.path; + final isAuthEndpoint = path.contains('/auth/login') || + path.contains('/auth/refresh') || + path.contains('/auth/logout'); + final isExpiredOrUnauthorized = statusCode == 401 || + (statusCode == 403 && + err.response?.data?.toString().contains('TOKEN_EXPIRED') == true); + + if (isExpiredOrUnauthorized && !isAuthEndpoint && onTokenRefresh != null) { + try { + final newToken = await onTokenRefresh!(); + if (newToken != null && newToken.isNotEmpty) { + setAuthToken(newToken); + final requestOptions = err.requestOptions; + requestOptions.headers['Authorization'] = 'Bearer $newToken'; + + final retryResponse = await dio.fetch(requestOptions); + return handler.resolve(retryResponse); + } + } catch (_) { + // Si el refresh falla, continuar el flujo del error normal + } + } + return handler.next(err); + }, + ), + ); + } + + void setAuthToken(String token) { + dio.options.headers['Authorization'] = 'Bearer $token'; + } + + void clearAuthToken() { + dio.options.headers.remove('Authorization'); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..3234d94 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,22 @@ +import 'package:go_router/go_router.dart'; +import '../../features/auth/presentation/views/login_view.dart'; +import '../../features/dashboard/presentation/views/dashboard_view.dart'; + +class AppRouter { + static GoRouter getRouter([String initialLocation = '/login']) => GoRouter( + initialLocation: initialLocation, + routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginView(), + ), + GoRoute( + path: '/', + name: 'dashboard', + builder: (context, state) => const DashboardView(), + ), + ], + ); +} + diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..99e9611 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Color institucional exacto de Hidalyx (extraído del SVG oficial) + static const Color primary = Color(0xFF2251FF); + static const Color primaryDark = Color(0xFF143CD6); + static const Color primaryLight = Color(0xFF5A80FF); + static const Color primaryContainer = Color(0xFFEEF2FF); + + // Acentos médicos complementarios + static const Color secondary = Color(0xFF0EA5E9); + static const Color teal = Color(0xFF00C9A7); + + // Superficies y fondos + static const Color background = Color(0xFFF8FAFC); + static const Color surface = Color(0xFFFFFFFF); + static const Color cardBg = Color(0xFFFFFFFF); + + // Bordes y divisores + static const Color border = Color(0xFFE2E8F0); + static const Color divider = Color(0xFFEDF2F7); + + // Textos + static const Color textPrimary = Color(0xFF0F172A); + static const Color textSecondary = Color(0xFF64748B); + static const Color textMuted = Color(0xFF94A3B8); + + // Estados + static const Color success = Color(0xFF10B981); + static const Color warning = Color(0xFFF59E0B); + static const Color error = Color(0xFFEF4444); + static const Color info = Color(0xFF2251FF); +} + diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..9a3d83a --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +class AppTheme { + static const String fontFamily = 'Poppins'; + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + fontFamily: fontFamily, + scaffoldBackgroundColor: AppColors.background, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + primary: AppColors.primary, + secondary: AppColors.secondary, + surface: AppColors.surface, + error: AppColors.error, + brightness: Brightness.light, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontFamily: fontFamily, + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + cardTheme: CardThemeData( + color: AppColors.cardBg, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: AppColors.border), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + textStyle: const TextStyle( + fontFamily: fontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary, width: 1.5), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + textStyle: const TextStyle( + fontFamily: fontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.error), + ), + labelStyle: const TextStyle( + fontFamily: fontFamily, + fontSize: 14, + color: AppColors.textSecondary, + ), + hintStyle: const TextStyle( + fontFamily: fontFamily, + fontSize: 14, + color: AppColors.textMuted, + ), + ), + dividerTheme: const DividerThemeData( + color: AppColors.divider, + thickness: 1, + space: 1, + ), + ); + } +} diff --git a/lib/core/widgets/medical_modal.dart b/lib/core/widgets/medical_modal.dart new file mode 100644 index 0000000..5eb791a --- /dev/null +++ b/lib/core/widgets/medical_modal.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../theme/app_colors.dart'; + +/// Función estándar para abrir modales médicos en Hidalyx Med. +/// +/// Cumple con la regla institucional estricta: +/// - barrierDismissible: false (NO se cierra haciendo clic en el exterior). +/// - Solamente se cierra mediante la tecla ESC o el botón (X) superior derecho. +Future showMedicalModal({ + required BuildContext context, + required WidgetBuilder builder, + bool useRootNavigator = true, +}) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withValues(alpha: 0.5), + useRootNavigator: useRootNavigator, + builder: builder, + ); +} + +/// Contenedor base estándar para todos los modales de Hidalyx Med. +/// +/// Implementa: +/// 1. Cierre garantizado con tecla [ESC] a nivel hardware y de atajos. +/// 2. Botón con icono de cerrar (X) en la esquina superior derecha. +/// 3. Bloqueo de cierre al tocar fuera del diálogo. +class MedicalModalDialog extends StatefulWidget { + final Widget? titleIcon; + final String title; + final Widget? subtitle; + final Widget child; + final double maxWidth; + final double maxHeight; + final VoidCallback? onClose; + final Widget? bottomBar; + final List? actions; + + const MedicalModalDialog({ + super.key, + this.titleIcon, + required this.title, + this.subtitle, + required this.child, + this.maxWidth = 740, + this.maxHeight = 620, + this.onClose, + this.bottomBar, + this.actions, + }); + + @override + State createState() => _MedicalModalDialogState(); +} + +class _MedicalModalDialogState extends State { + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_handleHardwareKey); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleHardwareKey); + super.dispose(); + } + + bool _handleHardwareKey(KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + _close(); + return true; + } + return false; + } + + void _close() { + if (!mounted) return; + if (widget.onClose != null) { + widget.onClose!(); + } else { + Navigator.of(context).maybePop(); + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Container( + constraints: BoxConstraints( + maxWidth: widget.maxWidth, + maxHeight: widget.maxHeight, + minWidth: 320, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Encabezado Superior con Botón (X) de Cierre + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: AppColors.border), + ), + ), + child: Row( + children: [ + if (widget.titleIcon != null) ...[ + widget.titleIcon!, + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 2), + widget.subtitle!, + ], + ], + ), + ), + if (widget.actions != null) ...[ + ...widget.actions!, + const SizedBox(width: 8), + ], + // Botón oficial de cerrar (X) en esquina superior derecha + IconButton( + icon: const Icon( + Icons.close_rounded, + color: AppColors.textSecondary, + size: 20, + ), + tooltip: 'Cerrar (Esc)', + splashRadius: 20, + onPressed: _close, + ), + ], + ), + ), + + // Contenido Principal + Expanded(child: widget.child), + + // Barra Inferior Informativa (Opcional) + if (widget.bottomBar != null) widget.bottomBar!, + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/.gitkeep b/lib/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/auth/data/models/auth_session_model.dart b/lib/features/auth/data/models/auth_session_model.dart new file mode 100644 index 0000000..b946776 --- /dev/null +++ b/lib/features/auth/data/models/auth_session_model.dart @@ -0,0 +1,103 @@ +class AuthSessionModel { + final String accessToken; + final String? refreshToken; + final int? expires; + final String userId; + final String firstName; + final String lastName; + final String email; + final String? roleName; + + const AuthSessionModel({ + required this.accessToken, + this.refreshToken, + this.expires, + required this.userId, + required this.firstName, + required this.lastName, + required this.email, + this.roleName, + }); + + String get fullName { + final name = '$firstName $lastName'.trim(); + return name.isNotEmpty ? name : 'Dr. Luis A. Castro Sosa'; + } + + /// Indica si el usuario actual tiene rol de Secretaria, Asistente o Recepción + bool get isSecretary { + final normalized = (roleName ?? '').trim().toLowerCase(); + return normalized.contains('secretari') || + normalized.contains('asistent') || + normalized.contains('recepc'); + } + + /// Indica si el usuario actual es Doctor o Administrador + bool get isDoctorOrAdmin => !isSecretary; + + AuthSessionModel copyWith({ + String? accessToken, + String? refreshToken, + int? expires, + String? userId, + String? firstName, + String? lastName, + String? email, + String? roleName, + }) { + return AuthSessionModel( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expires: expires ?? this.expires, + userId: userId ?? this.userId, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + email: email ?? this.email, + roleName: roleName ?? this.roleName, + ); + } + + factory AuthSessionModel.fromLoginAndUser({ + required Map loginData, + required Map userData, + }) { + return AuthSessionModel( + accessToken: loginData['access_token'] as String? ?? '', + refreshToken: loginData['refresh_token'] as String?, + expires: loginData['expires'] as int?, + userId: userData['id'] as String? ?? '', + firstName: userData['first_name'] as String? ?? '', + lastName: userData['last_name'] as String? ?? '', + email: userData['email'] as String? ?? '', + roleName: (userData['role'] is Map) + ? userData['role']['name'] as String? + : userData['role']?.toString(), + ); + } + + factory AuthSessionModel.fromJson(Map json) { + return AuthSessionModel( + accessToken: json['access_token'] as String? ?? '', + refreshToken: json['refresh_token'] as String?, + expires: json['expires'] as int?, + userId: json['user_id'] as String? ?? '', + firstName: json['first_name'] as String? ?? '', + lastName: json['last_name'] as String? ?? '', + email: json['email'] as String? ?? '', + roleName: json['role_name'] as String?, + ); + } + + Map toJson() { + return { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires': expires, + 'user_id': userId, + 'first_name': firstName, + 'last_name': lastName, + 'email': email, + 'role_name': roleName, + }; + } +} diff --git a/lib/features/auth/data/services/auth_service.dart b/lib/features/auth/data/services/auth_service.dart new file mode 100644 index 0000000..e41584e --- /dev/null +++ b/lib/features/auth/data/services/auth_service.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../core/network/api_client.dart'; +import '../models/auth_session_model.dart'; + +class AuthService { + static const String _sessionKey = 'hidalyx_med_auth_session'; + + final ApiClient apiClient; + + AuthService({ApiClient? client}) : apiClient = client ?? ApiClient() { + apiClient.onTokenRefresh = () async { + final session = await refreshToken(); + return session?.accessToken; + }; + } + + Future login({ + required String email, + required String password, + }) async { + try { + // 0. Limpiar cualquier token previo para garantizar petición limpia + apiClient.clearAuthToken(); + + // 1. Autenticación en Directus + final loginResponse = await apiClient.dio.post( + '/auth/login', + data: { + 'email': email.trim(), + 'password': password, + }, + ); + + if (loginResponse.statusCode == 200 && loginResponse.data != null) { + final loginData = loginResponse.data['data'] as Map; + final token = loginData['access_token'] as String; + + // 2. Establecer token para futuras peticiones + apiClient.setAuthToken(token); + + // 3. Consultar datos reales del usuario y su rol en Directus de forma segura + Map userData = { + 'id': '', + 'first_name': email.contains('castro') ? 'Luis A.' : 'Usuario', + 'last_name': email.contains('castro') ? 'Castro Sosa' : '', + 'email': email, + }; + + try { + final userResponse = await apiClient.dio.get( + '/users/me?fields=*.*,role.*', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + if (userResponse.data != null && userResponse.data['data'] != null) { + userData = userResponse.data['data'] as Map; + } + } catch (_) { + try { + final basicRes = await apiClient.dio.get( + '/users/me', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + if (basicRes.data != null && basicRes.data['data'] != null) { + userData = basicRes.data['data'] as Map; + } + } catch (_) {} + } + + // 4. Crear modelo de sesión + final session = AuthSessionModel.fromLoginAndUser( + loginData: loginData, + userData: userData, + ); + + // 5. Guardar en SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_sessionKey, jsonEncode(session.toJson())); + + return session; + } + return null; + } on DioException catch (e) { + final errorMsg = e.response?.data?['errors']?[0]?['message'] ?? 'Credenciales incorrectas'; + throw Exception(errorMsg); + } catch (e) { + throw Exception('Error al iniciar sesión: $e'); + } + } + + /// Refresca inmediatamente el token de acceso utilizando el endpoint POST /auth/refresh de Directus + Future refreshToken() async { + try { + final prefs = await SharedPreferences.getInstance(); + final sessionJson = prefs.getString(_sessionKey); + if (sessionJson == null) return null; + + final currentSession = AuthSessionModel.fromJson( + jsonDecode(sessionJson) as Map, + ); + + final rToken = currentSession.refreshToken; + if (rToken == null || rToken.isEmpty) { + await logout(); + return null; + } + + // Cliente Dio dedicado para la petición de refresh (evita re-interceptar en caso de fallo) + final refreshDio = Dio( + BaseOptions( + baseUrl: ApiClient.baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: {'Content-Type': 'application/json'}, + ), + ); + + final refreshResponse = await refreshDio.post( + '/auth/refresh', + data: { + 'refresh_token': rToken, + 'mode': 'json', + }, + ); + + if (refreshResponse.statusCode == 200 && refreshResponse.data != null) { + final data = refreshResponse.data['data'] as Map; + final newAccessToken = data['access_token'] as String; + final newRefreshToken = (data['refresh_token'] as String?) ?? rToken; + final expires = data['expires'] as int?; + + final updatedSession = currentSession.copyWith( + accessToken: newAccessToken, + refreshToken: newRefreshToken, + expires: expires, + ); + + await prefs.setString(_sessionKey, jsonEncode(updatedSession.toJson())); + apiClient.setAuthToken(newAccessToken); + return updatedSession; + } + + await logout(); + return null; + } catch (_) { + await logout(); + return null; + } + } + + Future loadSavedSession() async { + try { + final prefs = await SharedPreferences.getInstance(); + final sessionJson = prefs.getString(_sessionKey); + if (sessionJson != null) { + final session = AuthSessionModel.fromJson( + jsonDecode(sessionJson) as Map, + ); + + if (session.accessToken.isEmpty) { + return null; + } + + // Verificamos de inmediato si el token actual sigue activo o si ya expiró + final verifyDio = Dio( + BaseOptions( + baseUrl: ApiClient.baseUrl, + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), + headers: { + 'Authorization': 'Bearer ${session.accessToken}', + }, + ), + ); + + try { + final meRes = await verifyDio.get('/users/me?fields=id'); + if (meRes.statusCode == 200) { + apiClient.setAuthToken(session.accessToken); + return session; + } + } catch (_) { + // El token expiró: usar el refresh_token inmediatamente para renovarlo sin bloquear al doctor + if (session.refreshToken != null && session.refreshToken!.isNotEmpty) { + final refreshed = await refreshToken(); + if (refreshed != null) { + apiClient.setAuthToken(refreshed.accessToken); + return refreshed; + } + } + await logout(); + return null; + } + + apiClient.setAuthToken(session.accessToken); + return session; + } + return null; + } catch (_) { + return null; + } + } + + Future logout() async { + try { + final prefs = await SharedPreferences.getInstance(); + final sessionJson = prefs.getString(_sessionKey); + + // Limpiar inmediatamente el estado local y los headers para evitar fugas de token o loops de refresh + await prefs.remove(_sessionKey); + apiClient.clearAuthToken(); + + if (sessionJson != null) { + final session = AuthSessionModel.fromJson( + jsonDecode(sessionJson) as Map, + ); + if (session.refreshToken != null && session.refreshToken!.isNotEmpty) { + try { + final logoutDio = Dio( + BaseOptions( + baseUrl: ApiClient.baseUrl, + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + headers: {'Content-Type': 'application/json'}, + ), + ); + await logoutDio.post( + '/auth/logout', + data: {'refresh_token': session.refreshToken}, + ); + } catch (_) {} + } + } + } catch (_) { + apiClient.clearAuthToken(); + } + } +} diff --git a/lib/features/auth/presentation/store/auth_store.dart b/lib/features/auth/presentation/store/auth_store.dart new file mode 100644 index 0000000..46004c9 --- /dev/null +++ b/lib/features/auth/presentation/store/auth_store.dart @@ -0,0 +1,100 @@ +import 'package:mobx/mobx.dart'; +import '../../data/models/auth_session_model.dart'; +import '../../data/services/auth_service.dart'; + +part 'auth_store.g.dart'; + +// ignore: library_private_types_in_public_api +class AuthStore = _AuthStoreBase with _$AuthStore; + +abstract class _AuthStoreBase with Store { + final AuthService authService; + + _AuthStoreBase({AuthService? service}) + : authService = service ?? AuthService(); + + @observable + bool isLoading = false; + + @observable + bool isPasswordVisible = false; + + @observable + AuthSessionModel? currentSession; + + @observable + String? errorMessage; + + @computed + bool get isAuthenticated => currentSession != null; + + @action + void togglePasswordVisibility() { + isPasswordVisible = !isPasswordVisible; + } + + @action + Future login({required String email, required String password}) async { + isLoading = true; + errorMessage = null; + currentSession = null; + try { + final session = await authService.login(email: email, password: password); + if (session != null) { + currentSession = session; + return true; + } + errorMessage = 'No se pudo iniciar sesión. Verifique sus credenciales.'; + return false; + } catch (e) { + errorMessage = e.toString().replaceAll('Exception: ', ''); + return false; + } finally { + isLoading = false; + } + } + + @action + Future checkInitialSession() async { + isLoading = true; + try { + final session = await authService.loadSavedSession(); + currentSession = session; + } catch (_) { + currentSession = null; + } finally { + isLoading = false; + } + } + + @action + Future refreshToken() async { + isLoading = true; + try { + final session = await authService.refreshToken(); + if (session != null) { + currentSession = session; + return true; + } + currentSession = null; + return false; + } catch (_) { + currentSession = null; + return false; + } finally { + isLoading = false; + } + } + + @action + Future logout() async { + isLoading = true; + errorMessage = null; + try { + await authService.logout(); + currentSession = null; + } finally { + isLoading = false; + } + } +} diff --git a/lib/features/auth/presentation/store/auth_store.g.dart b/lib/features/auth/presentation/store/auth_store.g.dart new file mode 100644 index 0000000..c871386 --- /dev/null +++ b/lib/features/auth/presentation/store/auth_store.g.dart @@ -0,0 +1,163 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$AuthStore on _AuthStoreBase, Store { + Computed? _$isAuthenticatedComputed; + + @override + bool get isAuthenticated => (_$isAuthenticatedComputed ??= Computed( + () => super.isAuthenticated, + name: '_AuthStoreBase.isAuthenticated', + )).value; + + late final _$isLoadingAtom = Atom( + name: '_AuthStoreBase.isLoading', + context: context, + ); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$isPasswordVisibleAtom = Atom( + name: '_AuthStoreBase.isPasswordVisible', + context: context, + ); + + @override + bool get isPasswordVisible { + _$isPasswordVisibleAtom.reportRead(); + return super.isPasswordVisible; + } + + @override + set isPasswordVisible(bool value) { + _$isPasswordVisibleAtom.reportWrite(value, super.isPasswordVisible, () { + super.isPasswordVisible = value; + }); + } + + late final _$currentSessionAtom = Atom( + name: '_AuthStoreBase.currentSession', + context: context, + ); + + @override + AuthSessionModel? get currentSession { + _$currentSessionAtom.reportRead(); + return super.currentSession; + } + + @override + set currentSession(AuthSessionModel? value) { + _$currentSessionAtom.reportWrite(value, super.currentSession, () { + super.currentSession = value; + }); + } + + late final _$errorMessageAtom = Atom( + name: '_AuthStoreBase.errorMessage', + context: context, + ); + + @override + String? get errorMessage { + _$errorMessageAtom.reportRead(); + return super.errorMessage; + } + + @override + set errorMessage(String? value) { + _$errorMessageAtom.reportWrite(value, super.errorMessage, () { + super.errorMessage = value; + }); + } + + late final _$loginAsyncAction = AsyncAction( + '_AuthStoreBase.login', + context: context, + ); + + @override + Future login({required String email, required String password}) { + return _$loginAsyncAction.run( + () => super.login(email: email, password: password), + ); + } + + late final _$checkInitialSessionAsyncAction = AsyncAction( + '_AuthStoreBase.checkInitialSession', + context: context, + ); + + @override + Future checkInitialSession() { + return _$checkInitialSessionAsyncAction.run( + () => super.checkInitialSession(), + ); + } + + late final _$refreshTokenAsyncAction = AsyncAction( + '_AuthStoreBase.refreshToken', + context: context, + ); + + @override + Future refreshToken() { + return _$refreshTokenAsyncAction.run(() => super.refreshToken()); + } + + late final _$logoutAsyncAction = AsyncAction( + '_AuthStoreBase.logout', + context: context, + ); + + @override + Future logout() { + return _$logoutAsyncAction.run(() => super.logout()); + } + + late final _$_AuthStoreBaseActionController = ActionController( + name: '_AuthStoreBase', + context: context, + ); + + @override + void togglePasswordVisibility() { + final _$actionInfo = _$_AuthStoreBaseActionController.startAction( + name: '_AuthStoreBase.togglePasswordVisibility', + ); + try { + return super.togglePasswordVisibility(); + } finally { + _$_AuthStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +isLoading: ${isLoading}, +isPasswordVisible: ${isPasswordVisible}, +currentSession: ${currentSession}, +errorMessage: ${errorMessage}, +isAuthenticated: ${isAuthenticated} + '''; + } +} diff --git a/lib/features/auth/presentation/views/login_view.dart b/lib/features/auth/presentation/views/login_view.dart new file mode 100644 index 0000000..2453c11 --- /dev/null +++ b/lib/features/auth/presentation/views/login_view.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../store/auth_store.dart'; +import '../widgets/login_form_widget.dart'; +import '../widgets/login_header_widget.dart'; + +class LoginView extends StatefulWidget { + final AuthStore? store; + + const LoginView({super.key, this.store}); + + @override + State createState() => _LoginViewState(); +} + +class _LoginViewState extends State { + late final AuthStore _authStore; + + @override + void initState() { + super.initState(); + _authStore = widget.store ?? AuthStore(); + } + + void _onSuccess() { + context.go('/'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.04), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const LoginHeaderWidget(), + const SizedBox(height: 28), + LoginFormWidget( + store: _authStore, + onLoginSuccess: _onSuccess, + ), + const SizedBox(height: 24), + const Text( + 'Sistema protegido · Exclusivo Consultorio Dr. Luis Castro', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/login_form_widget.dart b/lib/features/auth/presentation/widgets/login_form_widget.dart new file mode 100644 index 0000000..b2e55d3 --- /dev/null +++ b/lib/features/auth/presentation/widgets/login_form_widget.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../store/auth_store.dart'; + +class LoginFormWidget extends StatefulWidget { + final AuthStore store; + final VoidCallback onLoginSuccess; + + const LoginFormWidget({ + super.key, + required this.store, + required this.onLoginSuccess, + }); + + @override + State createState() => _LoginFormWidgetState(); +} + +class _LoginFormWidgetState extends State { + final _formKey = GlobalKey(); + + Future _submit() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final values = _formKey.currentState!.value; + final email = values['email'] as String; + final password = values['password'] as String; + + final success = await widget.store.login( + email: email, + password: password, + ); + + if (success && mounted) { + widget.onLoginSuccess(); + } + } + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) { + return FormBuilder( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Mensaje de Error si existe + if (widget.store.errorMessage != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.error.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.store.errorMessage!, + style: const TextStyle( + color: AppColors.error, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Campo Email + const Text( + 'Correo Electrónico', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + FormBuilderTextField( + name: 'email', + initialValue: 'castroluis01@hotmail.com', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + hintText: 'correo@ejemplo.com', + prefixIcon: Icon(Icons.mail_outline_rounded, color: AppColors.textMuted, size: 20), + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'El correo electrónico es requerido'), + FormBuilderValidators.email(errorText: 'Ingrese un correo electrónico válido'), + ]), + ), + const SizedBox(height: 16), + + // Campo Contraseña + const Text( + 'Contraseña', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + FormBuilderTextField( + name: 'password', + initialValue: 'LUIS', + obscureText: !widget.store.isPasswordVisible, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + decoration: InputDecoration( + hintText: 'Ingrese su contraseña', + prefixIcon: const Icon(Icons.lock_outline_rounded, color: AppColors.textMuted, size: 20), + suffixIcon: IconButton( + icon: Icon( + widget.store.isPasswordVisible + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: AppColors.textMuted, + size: 20, + ), + onPressed: widget.store.togglePasswordVisibility, + ), + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'La contraseña es requerida'), + ]), + ), + const SizedBox(height: 24), + + // Botón de Inicio de Sesión + ElevatedButton( + onPressed: widget.store.isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: widget.store.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.login_rounded, size: 18), + SizedBox(width: 8), + Text( + 'Ingresar al Consultorio', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/auth/presentation/widgets/login_header_widget.dart b/lib/features/auth/presentation/widgets/login_header_widget.dart new file mode 100644 index 0000000..748d2ad --- /dev/null +++ b/lib/features/auth/presentation/widgets/login_header_widget.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../core/theme/app_colors.dart'; + +class LoginHeaderWidget extends StatelessWidget { + const LoginHeaderWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Logo de Hidalyx + SvgPicture.asset( + 'assets/images/hidalyx_logo_blue.svg', + height: 48, + fit: BoxFit.contain, + ), + const SizedBox(height: 18), + const Text( + 'Hidalyx Med', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 6), + const Text( + 'Portal de Gestión Clínica · Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/lib/features/config/data/services/config_service.dart b/lib/features/config/data/services/config_service.dart new file mode 100644 index 0000000..f79aa2d --- /dev/null +++ b/lib/features/config/data/services/config_service.dart @@ -0,0 +1,53 @@ +import '../../../../core/network/api_client.dart'; +import '../../../dashboard/data/models/doctor_info_model.dart'; + +class ConfigService { + final ApiClient apiClient; + + ConfigService({ApiClient? client}) : apiClient = client ?? ApiClient(); + + /// Obtiene la configuración del consultorio desde Directus. + Future getConfig() async { + try { + final response = await apiClient.dio.get('/items/configuracion'); + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data']; + if (data is Map) { + return DoctorInfoModel.fromJson(data); + } else if (data is List && data.isNotEmpty && data.first is Map) { + return DoctorInfoModel.fromJson(data.first as Map); + } + } + return const DoctorInfoModel( + nombreDoctor: 'Dr. Luis A. Castro Sosa', + especialidad: 'Cirujano Urólogo', + descripEspecialidad: '', + descripGeneral: '', + ); + } catch (e) { + throw Exception('Error al cargar la configuración del consultorio: $e'); + } + } + + /// Actualiza la configuración del consultorio en Directus mediante PATCH. + Future updateConfig(DoctorInfoModel model) async { + try { + final response = await apiClient.dio.patch( + '/items/configuracion', + data: model.toJson(includeId: false), + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data']; + if (data is Map) { + return DoctorInfoModel.fromJson(data); + } else if (data is List && data.isNotEmpty && data.first is Map) { + return DoctorInfoModel.fromJson(data.first as Map); + } + } + return model; + } catch (e) { + throw Exception('Error al guardar la configuración: $e'); + } + } +} diff --git a/lib/features/config/presentation/store/config_store.dart b/lib/features/config/presentation/store/config_store.dart new file mode 100644 index 0000000..a301d1e --- /dev/null +++ b/lib/features/config/presentation/store/config_store.dart @@ -0,0 +1,102 @@ +import 'package:mobx/mobx.dart'; +import '../../../dashboard/data/models/doctor_info_model.dart'; +import '../../data/services/config_service.dart'; + +part 'config_store.g.dart'; + +// ignore: library_private_types_in_public_api +class ConfigStore = _ConfigStoreBase with _$ConfigStore; + +abstract class _ConfigStoreBase with Store { + final ConfigService service; + + _ConfigStoreBase({ConfigService? configService}) + : service = configService ?? ConfigService(); + + @observable + int? id; + + @observable + bool isLoading = false; + + @observable + bool isSaving = false; + + @observable + String? errorMessage; + + @observable + String? successMessage; + + @observable + String nombreDoctor = ''; + + @observable + String especialidad = ''; + + @observable + String descripEspecialidad = ''; + + @observable + String descripGeneral = ''; + + @computed + bool get isValid => nombreDoctor.trim().isNotEmpty && especialidad.trim().isNotEmpty; + + @action + void initFromModel(DoctorInfoModel model) { + id = model.id; + nombreDoctor = model.nombreDoctor; + especialidad = model.especialidad; + descripEspecialidad = model.descripEspecialidad; + descripGeneral = model.descripGeneral; + errorMessage = null; + successMessage = null; + } + + @action + Future loadConfig() async { + isLoading = true; + errorMessage = null; + try { + final config = await service.getConfig(); + initFromModel(config); + } catch (e) { + errorMessage = e.toString(); + } finally { + isLoading = false; + } + } + + @action + Future saveConfig() async { + if (!isValid) { + errorMessage = 'El nombre del doctor y la especialidad son obligatorios.'; + return null; + } + + isSaving = true; + errorMessage = null; + successMessage = null; + + try { + final modelToSave = DoctorInfoModel( + id: id, + nombreDoctor: nombreDoctor.trim(), + especialidad: especialidad.trim(), + descripEspecialidad: descripEspecialidad.trim(), + descripGeneral: descripGeneral.trim(), + ); + + final updated = await service.updateConfig(modelToSave); + initFromModel(updated); + successMessage = 'Configuración guardada exitosamente'; + return updated; + } catch (e) { + errorMessage = e.toString().replaceAll('Exception: ', ''); + return null; + } finally { + isSaving = false; + } + } +} diff --git a/lib/features/config/presentation/store/config_store.g.dart b/lib/features/config/presentation/store/config_store.g.dart new file mode 100644 index 0000000..cc165f6 --- /dev/null +++ b/lib/features/config/presentation/store/config_store.g.dart @@ -0,0 +1,231 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'config_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$ConfigStore on _ConfigStoreBase, Store { + Computed? _$isValidComputed; + + @override + bool get isValid => (_$isValidComputed ??= Computed( + () => super.isValid, + name: '_ConfigStoreBase.isValid', + )).value; + + late final _$idAtom = Atom(name: '_ConfigStoreBase.id', context: context); + + @override + int? get id { + _$idAtom.reportRead(); + return super.id; + } + + @override + set id(int? value) { + _$idAtom.reportWrite(value, super.id, () { + super.id = value; + }); + } + + late final _$isLoadingAtom = Atom( + name: '_ConfigStoreBase.isLoading', + context: context, + ); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$isSavingAtom = Atom( + name: '_ConfigStoreBase.isSaving', + context: context, + ); + + @override + bool get isSaving { + _$isSavingAtom.reportRead(); + return super.isSaving; + } + + @override + set isSaving(bool value) { + _$isSavingAtom.reportWrite(value, super.isSaving, () { + super.isSaving = value; + }); + } + + late final _$errorMessageAtom = Atom( + name: '_ConfigStoreBase.errorMessage', + context: context, + ); + + @override + String? get errorMessage { + _$errorMessageAtom.reportRead(); + return super.errorMessage; + } + + @override + set errorMessage(String? value) { + _$errorMessageAtom.reportWrite(value, super.errorMessage, () { + super.errorMessage = value; + }); + } + + late final _$successMessageAtom = Atom( + name: '_ConfigStoreBase.successMessage', + context: context, + ); + + @override + String? get successMessage { + _$successMessageAtom.reportRead(); + return super.successMessage; + } + + @override + set successMessage(String? value) { + _$successMessageAtom.reportWrite(value, super.successMessage, () { + super.successMessage = value; + }); + } + + late final _$nombreDoctorAtom = Atom( + name: '_ConfigStoreBase.nombreDoctor', + context: context, + ); + + @override + String get nombreDoctor { + _$nombreDoctorAtom.reportRead(); + return super.nombreDoctor; + } + + @override + set nombreDoctor(String value) { + _$nombreDoctorAtom.reportWrite(value, super.nombreDoctor, () { + super.nombreDoctor = value; + }); + } + + late final _$especialidadAtom = Atom( + name: '_ConfigStoreBase.especialidad', + context: context, + ); + + @override + String get especialidad { + _$especialidadAtom.reportRead(); + return super.especialidad; + } + + @override + set especialidad(String value) { + _$especialidadAtom.reportWrite(value, super.especialidad, () { + super.especialidad = value; + }); + } + + late final _$descripEspecialidadAtom = Atom( + name: '_ConfigStoreBase.descripEspecialidad', + context: context, + ); + + @override + String get descripEspecialidad { + _$descripEspecialidadAtom.reportRead(); + return super.descripEspecialidad; + } + + @override + set descripEspecialidad(String value) { + _$descripEspecialidadAtom.reportWrite(value, super.descripEspecialidad, () { + super.descripEspecialidad = value; + }); + } + + late final _$descripGeneralAtom = Atom( + name: '_ConfigStoreBase.descripGeneral', + context: context, + ); + + @override + String get descripGeneral { + _$descripGeneralAtom.reportRead(); + return super.descripGeneral; + } + + @override + set descripGeneral(String value) { + _$descripGeneralAtom.reportWrite(value, super.descripGeneral, () { + super.descripGeneral = value; + }); + } + + late final _$loadConfigAsyncAction = AsyncAction( + '_ConfigStoreBase.loadConfig', + context: context, + ); + + @override + Future loadConfig() { + return _$loadConfigAsyncAction.run(() => super.loadConfig()); + } + + late final _$saveConfigAsyncAction = AsyncAction( + '_ConfigStoreBase.saveConfig', + context: context, + ); + + @override + Future saveConfig() { + return _$saveConfigAsyncAction.run(() => super.saveConfig()); + } + + late final _$_ConfigStoreBaseActionController = ActionController( + name: '_ConfigStoreBase', + context: context, + ); + + @override + void initFromModel(DoctorInfoModel model) { + final _$actionInfo = _$_ConfigStoreBaseActionController.startAction( + name: '_ConfigStoreBase.initFromModel', + ); + try { + return super.initFromModel(model); + } finally { + _$_ConfigStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +id: ${id}, +isLoading: ${isLoading}, +isSaving: ${isSaving}, +errorMessage: ${errorMessage}, +successMessage: ${successMessage}, +nombreDoctor: ${nombreDoctor}, +especialidad: ${especialidad}, +descripEspecialidad: ${descripEspecialidad}, +descripGeneral: ${descripGeneral}, +isValid: ${isValid} + '''; + } +} diff --git a/lib/features/config/presentation/views/config_modal_view.dart b/lib/features/config/presentation/views/config_modal_view.dart new file mode 100644 index 0000000..4251f4f --- /dev/null +++ b/lib/features/config/presentation/views/config_modal_view.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../dashboard/data/models/doctor_info_model.dart'; +import '../store/config_store.dart'; + +class ConfigModalView extends StatefulWidget { + final ValueChanged? onSaved; + final ConfigStore? store; + + const ConfigModalView({ + super.key, + this.onSaved, + this.store, + }); + + @override + State createState() => _ConfigModalViewState(); +} + +class _ConfigModalViewState extends State { + late final ConfigStore _store; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _store = widget.store ?? ConfigStore(); + if (widget.store == null) { + _store.loadConfig(); + } + } + + Future _handleSave() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final values = _formKey.currentState!.value; + if (values['nombre_doctor'] != null) { + _store.nombreDoctor = values['nombre_doctor'].toString(); + } + if (values['especialidad'] != null) { + _store.especialidad = values['especialidad'].toString(); + } + if (values['descrip_especialidad'] != null) { + _store.descripEspecialidad = values['descrip_especialidad'].toString(); + } + if (values['descrip_general'] != null) { + _store.descripGeneral = values['descrip_general'].toString(); + } + + final updated = await _store.saveConfig(); + if (updated != null && mounted) { + widget.onSaved?.call(updated); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle_rounded, color: Colors.white, size: 18), + SizedBox(width: 8), + Expanded( + child: Text('Configuración del consultorio guardada correctamente'), + ), + ], + ), + backgroundColor: Color(0xFF00A65A), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 3), + ), + ); + Navigator.of(context).pop(); + } + } + } + + InputDecoration _cleanDecoration({ + required String label, + IconData? icon, + bool isRequired = false, + String? hintText, + }) { + return InputDecoration( + labelText: isRequired ? '$label *' : label, + hintText: hintText, + filled: true, + fillColor: Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + prefixIcon: icon != null + ? Icon(icon, size: 18, color: isRequired ? AppColors.primary : AppColors.textMuted) + : null, + prefixIconConstraints: const BoxConstraints(minWidth: 38, minHeight: 38), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.error, width: 1.5), + ), + labelStyle: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isRequired ? AppColors.primary : AppColors.textSecondary, + ), + hintStyle: const TextStyle(fontSize: 12.5, color: Color(0xFF94A3B8)), + ); + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) { + return MedicalModalDialog( + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.settings_suggest_rounded, color: AppColors.primary, size: 22), + ), + title: 'Configuración del Consultorio', + subtitle: const Text( + 'Actualice la información del doctor, especialidad y datos de contacto de la clínica', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + maxWidth: 780, + maxHeight: 660, + bottomBar: _buildBottomBar(), + child: _store.isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(), + ), + ) + : _buildForm(), + ); + }, + ); + } + + Widget _buildBottomBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + ], + ), + ), + const Spacer(), + TextButton( + onPressed: _store.isSaving ? null : () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + child: const Text('Cancelar', style: TextStyle(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _store.isSaving ? null : _handleSave, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12), + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _store.isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, size: 18), + SizedBox(width: 8), + Text( + 'Guardar Configuración', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildForm() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: FormBuilder( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_store.errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.error.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _store.errorMessage!, + style: const TextStyle(fontSize: 12.5, color: AppColors.error, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + + // Sección 1: Identidad Médica + _buildSectionHeader( + icon: Icons.person_pin_rounded, + title: 'Identidad del Profesional', + subtitle: 'Nombre y especialidad principal reflejados en el encabezado y recetas', + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: FormBuilderTextField( + name: 'nombre_doctor', + initialValue: _store.nombreDoctor, + decoration: _cleanDecoration( + label: 'Nombre del Doctor', + icon: Icons.badge_outlined, + isRequired: true, + hintText: 'Ej. Dr. Luis A. Castro Sosa', + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'Requerido'), + FormBuilderValidators.minLength(3, errorText: 'Mínimo 3 caracteres'), + ]), + onChanged: (v) => _store.nombreDoctor = v ?? '', + ), + ), + const SizedBox(width: 14), + Expanded( + flex: 5, + child: FormBuilderTextField( + name: 'especialidad', + initialValue: _store.especialidad, + decoration: _cleanDecoration( + label: 'Especialidad', + icon: Icons.medical_services_outlined, + isRequired: true, + hintText: 'Ej. Cirujano Urólogo', + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'Requerido'), + FormBuilderValidators.minLength(3, errorText: 'Mínimo 3 caracteres'), + ]), + onChanged: (v) => _store.especialidad = v ?? '', + ), + ), + ], + ), + const SizedBox(height: 22), + + // Sección 2: Áreas de Enfoque y Subespecialidades + _buildSectionHeader( + icon: Icons.local_hospital_outlined, + title: 'Áreas de Enfoque y Subespecialidades', + subtitle: 'Enfermedades, órganos o áreas de tratamiento de su consulta', + ), + const SizedBox(height: 12), + FormBuilderTextField( + name: 'descrip_especialidad', + initialValue: _store.descripEspecialidad, + maxLines: 4, + decoration: _cleanDecoration( + label: 'Descripción de Especialidades', + hintText: 'Ej. Enfermedades Vías Urinarias, Riñones, Vejiga, Próstata, Disfunción eréctil...', + ), + onChanged: (v) => _store.descripEspecialidad = v ?? '', + ), + const SizedBox(height: 22), + + // Sección 3: Datos de Contacto y Clínica + _buildSectionHeader( + icon: Icons.apartment_rounded, + title: 'Datos del Consultorio y Contacto', + subtitle: 'Dirección física de la clínica, edificio, teléfonos, celular y correo', + ), + const SizedBox(height: 12), + FormBuilderTextField( + name: 'descrip_general', + initialValue: _store.descripGeneral, + maxLines: 4, + decoration: _cleanDecoration( + label: 'Información General y Contacto', + hintText: 'Ej. Instituto materno infantil, Av. Juan Pablo Duarte, Santiago. Tel: 809-583-2394...', + ), + onChanged: (v) => _store.descripGeneral = v ?? '', + ), + ], + ), + ), + ); + } + + Widget _buildSectionHeader({ + required IconData icon, + required String title, + required String subtitle, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/dashboard/data/models/dashboard_metrics_model.dart b/lib/features/dashboard/data/models/dashboard_metrics_model.dart new file mode 100644 index 0000000..0fe63c9 --- /dev/null +++ b/lib/features/dashboard/data/models/dashboard_metrics_model.dart @@ -0,0 +1,39 @@ +class DashboardMetricsModel { + final int nuevosPacientes; + final int consultadosHoy; + final int citasHoy; + final int pacientesMasculinos; + final int pacientesFemeninos; + final int totalPacientes; + + const DashboardMetricsModel({ + this.nuevosPacientes = 0, + this.consultadosHoy = 0, + this.citasHoy = 0, + this.pacientesMasculinos = 11078, + this.pacientesFemeninos = 2634, + this.totalPacientes = 13712, + }); + + factory DashboardMetricsModel.fromJson(Map json) { + return DashboardMetricsModel( + nuevosPacientes: json['nuevos_pacientes'] as int? ?? 0, + consultadosHoy: json['consultados_hoy'] as int? ?? 0, + citasHoy: json['citas_hoy'] as int? ?? 0, + pacientesMasculinos: json['pacientes_masculinos'] as int? ?? 0, + pacientesFemeninos: json['pacientes_femeninos'] as int? ?? 0, + totalPacientes: json['total_pacientes'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'nuevos_pacientes': nuevosPacientes, + 'consultados_hoy': consultadosHoy, + 'citas_hoy': citasHoy, + 'pacientes_masculinos': pacientesMasculinos, + 'pacientes_femeninos': pacientesFemeninos, + 'total_pacientes': totalPacientes, + }; + } +} diff --git a/lib/features/dashboard/data/models/doctor_info_model.dart b/lib/features/dashboard/data/models/doctor_info_model.dart new file mode 100644 index 0000000..7fbbfcd --- /dev/null +++ b/lib/features/dashboard/data/models/doctor_info_model.dart @@ -0,0 +1,57 @@ +class DoctorInfoModel { + final int? id; + final String nombreDoctor; + final String especialidad; + final String descripEspecialidad; + final String descripGeneral; + + const DoctorInfoModel({ + this.id, + required this.nombreDoctor, + required this.especialidad, + required this.descripEspecialidad, + required this.descripGeneral, + }); + + factory DoctorInfoModel.fromJson(Map json) { + final doctor = (json['nombre_doctor']?.toString() ?? 'Dr. Luis A. Castro Sosa').trim(); + final esp = (json['especialidad']?.toString() ?? 'Cirujano Urólogo').trim(); + + return DoctorInfoModel( + id: json['id'] is int ? json['id'] as int : int.tryParse(json['id']?.toString() ?? ''), + nombreDoctor: doctor.isEmpty ? 'Dr. Luis A. Castro Sosa' : doctor, + especialidad: esp.isEmpty ? 'Cirujano Urólogo' : esp, + descripEspecialidad: json['descrip_especialidad']?.toString() ?? '', + descripGeneral: json['descrip_general']?.toString() ?? '', + ); + } + + Map toJson({bool includeId = false}) { + final map = { + 'nombre_doctor': nombreDoctor, + 'especialidad': especialidad, + 'descrip_especialidad': descripEspecialidad, + 'descrip_general': descripGeneral, + }; + if (includeId && id != null) { + map['id'] = id; + } + return map; + } + + DoctorInfoModel copyWith({ + int? id, + String? nombreDoctor, + String? especialidad, + String? descripEspecialidad, + String? descripGeneral, + }) { + return DoctorInfoModel( + id: id ?? this.id, + nombreDoctor: nombreDoctor ?? this.nombreDoctor, + especialidad: especialidad ?? this.especialidad, + descripEspecialidad: descripEspecialidad ?? this.descripEspecialidad, + descripGeneral: descripGeneral ?? this.descripGeneral, + ); + } +} diff --git a/lib/features/dashboard/data/models/patient_summary_model.dart b/lib/features/dashboard/data/models/patient_summary_model.dart new file mode 100644 index 0000000..1a6d876 --- /dev/null +++ b/lib/features/dashboard/data/models/patient_summary_model.dart @@ -0,0 +1,109 @@ +class PatientSummaryModel { + final int id; + final String nombre; + final String apellido; + final String cedula; + final String telefono; + final String fecReg; + final String fecProxCita; + final String seguro; + final String motCons; + final String estadoEspera; + + const PatientSummaryModel({ + required this.id, + required this.nombre, + required this.apellido, + required this.cedula, + required this.telefono, + required this.fecReg, + required this.fecProxCita, + required this.seguro, + required this.motCons, + this.estadoEspera = 'no_presente', + }); + + String get fullName => '$nombre $apellido'.trim(); + + bool get isNoPresente => estadoEspera == 'no_presente' || estadoEspera.isEmpty; + bool get isEnEspera => estadoEspera == 'en_espera'; + bool get isEnConsulta => estadoEspera == 'en_consulta'; + bool get isAtendido => estadoEspera == 'atendido'; + + String get estadoEsperaLabel { + switch (estadoEspera) { + case 'en_espera': + return 'En Sala de Espera'; + case 'en_consulta': + return 'En Consulta'; + case 'atendido': + return 'Atendido'; + case 'no_presente': + default: + return 'No Presente'; + } + } + + PatientSummaryModel copyWith({ + int? id, + String? nombre, + String? apellido, + String? cedula, + String? telefono, + String? fecReg, + String? fecProxCita, + String? seguro, + String? motCons, + String? estadoEspera, + }) { + return PatientSummaryModel( + id: id ?? this.id, + nombre: nombre ?? this.nombre, + apellido: apellido ?? this.apellido, + cedula: cedula ?? this.cedula, + telefono: telefono ?? this.telefono, + fecReg: fecReg ?? this.fecReg, + fecProxCita: fecProxCita ?? this.fecProxCita, + seguro: seguro ?? this.seguro, + motCons: motCons ?? this.motCons, + estadoEspera: estadoEspera ?? this.estadoEspera, + ); + } + + factory PatientSummaryModel.fromJson(Map json) { + int parsedId = 0; + if (json['id'] is int) { + parsedId = json['id'] as int; + } else if (json['id'] != null) { + parsedId = int.tryParse(json['id'].toString()) ?? 0; + } + + return PatientSummaryModel( + id: parsedId, + nombre: json['nombre']?.toString() ?? '', + apellido: json['apellido']?.toString() ?? '', + cedula: json['cedula']?.toString() ?? '', + telefono: json['telefono']?.toString() ?? '', + fecReg: json['fec_reg']?.toString() ?? '', + fecProxCita: json['fec_proxcita']?.toString() ?? '', + seguro: json['seguro']?.toString() ?? '', + motCons: json['mot_cons']?.toString() ?? '', + estadoEspera: json['estado_espera']?.toString() ?? 'no_presente', + ); + } + + Map toJson() { + return { + 'id': id, + 'nombre': nombre, + 'apellido': apellido, + 'cedula': cedula, + 'telefono': telefono, + 'fec_reg': fecReg, + 'fec_proxcita': fecProxCita, + 'seguro': seguro, + 'mot_cons': motCons, + 'estado_espera': estadoEspera, + }; + } +} diff --git a/lib/features/dashboard/data/services/dashboard_service.dart b/lib/features/dashboard/data/services/dashboard_service.dart new file mode 100644 index 0000000..c6cd090 --- /dev/null +++ b/lib/features/dashboard/data/services/dashboard_service.dart @@ -0,0 +1,188 @@ +import '../../../../core/network/api_client.dart'; +import '../models/dashboard_metrics_model.dart'; +import '../models/doctor_info_model.dart'; +import '../models/patient_summary_model.dart'; + +class DashboardService { + final ApiClient apiClient; + + DashboardService({ApiClient? client}) : apiClient = client ?? ApiClient(); + + String _formatTwoDigits(int n) => n.toString().padLeft(2, '0'); + + Future getDoctorConfig() async { + try { + final response = await apiClient.dio.get('/items/configuracion'); + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data'] as Map; + return DoctorInfoModel.fromJson(data); + } + return const DoctorInfoModel( + nombreDoctor: 'Dr. Luis A. Castro Sosa', + especialidad: 'Cirujano Urólogo', + descripEspecialidad: '', + descripGeneral: '', + ); + } catch (_) { + return const DoctorInfoModel( + nombreDoctor: 'Dr. Luis A. Castro Sosa', + especialidad: 'Cirujano Urólogo', + descripEspecialidad: '', + descripGeneral: '', + ); + } + } + + Future getDashboardMetrics() async { + try { + final now = DateTime.now(); + final day = _formatTwoDigits(now.day); + final month = _formatTwoDigits(now.month); + final year = now.year.toString(); + final dmy = '$day/$month/$year'; + final iso = '$year-$month-$day'; + + // Consultas concurrentes optimizadas a Directus + final results = await Future.wait([ + // 0: Desglose por sexo + apiClient.dio.get('/items/pacientes?aggregate[count]=*&groupBy[]=sexo'), + + // 1: Citas para hoy (condición estricta: fec_proxcita igual a la fecha del día actual) + apiClient.dio.get( + '/items/pacientes?aggregate[count]=*&filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso', + ), + + // 2: Pacientes nuevos registrados hoy + apiClient.dio.get( + '/items/pacientes?aggregate[count]=*&filter[_or][0][fec_reg][_contains]=$dmy&filter[_or][1][fec_reg][_contains]=$iso', + ), + + // 3: Pacientes consultados hoy + apiClient.dio.get( + '/items/pacientes?aggregate[count]=*&filter[_or][0][fec_ultcon][_contains]=$dmy&filter[_or][1][fec_ultcon][_contains]=$iso', + ), + + // 4: Total de pacientes + apiClient.dio.get('/items/pacientes?aggregate[count]=*'), + ]); + + int masc = 11078; + int fem = 2634; + int citasHoy = 0; + int nuevosHoy = 0; + int consultadosHoy = 0; + int total = 13712; + + // 0. Desglose por sexo + if (results[0].statusCode == 200 && results[0].data != null) { + final sexoData = results[0].data['data'] as List? ?? []; + for (final item in sexoData) { + final s = item['sexo']?.toString().toLowerCase() ?? ''; + final count = int.tryParse(item['count'].toString()) ?? 0; + if (s.contains('masc')) masc = count; + if (s.contains('fem')) fem = count; + } + } + + // 1. Citas para hoy + if (results[1].statusCode == 200 && results[1].data != null) { + final data = results[1].data['data'] as List? ?? []; + if (data.isNotEmpty) { + citasHoy = int.tryParse(data[0]['count'].toString()) ?? 0; + } + } + + // 2. Nuevos hoy + if (results[2].statusCode == 200 && results[2].data != null) { + final data = results[2].data['data'] as List? ?? []; + if (data.isNotEmpty) { + nuevosHoy = int.tryParse(data[0]['count'].toString()) ?? 0; + } + } + + // 3. Consultados hoy + if (results[3].statusCode == 200 && results[3].data != null) { + final data = results[3].data['data'] as List? ?? []; + if (data.isNotEmpty) { + consultadosHoy = int.tryParse(data[0]['count'].toString()) ?? 0; + } + } + + // 4. Total pacientes + if (results[4].statusCode == 200 && results[4].data != null) { + final data = results[4].data['data'] as List? ?? []; + if (data.isNotEmpty) { + total = int.tryParse(data[0]['count'].toString()) ?? 13712; + } + } + + return DashboardMetricsModel( + nuevosPacientes: nuevosHoy, + consultadosHoy: consultadosHoy, + citasHoy: citasHoy, + pacientesMasculinos: masc, + pacientesFemeninos: fem, + totalPacientes: total, + ); + } catch (_) { + return const DashboardMetricsModel(); + } + } + + Future> getRecentPatients({int limit = 5}) async { + try { + final response = await apiClient.dio.get( + '/items/pacientes?sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_reg,fec_proxcita,seguro,mot_cons,estado_espera', + ); + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data'] as List? ?? []; + return data + .map((item) => PatientSummaryModel.fromJson(item as Map)) + .toList(); + } + return []; + } catch (_) { + return []; + } + } + + Future> getAppointmentsByDate(DateTime date, {int limit = 100}) async { + try { + final day = _formatTwoDigits(date.day); + final month = _formatTwoDigits(date.month); + final year = date.year.toString(); + final dmy = '$day/$month/$year'; + final iso = '$year-$month-$day'; + + final response = await apiClient.dio.get( + '/items/pacientes?filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso&sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_proxcita,mot_cons,estado_espera', + ); + + final data = response.data?['data'] as List? ?? []; + + return data + .map((item) => PatientSummaryModel.fromJson(item as Map)) + .toList(); + } catch (_) { + return []; + } + } + + Future> getTodayAppointments({int limit = 50}) async { + return getAppointmentsByDate(DateTime.now(), limit: limit); + } + + Future updatePatientWaitingStatus(int patientId, String nuevoEstado) async { + try { + final response = await apiClient.dio.patch( + '/items/pacientes/$patientId', + data: {'estado_espera': nuevoEstado}, + ); + return response.statusCode == 200; + } catch (_) { + return false; + } + } +} + + diff --git a/lib/features/dashboard/presentation/store/dashboard_store.dart b/lib/features/dashboard/presentation/store/dashboard_store.dart new file mode 100644 index 0000000..2e921b1 --- /dev/null +++ b/lib/features/dashboard/presentation/store/dashboard_store.dart @@ -0,0 +1,90 @@ +import 'package:mobx/mobx.dart'; +import '../../data/models/dashboard_metrics_model.dart'; +import '../../data/models/doctor_info_model.dart'; +import '../../data/models/patient_summary_model.dart'; +import '../../data/services/dashboard_service.dart'; + +part 'dashboard_store.g.dart'; + +// ignore: library_private_types_in_public_api +class DashboardStore = _DashboardStoreBase with _$DashboardStore; + +abstract class _DashboardStoreBase with Store { + final DashboardService service; + + _DashboardStoreBase({DashboardService? dashboardService}) + : service = dashboardService ?? DashboardService(); + + @observable + bool isLoading = true; + + @observable + DoctorInfoModel doctorInfo = const DoctorInfoModel( + nombreDoctor: 'Dr. Luis A. Castro Sosa', + especialidad: 'Cirujano Urólogo', + descripEspecialidad: '', + descripGeneral: '', + ); + + @observable + DashboardMetricsModel metrics = const DashboardMetricsModel(); + + @observable + ObservableList recentPatients = ObservableList(); + + @observable + ObservableList todayAppointments = ObservableList(); + + @observable + String? errorMessage; + + int get enEsperaCount => + todayAppointments.where((a) => a.isEnEspera).length; + + @action + Future loadDashboardData() async { + isLoading = true; + errorMessage = null; + try { + final config = await service.getDoctorConfig(); + final metricsData = await service.getDashboardMetrics(); + final recents = await service.getRecentPatients(); + final appts = await service.getTodayAppointments(); + + runInAction(() { + doctorInfo = config; + metrics = metricsData; + recentPatients = ObservableList.of(recents); + todayAppointments = ObservableList.of(appts); + isLoading = false; + }); + } catch (e) { + runInAction(() { + errorMessage = e.toString(); + isLoading = false; + }); + } + } + + @action + Future changeWaitingStatus(int patientId, String nuevoEstado) async { + final success = await service.updatePatientWaitingStatus(patientId, nuevoEstado); + if (success) { + runInAction(() { + final index = todayAppointments.indexWhere((p) => p.id == patientId); + if (index != -1) { + todayAppointments[index] = + todayAppointments[index].copyWith(estadoEspera: nuevoEstado); + } + final recentIndex = recentPatients.indexWhere((p) => p.id == patientId); + if (recentIndex != -1) { + recentPatients[recentIndex] = + recentPatients[recentIndex].copyWith(estadoEspera: nuevoEstado); + } + }); + } + return success; + } +} + + diff --git a/lib/features/dashboard/presentation/store/dashboard_store.g.dart b/lib/features/dashboard/presentation/store/dashboard_store.g.dart new file mode 100644 index 0000000..e5bd261 --- /dev/null +++ b/lib/features/dashboard/presentation/store/dashboard_store.g.dart @@ -0,0 +1,153 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dashboard_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$DashboardStore on _DashboardStoreBase, Store { + late final _$isLoadingAtom = Atom( + name: '_DashboardStoreBase.isLoading', + context: context, + ); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$doctorInfoAtom = Atom( + name: '_DashboardStoreBase.doctorInfo', + context: context, + ); + + @override + DoctorInfoModel get doctorInfo { + _$doctorInfoAtom.reportRead(); + return super.doctorInfo; + } + + @override + set doctorInfo(DoctorInfoModel value) { + _$doctorInfoAtom.reportWrite(value, super.doctorInfo, () { + super.doctorInfo = value; + }); + } + + late final _$metricsAtom = Atom( + name: '_DashboardStoreBase.metrics', + context: context, + ); + + @override + DashboardMetricsModel get metrics { + _$metricsAtom.reportRead(); + return super.metrics; + } + + @override + set metrics(DashboardMetricsModel value) { + _$metricsAtom.reportWrite(value, super.metrics, () { + super.metrics = value; + }); + } + + late final _$recentPatientsAtom = Atom( + name: '_DashboardStoreBase.recentPatients', + context: context, + ); + + @override + ObservableList get recentPatients { + _$recentPatientsAtom.reportRead(); + return super.recentPatients; + } + + @override + set recentPatients(ObservableList value) { + _$recentPatientsAtom.reportWrite(value, super.recentPatients, () { + super.recentPatients = value; + }); + } + + late final _$todayAppointmentsAtom = Atom( + name: '_DashboardStoreBase.todayAppointments', + context: context, + ); + + @override + ObservableList get todayAppointments { + _$todayAppointmentsAtom.reportRead(); + return super.todayAppointments; + } + + @override + set todayAppointments(ObservableList value) { + _$todayAppointmentsAtom.reportWrite(value, super.todayAppointments, () { + super.todayAppointments = value; + }); + } + + late final _$errorMessageAtom = Atom( + name: '_DashboardStoreBase.errorMessage', + context: context, + ); + + @override + String? get errorMessage { + _$errorMessageAtom.reportRead(); + return super.errorMessage; + } + + @override + set errorMessage(String? value) { + _$errorMessageAtom.reportWrite(value, super.errorMessage, () { + super.errorMessage = value; + }); + } + + late final _$loadDashboardDataAsyncAction = AsyncAction( + '_DashboardStoreBase.loadDashboardData', + context: context, + ); + + @override + Future loadDashboardData() { + return _$loadDashboardDataAsyncAction.run(() => super.loadDashboardData()); + } + + late final _$changeWaitingStatusAsyncAction = AsyncAction( + '_DashboardStoreBase.changeWaitingStatus', + context: context, + ); + + @override + Future changeWaitingStatus(int patientId, String nuevoEstado) { + return _$changeWaitingStatusAsyncAction.run( + () => super.changeWaitingStatus(patientId, nuevoEstado), + ); + } + + @override + String toString() { + return ''' +isLoading: ${isLoading}, +doctorInfo: ${doctorInfo}, +metrics: ${metrics}, +recentPatients: ${recentPatients}, +todayAppointments: ${todayAppointments}, +errorMessage: ${errorMessage} + '''; + } +} diff --git a/lib/features/dashboard/presentation/views/appointments_modal_view.dart b/lib/features/dashboard/presentation/views/appointments_modal_view.dart new file mode 100644 index 0000000..fd06b09 --- /dev/null +++ b/lib/features/dashboard/presentation/views/appointments_modal_view.dart @@ -0,0 +1,960 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../patient/presentation/views/patient_modal_view.dart'; +import '../../data/models/patient_summary_model.dart'; +import '../../data/services/dashboard_service.dart'; + +class AppointmentsModalView extends StatefulWidget { + final DateTime? initialDate; + final DashboardService? service; + final bool isSecretary; + final void Function(int patientId, String newStatus)? onStatusChanged; + + const AppointmentsModalView({ + super.key, + this.initialDate, + this.service, + this.isSecretary = false, + this.onStatusChanged, + }); + + @override + State createState() => _AppointmentsModalViewState(); +} + +class _AppointmentsModalViewState extends State { + late final DashboardService _service; + late DateTime _selectedDate; + bool _isLoading = false; + String? _errorMessage; + List _appointments = []; + String _waitingFilter = 'todos'; // 'todos', 'en_espera', 'en_consulta', 'atendido', 'no_presente' + + @override + void initState() { + super.initState(); + _service = widget.service ?? DashboardService(); + _selectedDate = widget.initialDate ?? DateTime.now(); + _fetchAppointments(_selectedDate); + } + + Future _handleStatusChange(PatientSummaryModel item, String newStatus) async { + final success = await _service.updatePatientWaitingStatus(item.id, newStatus); + if (success && mounted) { + setState(() { + final index = _appointments.indexWhere((p) => p.id == item.id); + if (index != -1) { + _appointments[index] = _appointments[index].copyWith(estadoEspera: newStatus); + } + }); + if (widget.onStatusChanged != null) { + widget.onStatusChanged!(item.id, newStatus); + } + } + } + + List get _filteredAppointments { + if (_waitingFilter == 'todos') return _appointments; + if (_waitingFilter == 'en_espera') return _appointments.where((a) => a.isEnEspera).toList(); + if (_waitingFilter == 'en_consulta') return _appointments.where((a) => a.isEnConsulta).toList(); + if (_waitingFilter == 'atendido') return _appointments.where((a) => a.isAtendido).toList(); + if (_waitingFilter == 'no_presente') return _appointments.where((a) => a.isNoPresente).toList(); + return _appointments; + } + + bool _isToday(DateTime date) { + final now = DateTime.now(); + return date.year == now.year && date.month == now.month && date.day == now.day; + } + + String _formatDate(DateTime date) { + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + final year = date.year.toString(); + return '$day/$month/$year'; + } + + String _formatDateHuman(DateTime date) { + const weekdays = [ + 'Lunes', + 'Martes', + 'Miércoles', + 'Jueves', + 'Viernes', + 'Sábado', + 'Domingo' + ]; + const months = [ + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre' + ]; + + final weekdayName = weekdays[date.weekday - 1]; + final monthName = months[date.month - 1]; + return '$weekdayName, ${date.day} de $monthName de ${date.year}'; + } + + Future _fetchAppointments(DateTime date) async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final results = await _service.getAppointmentsByDate(date); + if (mounted) { + setState(() { + _appointments = results; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Error al cargar las citas para la fecha seleccionada: $e'; + _isLoading = false; + }); + } + } + } + + void _onDateSelected(DateTime newDate) { + if (newDate.year == _selectedDate.year && + newDate.month == _selectedDate.month && + newDate.day == _selectedDate.day) { + return; + } + setState(() { + _selectedDate = newDate; + }); + _fetchAppointments(newDate); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2050), + helpText: 'SELECCIONAR FECHA DE CITAS', + confirmText: 'FILTRAR', + cancelText: 'CANCELAR', + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + _onDateSelected(picked); + } + } + + void _openPatient(int patientId) { + showMedicalModal( + context: context, + builder: (_) => PatientModalView( + patientId: patientId, + isSecretary: widget.isSecretary, + onSaved: (_) { + _fetchAppointments(_selectedDate); + if (widget.onStatusChanged != null) { + widget.onStatusChanged!(patientId, 'atendido'); + } + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isFilteringToday = _isToday(_selectedDate); + + return MedicalModalDialog( + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.event_available_rounded, + color: AppColors.warning, + size: 22, + ), + ), + title: 'Agenda de Citas Médicas', + subtitle: const Text( + 'Consulte y gestione los pacientes citados por fecha de consulta', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + maxWidth: 820, + maxHeight: 700, + bottomBar: _buildBottomBar(), + child: Column( + children: [ + // Barra de Filtro de Fecha + _buildDateFilterBar(isFilteringToday), + + // Divisor + const Divider(height: 1, color: Color(0xFFE2E8F0)), + + // Contenido Principal (Lista de Citas) + Expanded( + child: _buildBody(isFilteringToday), + ), + ], + ), + ); + } + + Widget _buildDateFilterBar(bool isFilteringToday) { + final filterText = isFilteringToday + ? 'Hoy (${_formatDate(_selectedDate)})' + : _formatDate(_selectedDate); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + // Indicador dinámico de filtro activo (Hoy vs Fecha Específica) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isFilteringToday + ? AppColors.primaryContainer + : const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isFilteringToday + ? AppColors.primary.withValues(alpha: 0.3) + : const Color(0xFF93C5FD), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isFilteringToday ? Icons.today_rounded : Icons.date_range_rounded, + size: 16, + color: isFilteringToday ? AppColors.primary : const Color(0xFF2563EB), + ), + const SizedBox(width: 8), + Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Filtro: ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + TextSpan( + text: filterText, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: isFilteringToday + ? AppColors.primary + : const Color(0xFF1D4ED8), + ), + ), + ], + ), + ), + ], + ), + ), + + // Botones de Navegación Rápida + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Botón Día Anterior + IconButton( + icon: const Icon(Icons.chevron_left_rounded, size: 20), + tooltip: 'Día anterior', + splashRadius: 18, + onPressed: _isLoading + ? null + : () => _onDateSelected(_selectedDate.subtract(const Duration(days: 1))), + ), + + // Botón acceso directo a "Hoy" + InkWell( + onTap: _isLoading || isFilteringToday + ? null + : () => _onDateSelected(DateTime.now()), + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isFilteringToday + ? AppColors.primary + : Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isFilteringToday + ? AppColors.primary + : const Color(0xFFCBD5E1), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.refresh_rounded, + size: 14, + color: isFilteringToday ? Colors.white : AppColors.textPrimary, + ), + const SizedBox(width: 4), + Text( + 'Hoy', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isFilteringToday ? Colors.white : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + + // Botón Día Siguiente + IconButton( + icon: const Icon(Icons.chevron_right_rounded, size: 20), + tooltip: 'Día siguiente', + splashRadius: 18, + onPressed: _isLoading + ? null + : () => _onDateSelected(_selectedDate.add(const Duration(days: 1))), + ), + + const SizedBox(width: 6), + + // Botón Selector de Fecha + ElevatedButton.icon( + onPressed: _isLoading ? null : _pickDate, + icon: const Icon(Icons.calendar_month_outlined, size: 16), + label: const Text( + 'Cambiar Fecha', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.textPrimary, + elevation: 0, + side: const BorderSide(color: Color(0xFFCBD5E1)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 6, + children: [ + Text( + _formatDateHuman(_selectedDate), + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + // Píldoras de Filtro de Sala de Espera + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildFilterChip('todos', 'Todos (${_appointments.length})', null), + _buildFilterChip( + 'en_espera', + 'En Espera (${_appointments.where((a) => a.isEnEspera).length})', + const Color(0xFF15803D), + ), + _buildFilterChip( + 'en_consulta', + 'En Consulta (${_appointments.where((a) => a.isEnConsulta).length})', + const Color(0xFF0369A1), + ), + _buildFilterChip( + 'atendido', + 'Atendidos (${_appointments.where((a) => a.isAtendido).length})', + const Color(0xFF64748B), + ), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildFilterChip(String value, String label, Color? highlightColor) { + final isSelected = _waitingFilter == value; + return InkWell( + onTap: () { + setState(() { + _waitingFilter = value; + }); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? (highlightColor ?? AppColors.primary).withValues(alpha: 0.15) + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? (highlightColor ?? AppColors.primary) + : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected + ? (highlightColor ?? AppColors.primary) + : AppColors.textSecondary, + ), + ), + ), + ); + } + + Widget _buildBody(bool isFilteringToday) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 14), + Text( + 'Consultando agenda médica...', + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 40), + const SizedBox(height: 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppColors.error), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _fetchAppointments(_selectedDate), + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); + } + + if (_appointments.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(50), + ), + child: const Icon( + Icons.event_busy_rounded, + size: 42, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: 16), + Text( + isFilteringToday + ? 'No hay citas programadas para el día de hoy' + : 'No hay citas registradas para el ${_formatDate(_selectedDate)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + isFilteringToday + ? 'Puede consultar otra fecha utilizando el selector de calendario superior.' + : 'Intente seleccionar otro día o presione "Hoy" para volver a la agenda actual.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ); + } + + final list = _filteredAppointments; + + if (list.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.filter_list_off_rounded, size: 38, color: AppColors.textMuted), + const SizedBox(height: 12), + Text( + 'No hay pacientes con el filtro seleccionado', + style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.textSecondary), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => setState(() => _waitingFilter = 'todos'), + child: const Text('Mostrar todos'), + ), + ], + ), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + itemCount: list.length, + separatorBuilder: (_, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = list[index]; + return _buildAppointmentTile(item); + }, + ); + } + + Widget _buildAppointmentTile(PatientSummaryModel item) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: item.isEnEspera ? const Color(0xFF86EFAC) : const Color(0xFFE2E8F0), + width: item.isEnEspera ? 1.5 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: item.id > 0 ? () => _openPatient(item.id) : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + // Icono de Cita + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: item.isEnEspera + ? const Color(0xFFDCFCE7) + : AppColors.teal.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + item.isEnEspera ? Icons.chair_rounded : Icons.calendar_today_rounded, + color: item.isEnEspera ? const Color(0xFF15803D) : AppColors.teal, + size: 20, + ), + ), + const SizedBox(width: 14), + + // Datos del Paciente y Motivo + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.fullName, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (item.cedula.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.cedula, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (item.telefono.isNotEmpty) ...[ + const Icon(Icons.phone_outlined, size: 13, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + item.telefono, + style: const TextStyle(fontSize: 11.5, color: AppColors.textSecondary), + ), + const SizedBox(width: 12), + ], + const Icon(Icons.medical_information_outlined, size: 13, color: AppColors.textMuted), + const SizedBox(width: 4), + Expanded( + child: Text( + item.motCons.isNotEmpty + ? item.motCons.replaceAll('\n', ' ').trim() + : 'Consulta médica programada', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11.5, color: AppColors.textMuted), + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Acciones de Sala de Espera / Estado + _buildAppointmentActions(item), + + const SizedBox(width: 8), + + // Botón Ver Ficha / Abrir Historial + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.arrow_forward_ios_rounded, + color: AppColors.primary, + size: 14, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildAppointmentActions(PatientSummaryModel item) { + const double uniformHeight = 28.0; + const double uniformRadius = 6.0; + + if (item.isEnEspera) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFF86EFAC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)), + SizedBox(width: 4), + Text( + 'En Espera', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF15803D), + ), + ), + ], + ), + ), + if (!widget.isSecretary) ...[ + const SizedBox(width: 6), + SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)), + visualDensity: VisualDensity.compact, + ), + onPressed: () async { + await _handleStatusChange(item, 'en_consulta'); + _openPatient(item.id); + }, + icon: const Icon(Icons.arrow_forward_rounded, size: 12), + label: const Text('Atender', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)), + ), + ), + ] else ...[ + const SizedBox(width: 4), + Tooltip( + message: 'Quitar de la sala de espera', + child: InkWell( + onTap: () => _handleStatusChange(item, 'no_presente'), + borderRadius: BorderRadius.circular(uniformRadius), + child: Container( + height: uniformHeight, + width: uniformHeight, + alignment: Alignment.center, + child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted), + ), + ), + ), + ], + ], + ); + } + + if (item.isEnConsulta) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => _openPatient(item.id), + borderRadius: BorderRadius.circular(uniformRadius), + child: Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFF7DD3FC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)), + SizedBox(width: 4), + Text( + 'En Consulta', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF0369A1)), + ), + ], + ), + ), + ), + if (!widget.isSecretary) ...[ + const SizedBox(width: 6), + SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0284C7), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)), + visualDensity: VisualDensity.compact, + ), + onPressed: () => _handleStatusChange(item, 'atendido'), + icon: const Icon(Icons.check_rounded, size: 13), + label: const Text('Finalizar', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)), + ), + ), + ], + ], + ); + } + + if (item.isAtendido) { + return Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)), + SizedBox(width: 4), + Text( + 'Atendido', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF64748B)), + ), + ], + ), + ); + } + + // no_presente + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isSecretary) + SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)), + visualDensity: VisualDensity.compact, + ), + onPressed: () => _handleStatusChange(item, 'en_espera'), + icon: const Icon(Icons.how_to_reg_rounded, size: 13), + label: const Text('Llegó', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)), + ), + ), + ], + ); + } + + Widget _buildBottomBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + ], + ), + ), + const Spacer(), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + 'Cerrar', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/views/dashboard_view.dart b/lib/features/dashboard/presentation/views/dashboard_view.dart new file mode 100644 index 0000000..bba7807 --- /dev/null +++ b/lib/features/dashboard/presentation/views/dashboard_view.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../auth/data/services/auth_service.dart'; +import '../store/dashboard_store.dart'; +import '../widgets/dashboard_app_bar.dart'; +import '../widgets/dashboard_metrics_cards.dart'; +import '../widgets/quick_actions_dropdown.dart'; +import '../widgets/recent_patients_card.dart'; +import '../widgets/today_appointments_card.dart'; +import '../../../patient_search/presentation/views/patient_search_modal_view.dart'; +import '../../../patient/presentation/views/patient_modal_view.dart'; +import '../../../config/presentation/views/config_modal_view.dart'; +import 'appointments_modal_view.dart'; +import '../../../../core/widgets/medical_modal.dart'; + +class DashboardView extends StatefulWidget { + final DashboardStore? store; + + const DashboardView({super.key, this.store}); + + @override + State createState() => _DashboardViewState(); +} + +class _DashboardViewState extends State { + late final DashboardStore _store; + bool _isSecretary = false; + + @override + void initState() { + super.initState(); + _store = widget.store ?? DashboardStore(); + _store.loadDashboardData(); + _loadUserRole(); + } + + Future _loadUserRole() async { + final session = await AuthService().loadSavedSession(); + if (mounted && session != null) { + setState(() { + _isSecretary = session.isSecretary; + }); + } + } + + void _handleLogout() { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.logout_rounded, color: AppColors.error), + SizedBox(width: 10), + Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + content: const Text('¿Está seguro de que desea salir del sistema Hidalyx Med?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.of(dialogContext).pop(); + await AuthService().logout(); + if (mounted) { + context.go('/login'); + } + }, + child: const Text('Salir'), + ), + ], + ), + ); + } + + + void _handleQuickAction(QuickActionType action) { + switch (action) { + case QuickActionType.nuevoPaciente: + showMedicalModal( + context: context, + builder: (_) => PatientModalView( + isSecretary: _isSecretary, + onSaved: (_) { + _store.loadDashboardData(); + }, + ), + ); + break; + case QuickActionType.buscarPaciente: + showMedicalModal( + context: context, + builder: (_) => PatientSearchModalView(isSecretary: _isSecretary), + ); + break; + case QuickActionType.configuracion: + showMedicalModal( + context: context, + builder: (_) => ConfigModalView( + onSaved: (updatedConfig) { + _store.doctorInfo = updatedConfig; + }, + ), + ); + break; + } + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: DashboardAppBar( + doctorName: _store.doctorInfo.nombreDoctor, + specialty: _store.doctorInfo.especialidad, + isSecretary: _isSecretary, + onLogoutPressed: _handleLogout, + onQuickActionSelected: _handleQuickAction, + ), + body: _store.isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Encabezado de Bienvenida + Row( + children: [ + Container( + width: 4, + height: 28, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isSecretary ? 'Recepción de Pacientes' : 'Panel Central - Consultorio', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + _isSecretary + ? 'Módulo de recepción, registro y sala de espera.' + : 'Bienvenido, ${_store.doctorInfo.nombreDoctor}. Sistema clínico listo.', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + ], + ), + ), + // Indicador en Vivo de Sala de Espera + if (_store.enEsperaCount > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF86EFAC)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.chair_rounded, + size: 18, + color: Color(0xFF15803D), + ), + const SizedBox(width: 8), + Text( + '${_store.enEsperaCount} en Sala de Espera', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF15803D), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + + // Mensaje de Error / Reintento si aplica + if (_store.errorMessage != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, color: AppColors.warning), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Aviso de sincronización: ${_store.errorMessage}', + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + ), + ), + TextButton( + onPressed: () => _store.loadDashboardData(), + child: const Text('Reintentar'), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Las 4 Tarjetas de Métricas Solicitadas + DashboardMetricsCards(metrics: _store.metrics), + + const SizedBox(height: 24), + + // Paneles de Detalle: Pacientes Nuevos y Citas para Hoy + LayoutBuilder( + builder: (context, constraints) { + final recentCard = RecentPatientsCard( + patients: _store.recentPatients, + ); + + final appointmentsCard = TodayAppointmentsCard( + appointments: _store.todayAppointments, + isSecretary: _isSecretary, + onStatusChange: (patient, newStatus) async { + await _store.changeWaitingStatus(patient.id, newStatus); + }, + onSchedulePressed: () { + showMedicalModal( + context: context, + builder: (_) => AppointmentsModalView( + isSecretary: _isSecretary, + onStatusChanged: (patientId, newStatus) { + _store.changeWaitingStatus(patientId, newStatus); + }, + ), + ); + }, + onPatientTap: (patient) { + if (patient.id > 0) { + showMedicalModal( + context: context, + builder: (_) => PatientModalView( + patientId: patient.id, + isSecretary: _isSecretary, + onSaved: (_) => _store.loadDashboardData(), + ), + ); + } + }, + ); + + if (constraints.maxWidth >= 1000) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: recentCard), + const SizedBox(width: 16), + Expanded(child: appointmentsCard), + ], + ), + ); + } + + return Column( + children: [ + recentCard, + const SizedBox(height: 16), + appointmentsCard, + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + + diff --git a/lib/features/dashboard/presentation/widgets/dashboard_app_bar.dart b/lib/features/dashboard/presentation/widgets/dashboard_app_bar.dart new file mode 100644 index 0000000..8f9bfa9 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/dashboard_app_bar.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../widgets/quick_actions_dropdown.dart'; + +class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { + final String doctorName; + final String specialty; + final bool isSecretary; + final VoidCallback? onLogoutPressed; + final ValueChanged? onQuickActionSelected; + + const DashboardAppBar({ + super.key, + required this.doctorName, + required this.specialty, + this.isSecretary = false, + this.onLogoutPressed, + this.onQuickActionSelected, + }); + + @override + Size get preferredSize => const Size.fromHeight(70); + + @override + Widget build(BuildContext context) { + return Container( + height: preferredSize.height, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border( + bottom: BorderSide(color: AppColors.border, width: 1), + ), + ), + child: SafeArea( + bottom: false, + child: Row( + children: [ + // 1. Parte Izquierda: Nombre del Doctor y Especialidad (Flexible) + Expanded( + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.medical_services_rounded, + color: AppColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + doctorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + Row( + children: [ + Flexible( + child: Text( + specialty, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ), + if (isSecretary) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'RECEPCIÓN', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w700, + color: Color(0xFF00A65A), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + + // 2. Parte Central: Logo de Hidalyx + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SvgPicture.asset( + 'assets/images/hidalyx_logo_blue.svg', + height: 32, + fit: BoxFit.contain, + ), + ), + + // 3. Parte Derecha: Dropdown de Acciones Rápidas + Botón Cerrar Sesión + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + QuickActionsDropdown( + onActionSelected: onQuickActionSelected, + isSecretary: isSecretary, + ), + const SizedBox(width: 10), + Tooltip( + message: 'Cerrar sesión', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onLogoutPressed, + child: Container( + height: 36, + width: 36, + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.logout_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/dashboard_metrics_cards.dart b/lib/features/dashboard/presentation/widgets/dashboard_metrics_cards.dart new file mode 100644 index 0000000..d667827 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/dashboard_metrics_cards.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../data/models/dashboard_metrics_model.dart'; + +class DashboardMetricsCards extends StatelessWidget { + final DashboardMetricsModel metrics; + + const DashboardMetricsCards({super.key, required this.metrics}); + + String _formatNumber(int n) { + return n.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + } + + @override + Widget build(BuildContext context) { + final cards = [ + // 1. Total de Nuevos Pacientes + _buildMetricCard( + title: 'Total Nuevos Pacientes', + value: _formatNumber(metrics.nuevosPacientes), + subtitle: 'Registrados en la plataforma', + icon: Icons.person_add_alt_1_rounded, + iconColor: AppColors.primary, + bgColor: AppColors.primaryContainer, + ), + + // 2. Total de Consultados Hoy + _buildMetricCard( + title: 'Total Consultados Hoy', + value: _formatNumber(metrics.consultadosHoy), + subtitle: 'Atenciones médicas realizadas', + icon: Icons.assignment_turned_in_rounded, + iconColor: AppColors.teal, + bgColor: AppColors.teal.withValues(alpha: 0.12), + ), + + // 3. Total de Citas para Hoy + _buildMetricCard( + title: 'Total Citas para Hoy', + value: _formatNumber(metrics.citasHoy), + subtitle: 'Pacientes agendados', + icon: Icons.event_available_rounded, + iconColor: AppColors.warning, + bgColor: AppColors.warning.withValues(alpha: 0.12), + ), + + // 4. Total de Pacientes por Sexo + _buildGenderCard(), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 16.0; + + // Distribución equitativa en 4 columnas (pantallas amplias) + if (constraints.maxWidth >= 1000) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: cards[0]), + const SizedBox(width: spacing), + Expanded(child: cards[1]), + const SizedBox(width: spacing), + Expanded(child: cards[2]), + const SizedBox(width: spacing), + Expanded(child: cards[3]), + ], + ), + ); + } + + // Distribución equitativa en 2 columnas (pantallas medianas) + if (constraints.maxWidth >= 550) { + return Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: cards[0]), + const SizedBox(width: spacing), + Expanded(child: cards[1]), + ], + ), + ), + const SizedBox(height: spacing), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: cards[2]), + const SizedBox(width: spacing), + Expanded(child: cards[3]), + ], + ), + ), + ], + ); + } + + // 1 columna ocupando 100% del ancho (pantallas móviles / compactas) + return Column( + children: [ + cards[0], + const SizedBox(height: spacing), + cards[1], + const SizedBox(height: spacing), + cards[2], + const SizedBox(height: spacing), + cards[3], + ], + ); + }, + ); + } + + Widget _buildMetricCard({ + required String title, + String? value, + Widget? valueWidget, + required String subtitle, + required IconData icon, + required Color iconColor, + required Color bgColor, + }) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + valueWidget ?? + Text( + value ?? '', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ], + ), + ); + } + + + Widget _buildGenderCard() { + final total = metrics.pacientesMasculinos + metrics.pacientesFemeninos; + final mascPct = total > 0 ? ((metrics.pacientesMasculinos / total) * 100).toInt() : 81; + final femPct = total > 0 ? ((metrics.pacientesFemeninos / total) * 100).toInt() : 19; + + return _buildMetricCard( + title: 'Pacientes por Sexo', + valueWidget: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + _formatNumber(metrics.pacientesMasculinos), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const Text( + ' M', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const SizedBox(width: 8), + const Text( + '/', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.border, + ), + ), + const SizedBox(width: 8), + Text( + _formatNumber(metrics.pacientesFemeninos), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Color(0xFFEC4899), + ), + ), + const Text( + ' F', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFFEC4899), + ), + ), + ], + ), + ), + subtitle: '$mascPct% Masc · $femPct% Fem', + icon: Icons.people_alt_rounded, + iconColor: AppColors.secondary, + bgColor: AppColors.secondary.withValues(alpha: 0.12), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/quick_actions_dropdown.dart b/lib/features/dashboard/presentation/widgets/quick_actions_dropdown.dart new file mode 100644 index 0000000..5dee0ab --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/quick_actions_dropdown.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +enum QuickActionType { + nuevoPaciente, + buscarPaciente, + configuracion, +} + +typedef QuickActionBar = QuickActionsDropdown; + +class QuickActionsDropdown extends StatelessWidget { + final ValueChanged? onActionSelected; + final bool isSecretary; + + const QuickActionsDropdown({ + super.key, + this.onActionSelected, + this.isSecretary = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 1. Botón Buscar Paciente (Acción Secundaria / Buscador Rápido) + Tooltip( + message: 'Buscar paciente por nombre, cédula o teléfono', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onActionSelected?.call(QuickActionType.buscarPaciente), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_rounded, + color: AppColors.textSecondary, + size: 17, + ), + SizedBox(width: 7), + Text( + 'Buscar Paciente', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ), + + const SizedBox(width: 8), + + // 2. Botón Nuevo Paciente (Acción Primaria Destacada de 1 clic) + Tooltip( + message: 'Registrar nuevo paciente', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onActionSelected?.call(QuickActionType.nuevoPaciente), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.22), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_add_rounded, + color: Colors.white, + size: 17, + ), + SizedBox(width: 7), + Text( + 'Nuevo Paciente', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + + // 3. Botón Configuración (Solo Doctor / Administrador, oculto para Secretaria) + if (!isSecretary) ...[ + const SizedBox(width: 8), + Tooltip( + message: 'Configuración del Consultorio', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onActionSelected?.call(QuickActionType.configuracion), + child: Container( + height: 36, + width: 36, + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.settings_outlined, + color: AppColors.textSecondary, + size: 18, + ), + ), + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/recent_patients_card.dart b/lib/features/dashboard/presentation/widgets/recent_patients_card.dart new file mode 100644 index 0000000..e35a0d2 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/recent_patients_card.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../data/models/patient_summary_model.dart'; + +class RecentPatientsCard extends StatelessWidget { + final List patients; + + const RecentPatientsCard({ + super.key, + required this.patients, + }); + + String _getInitials(String name, String lastName) { + final n = name.trim().isNotEmpty ? name.trim()[0] : ''; + final l = lastName.trim().isNotEmpty ? lastName.trim()[0] : ''; + final res = '$n$l'.toUpperCase(); + return res.isNotEmpty ? res : 'P'; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Encabezado + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.person_add_alt_1_rounded, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pacientes Nuevos', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Últimos registros ingresados al sistema', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + const Divider(color: AppColors.divider, height: 1), + const SizedBox(height: 14), + + // Contenido con Scroll Interno delimitado + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 330), + child: Scrollbar( + child: SingleChildScrollView( + child: patients.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Column( + children: [ + Icon(Icons.people_outline_rounded, size: 40, color: AppColors.textMuted), + SizedBox(height: 8), + Text( + 'No se encontraron pacientes recientes', + style: TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ], + ), + ), + ) + : Padding( + padding: const EdgeInsets.only(right: 6), + child: Column( + children: [ + for (var i = 0; i < patients.length; i++) ...[ + if (i > 0) const Divider(color: AppColors.divider, height: 16), + Row( + children: [ + // Avatar con iniciales + CircleAvatar( + radius: 19, + backgroundColor: AppColors.primaryContainer, + child: Text( + _getInitials(patients[i].nombre, patients[i].apellido), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 14), + + // Nombre y Detalles + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + patients[i].fullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (patients[i].telefono.isNotEmpty) + Text( + patients[i].telefono, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + Text( + patients[i].cedula.isNotEmpty + ? patients[i].cedula + : (patients[i].seguro.isNotEmpty ? patients[i].seguro : 'Particular'), + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 10), + + // Badge de Fecha de Registro + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 11, + color: AppColors.textSecondary, + ), + const SizedBox(width: 5), + Text( + patients[i].fecReg.isNotEmpty + ? patients[i].fecReg.split(' ').first + : 'Reciente', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ), + ], +), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/today_appointments_card.dart b/lib/features/dashboard/presentation/widgets/today_appointments_card.dart new file mode 100644 index 0000000..2ed547f --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/today_appointments_card.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../data/models/patient_summary_model.dart'; + +class TodayAppointmentsCard extends StatelessWidget { + final List appointments; + final VoidCallback? onSchedulePressed; + final ValueChanged? onPatientTap; + final bool isSecretary; + final Future Function(PatientSummaryModel patient, String newStatus)? onStatusChange; + + const TodayAppointmentsCard({ + super.key, + required this.appointments, + this.onSchedulePressed, + this.onPatientTap, + this.isSecretary = false, + this.onStatusChange, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Encabezado + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.event_available_rounded, + color: AppColors.warning, + size: 20, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Citas para Hoy', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Agenda y turnos de consulta médica', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (onSchedulePressed != null) + TextButton( + onPressed: onSchedulePressed, + child: const Text( + 'Ver todos', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 18), + const Divider(color: AppColors.divider, height: 1), + const SizedBox(height: 14), + + // Contenido de Citas con Scroll Interno delimitado + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 330), + child: Scrollbar( + child: SingleChildScrollView( + child: appointments.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.event_note_rounded, + size: 40, + color: AppColors.textMuted.withValues(alpha: 0.6), + ), + const SizedBox(height: 8), + const Text( + 'No hay citas registradas para el día de hoy', + style: TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + const SizedBox(height: 4), + const Text( + 'Los nuevos turnos agendados aparecerán aquí', + style: TextStyle(color: AppColors.textMuted, fontSize: 11), + ), + ], + ), + ), + ) + : Padding( + padding: const EdgeInsets.only(right: 6), + child: Column( + children: [ + for (var i = 0; i < appointments.length; i++) ...[ + if (i > 0) const Divider(color: AppColors.divider, height: 16), + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onPatientTap != null && appointments[i].id > 0 + ? () => onPatientTap!(appointments[i]) + : null, + borderRadius: BorderRadius.circular(10), + hoverColor: AppColors.primary.withValues(alpha: 0.05), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: Row( + children: [ + // Icono de Cita + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.teal.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.access_time_rounded, + color: AppColors.teal, + size: 20, + ), + ), + const SizedBox(width: 14), + + // Paciente y Motivo de Consulta + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appointments[i].fullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (appointments[i].telefono.isNotEmpty) + Text( + appointments[i].telefono, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + Text( + appointments[i].motCons.isNotEmpty + ? appointments[i].motCons.replaceAll('\n', ' ').trim() + : 'Consulta de rutina', + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 10), + + // Estado de Sala de Espera y Acciones Rápidas + _buildStatusAndAction(context, appointments[i]), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatusAndAction(BuildContext context, PatientSummaryModel patient) { + const double uniformHeight = 28.0; + const double uniformRadius = 6.0; + + if (patient.isEnEspera) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Badge En Espera + Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFF86EFAC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)), + SizedBox(width: 4), + Text( + 'En Espera', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF15803D), + ), + ), + ], + ), + ), + + // Botón Doctor: Llamar a Consulta + if (!isSecretary) ...[ + const SizedBox(width: 6), + Tooltip( + message: 'Llamar a consulta médica ahora', + child: SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(uniformRadius), + ), + visualDensity: VisualDensity.compact, + ), + onPressed: () async { + if (onStatusChange != null) { + await onStatusChange!(patient, 'en_consulta'); + } + if (onPatientTap != null) { + onPatientTap!(patient); + } + }, + icon: const Icon(Icons.arrow_forward_rounded, size: 12), + label: const Text( + 'Atender', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ], + + // Botón Secretaria: Revertir si fue error + if (isSecretary && onStatusChange != null) ...[ + const SizedBox(width: 4), + Tooltip( + message: 'Quitar de la sala de espera', + child: InkWell( + onTap: () => onStatusChange!(patient, 'no_presente'), + borderRadius: BorderRadius.circular(uniformRadius), + child: Container( + height: uniformHeight, + width: uniformHeight, + alignment: Alignment.center, + child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted), + ), + ), + ), + ], + ], + ); + } + + if (patient.isEnConsulta) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onPatientTap != null ? () => onPatientTap!(patient) : null, + borderRadius: BorderRadius.circular(uniformRadius), + child: Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFF7DD3FC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)), + SizedBox(width: 4), + Text( + 'En Consulta', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF0369A1), + ), + ), + ], + ), + ), + ), + if (!isSecretary && onStatusChange != null) ...[ + const SizedBox(width: 6), + Tooltip( + message: 'Finalizar consulta médica y marcar como Atendido', + child: SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0284C7), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(uniformRadius), + ), + visualDensity: VisualDensity.compact, + ), + onPressed: () => onStatusChange!(patient, 'atendido'), + icon: const Icon(Icons.check_rounded, size: 13), + label: const Text( + 'Finalizar', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ], + ], + ); + } + + if (patient.isAtendido) { + return Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(uniformRadius), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)), + SizedBox(width: 4), + Text( + 'Atendido', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + ); + } + + // Estado no_presente (No ha llegado aún) + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSecretary && onStatusChange != null) + SizedBox( + height: uniformHeight, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(uniformRadius), + ), + visualDensity: VisualDensity.compact, + ), + onPressed: () => onStatusChange!(patient, 'en_espera'), + icon: const Icon(Icons.how_to_reg_rounded, size: 13), + label: const Text( + 'Llegó', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ) + else + Container( + height: uniformHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(uniformRadius), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_month_rounded, + size: 11, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + patient.fecProxCita.isNotEmpty + ? patient.fecProxCita.split(' ').first + : 'Hoy', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/insurance_auth/presentation/views/insurance_auth_modal_view.dart b/lib/features/insurance_auth/presentation/views/insurance_auth_modal_view.dart new file mode 100644 index 0000000..65908e0 --- /dev/null +++ b/lib/features/insurance_auth/presentation/views/insurance_auth_modal_view.dart @@ -0,0 +1,533 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../config/presentation/store/config_store.dart'; +import '../../services/insurance_auth_pdf_service.dart'; + +class InsuranceAuthModalView extends StatefulWidget { + final String patientName; + final String? initialDiagnosis; + final ConfigStore? configStore; + + const InsuranceAuthModalView({ + super.key, + required this.patientName, + this.initialDiagnosis, + this.configStore, + }); + + @override + State createState() => _InsuranceAuthModalViewState(); +} + +class _InsuranceAuthModalViewState extends State { + static const List arsList = [ + 'Humano Seguros, S.A.', + 'Seguros Universal, S. A.', + 'Seguros Reservas, S.A.', + 'La Monumental de Seguros, C por A', + 'Palic / Primera ARS', + 'Seguros Pepín, S. A.', + 'La Colonial, S.A.', + 'Banesco Seguros, S.A.', + 'Seguros La Internacional, S. A.', + 'Seguros Patria, S. A.', + 'Angloamericana de Seguros, S. A.', + 'General de Seguros, S.A.', + 'Seguros Ademi, S.A.', + 'ARS Senasa', + 'ARS Simag', + 'ARS Renacer', + 'ARS GMA', + 'ARS Yunen', + ]; + + late final TextEditingController _patientController; + late final TextEditingController _arsController; + late final TextEditingController _surgeryController; + late final TextEditingController _diagnosisController; + late final TextEditingController _anesthesiaController; + late final TextEditingController _daysController; + late final ConfigStore _configStore; + + bool _isPrinting = false; + String? _statusMessage; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _patientController = TextEditingController(text: widget.patientName); + _arsController = TextEditingController(text: arsList.first); + _surgeryController = TextEditingController(); + _diagnosisController = TextEditingController(text: widget.initialDiagnosis ?? ''); + _anesthesiaController = TextEditingController(text: 'General'); + _daysController = TextEditingController(text: '1'); + _configStore = widget.configStore ?? ConfigStore(); + _configStore.loadConfig(); + } + + @override + void dispose() { + _patientController.dispose(); + _arsController.dispose(); + _surgeryController.dispose(); + _diagnosisController.dispose(); + _anesthesiaController.dispose(); + _daysController.dispose(); + super.dispose(); + } + + Future _handlePrint() async { + final ars = _arsController.text.trim(); + final patient = _patientController.text.trim(); + final surgery = _surgeryController.text.trim(); + final diagnosis = _diagnosisController.text.trim(); + + if (ars.isEmpty) { + setState(() { + _statusMessage = 'Debe indicar o seleccionar la ARS destinataria.'; + _hasError = true; + }); + return; + } + + if (patient.isEmpty) { + setState(() { + _statusMessage = 'Debe indicar el nombre del paciente.'; + _hasError = true; + }); + return; + } + + if (surgery.isEmpty) { + setState(() { + _statusMessage = 'Debe indicar la cirugía o procedimiento a autorizar.'; + _hasError = true; + }); + return; + } + + if (diagnosis.isEmpty) { + setState(() { + _statusMessage = 'Debe indicar el diagnóstico correspondiente.'; + _hasError = true; + }); + return; + } + + setState(() { + _isPrinting = true; + _statusMessage = 'Generando solicitud de autorización de seguro...'; + _hasError = false; + }); + + try { + await InsuranceAuthPdfService.printInsuranceAuth( + doctorName: _configStore.nombreDoctor, + specialty: _configStore.especialidad, + subspecialty: _configStore.descripEspecialidad, + generalContact: _configStore.descripGeneral, + arsName: ars, + patientName: patient, + surgeryText: surgery, + diagnosisText: diagnosis, + anesthesia: _anesthesiaController.text.trim(), + daysOfStay: _daysController.text.trim(), + ); + + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Autorización generada con éxito.'; + _hasError = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Error al generar la autorización: $e'; + _hasError = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat('dd/MM/yyyy').format(DateTime.now()); + + return Observer( + builder: (_) { + return MedicalModalDialog( + maxWidth: 820, + maxHeight: 650, + title: 'Autorización De Seguro (ARS)', + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.verified_user_rounded, + color: Color(0xFF00A65A), + size: 20, + ), + ), + subtitle: const Text( + 'Formulario oficial de solicitud de cobertura quirúrgica para aseguradoras', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _hasError + ? AppColors.error.withValues(alpha: 0.12) + : const Color(0xFF00A65A).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + _hasError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + size: 15, + color: _hasError ? AppColors.error : const Color(0xFF00A65A), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage ?? + (_isPrinting + ? 'Preparando documento para impresión...' + : 'Listo para emitir la autorización de seguro'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _hasError ? AppColors.error : AppColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _isPrinting ? null : _handlePrint, + icon: _isPrinting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.print_rounded, size: 16), + label: Text( + _isPrinting ? 'Generando...' : 'Crear / Imprimir Autorización', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Membrete Informativo del Médico + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + _configStore.nombreDoctor.isNotEmpty + ? _configStore.nombreDoctor + : 'Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + if (_configStore.especialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.especialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + if (_configStore.descripEspecialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.descripEspecialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 4), + Text( + 'Fecha: $formattedDate', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Fila 1: Señores ARS y Paciente (Favor autorizar al Sr. a) + Row( + children: [ + Expanded( + flex: 5, + child: Autocomplete( + initialValue: TextEditingValue(text: _arsController.text), + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.isEmpty) { + return arsList; + } + return arsList.where((ars) => ars + .toLowerCase() + .contains(textEditingValue.text.toLowerCase())); + }, + onSelected: (selection) => _arsController.text = selection, + fieldViewBuilder: (context, controller, focusNode, onEditingComplete) { + controller.addListener(() { + _arsController.text = controller.text; + }); + return TextField( + controller: controller, + focusNode: focusNode, + onEditingComplete: onEditingComplete, + decoration: InputDecoration( + labelText: 'Señores ARS *', + hintText: 'Seleccione o escriba la ARS', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.security_rounded, size: 18, color: AppColors.primary), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), + ); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 5, + child: TextField( + controller: _patientController, + decoration: InputDecoration( + labelText: 'Favor autorizar al Sr. (a) *', + hintText: 'Nombre del paciente', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.person_outline_rounded, size: 18, color: AppColors.primary), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 14), + + // Campo: La cirugía de: + TextField( + controller: _surgeryController, + maxLines: 3, + style: const TextStyle(fontSize: 13, height: 1.4), + decoration: InputDecoration( + labelText: 'La cirugía de: *', + hintText: 'Especifique el procedimiento quirúrgico o intervención (ej. Ureterolitotomía endoscópica láser, Biopsia de próstata...)', + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 14), + + // Campo: Diagnóstico: + TextField( + controller: _diagnosisController, + maxLines: 3, + style: const TextStyle(fontSize: 13, height: 1.4), + decoration: InputDecoration( + labelText: 'Diagnóstico: *', + hintText: 'Diagnóstico principal, patología o hallazgo clínico que justifica la cirugía...', + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 14), + + // Fila 3: Bajo Anestesia y Días de ingreso + Row( + children: [ + Expanded( + flex: 6, + child: TextField( + controller: _anesthesiaController, + decoration: InputDecoration( + labelText: 'Bajo Anestesia', + hintText: 'General, Raquídea, Sedación, Local...', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.vaccines_outlined, size: 18, color: AppColors.primary), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + style: const TextStyle(fontSize: 12.5), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 4, + child: TextField( + controller: _daysController, + decoration: InputDecoration( + labelText: 'Días de ingreso', + hintText: 'Ej. 1, 2, Ambulatorio', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.hotel_outlined, size: 18, color: AppColors.primary), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + style: const TextStyle(fontSize: 12.5), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/insurance_auth/services/insurance_auth_pdf_service.dart b/lib/features/insurance_auth/services/insurance_auth_pdf_service.dart new file mode 100644 index 0000000..12c075e --- /dev/null +++ b/lib/features/insurance_auth/services/insurance_auth_pdf_service.dart @@ -0,0 +1,383 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class InsuranceAuthPdfService { + /// Genera el documento PDF de Autorización de Seguro en formato Media Carta Horizontal + /// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical. + static Future buildInsuranceAuthPdf({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String arsName, + required String patientName, + required String surgeryText, + required String diagnosisText, + String? anesthesia, + String? daysOfStay, + DateTime? date, + pw.Font? fontRegular, + pw.Font? fontBold, + pw.Font? fontTitle, + String? logoSvg, + }) async { + final pdf = pw.Document(title: 'Autorización de seguro - $patientName'); + final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now()); + + // Formato exacto e idéntico a AlfaMedical (ReportLab): + // pagesize = (8.5*inch, 5.5*inch) + // leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18. + const pageFormat = PdfPageFormat( + 8.5 * PdfPageFormat.inch, + 5.5 * PdfPageFormat.inch, + marginLeft: 72, + marginRight: 72, + marginTop: 18, + marginBottom: 18, + ); + + // Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet + pw.Font regular = fontRegular ?? pw.Font.helvetica(); + pw.Font bold = fontBold ?? pw.Font.helveticaBold(); + pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic(); + + if (fontRegular == null || fontBold == null || fontTitle == null) { + try { + regular = await PdfGoogleFonts.robotoRegular(); + bold = await PdfGoogleFonts.robotoBold(); + titleFont = await PdfGoogleFonts.playfairDisplayItalic(); + } catch (_) { + regular = pw.Font.helvetica(); + bold = pw.Font.helveticaBold(); + titleFont = pw.Font.timesBoldItalic(); + } + } + + String? svgContent = logoSvg; + if (svgContent == null) { + try { + svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg'); + } catch (_) { + svgContent = null; + } + } + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med + pw.Stack( + children: [ + // Nombre y especialidad del doctor centrado + pw.Align( + alignment: pw.Alignment.center, + child: pw.Column( + children: [ + pw.Text( + doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa', + style: pw.TextStyle( + font: titleFont, + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColor.fromHex('1E293B'), + ), + ), + pw.SizedBox(height: 3), + if (specialty.isNotEmpty) + pw.Text( + specialty.toUpperCase(), + style: pw.TextStyle( + font: bold, + fontSize: 10.5, + letterSpacing: 0.5, + color: PdfColor.fromHex('0F172A'), + ), + ), + if (subspecialty.isNotEmpty) ...[ + pw.SizedBox(height: 2), + pw.Text( + subspecialty.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 8.5, + color: PdfColor.fromHex('475569'), + ), + ), + ], + pw.SizedBox(height: 3), + pw.Text( + 'Fecha: $formattedDate', + style: pw.TextStyle( + font: regular, + fontSize: 9.5, + color: PdfColor.fromHex('334155'), + ), + ), + ], + ), + ), + + // Badge sutil de Hidalyx Med en la esquina superior derecha + pw.Positioned( + top: 0, + right: 0, + child: pw.Row( + mainAxisSize: pw.MainAxisSize.min, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + if (svgContent != null && svgContent.isNotEmpty) ...[ + pw.Opacity( + opacity: 0.6, + child: pw.SvgImage( + svg: svgContent, + width: 7.5, + height: 7.5, + ), + ), + pw.SizedBox(width: 3), + ], + pw.Text( + 'hidalyx.com', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 6), + + // Línea divisoria decorativa del membrete + pw.Container( + height: 1, + color: PdfColor.fromHex('CBD5E1'), + ), + pw.SizedBox(height: 8), + + // 2. Encabezado de la Orden Médica (Símbolo Rx) + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: pw.BoxDecoration( + color: PdfColor.fromHex('E2E8F0'), + borderRadius: pw.BorderRadius.circular(4), + ), + child: pw.Text( + 'Rx', + style: pw.TextStyle( + font: bold, + fontSize: 12, + color: PdfColor.fromHex('00A65A'), + ), + ), + ), + pw.SizedBox(width: 8), + pw.Text( + 'SOLICITUD DE AUTORIZACIÓN QUIRÚRGICA', + style: pw.TextStyle( + font: bold, + fontSize: 10, + letterSpacing: 0.5, + color: PdfColor.fromHex('475569'), + ), + ), + ], + ), + pw.SizedBox(height: 8), + + // 3. Contenido Formal idéntico a AlfaMedical + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // Señores ARS + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: 'Señores ARS: ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: arsName.isNotEmpty ? arsName : '____________________________________', + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + ), + ), + pw.SizedBox(height: 6), + + // Favor autorizar al Sr. (a) + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: 'Favor autorizar al Sr. (a): ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: patientName.isNotEmpty ? patientName : '___________________________', + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + ), + ), + pw.SizedBox(height: 6), + + // La cirugía de: + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: 'La cirugía de: ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: surgeryText.trim().isNotEmpty ? surgeryText.trim() : '___________________________', + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + ), + ), + pw.SizedBox(height: 6), + + // Diagnóstico + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: 'Diagnóstico: ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: diagnosisText.trim().isNotEmpty ? diagnosisText.trim() : '___________________________', + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + ), + ), + pw.SizedBox(height: 6), + + // Bajo Anestesia y Días de ingreso + if ((anesthesia != null && anesthesia.trim().isNotEmpty) || + (daysOfStay != null && daysOfStay.trim().isNotEmpty)) ...[ + pw.RichText( + text: pw.TextSpan( + children: [ + if (anesthesia != null && anesthesia.trim().isNotEmpty) ...[ + pw.TextSpan( + text: 'Bajo Anestesia: ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: anesthesia.trim(), + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + if (daysOfStay != null && daysOfStay.trim().isNotEmpty) ...[ + pw.TextSpan( + text: ' Con ', + style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')), + ), + pw.TextSpan( + text: '${daysOfStay.trim()} Días de ingreso', + style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')), + ), + ], + ], + ), + ), + ], + ], + ), + ), + pw.SizedBox(height: 6), + + // 4. Pie de página con Contacto del Consultorio + if (generalContact.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.only(top: 4), + child: pw.Text( + generalContact.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 6.8, + color: PdfColor.fromHex('64748B'), + lineSpacing: 1.5, + ), + ), + ), + ], + + pw.SizedBox(height: 3), + + // Confirmación institucional + pw.Center( + child: pw.Text( + 'Documento médico emitido electrónicamente', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ), + ], + ); + }, + ), + ); + + return pdf.save(); + } + + /// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF. + static Future printInsuranceAuth({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String arsName, + required String patientName, + required String surgeryText, + required String diagnosisText, + String? anesthesia, + String? daysOfStay, + DateTime? date, + }) async { + final pdfBytes = await buildInsuranceAuthPdf( + doctorName: doctorName, + specialty: specialty, + subspecialty: subspecialty, + generalContact: generalContact, + arsName: arsName, + patientName: patientName, + surgeryText: surgeryText, + diagnosisText: diagnosisText, + anesthesia: anesthesia, + daysOfStay: daysOfStay, + date: date, + ); + + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + name: 'Autorizacion_Seguro_${patientName.replaceAll(' ', '_')}', + ); + } +} diff --git a/lib/features/patient/data/models/patient_model.dart b/lib/features/patient/data/models/patient_model.dart new file mode 100644 index 0000000..6ab52c9 --- /dev/null +++ b/lib/features/patient/data/models/patient_model.dart @@ -0,0 +1,175 @@ +class PatientModel { + final int? id; + final String nombre; + final String apellido; + final String telefono; + final String direccion; + final String cedula; + final String ocupacion; + final String edad; + final String fecUltcon; + final String fecReg; + final String sexo; + final String seguro; + final String estCivil; + final String motCons; + final String hisEnfAct; + final String datPadre; + final String datEsp; + final String datHijos; + final String datOtrosnp; + final String datAlerg; + final String datTran; + final String datOtrosp; + final String metDiagnRad; + final String datLab; + final String diagnDesc; + final String diagnDef; + final String fecProxcita; + final String tratamiento; + final String datHer; + final String datMad; + final String datNi; + final String datAdo; + final String datAdu; + final String datAc; + final String datCir; + final String datJuv; + final String estadoEspera; + + const PatientModel({ + this.id, + this.nombre = '', + this.apellido = '', + this.telefono = '', + this.direccion = '', + this.cedula = '', + this.ocupacion = '', + this.edad = '', + this.fecUltcon = '', + this.fecReg = '', + this.sexo = 'Masculino', + this.seguro = 'Sí', + this.estCivil = 'Soltero', + this.motCons = '', + this.hisEnfAct = '', + this.datPadre = '', + this.datEsp = '', + this.datHijos = '', + this.datOtrosnp = '', + this.datAlerg = '', + this.datTran = '', + this.datOtrosp = '', + this.metDiagnRad = '', + this.datLab = '', + this.diagnDesc = '', + this.diagnDef = '', + this.fecProxcita = '', + this.tratamiento = '', + this.datHer = '', + this.datMad = '', + this.datNi = '', + this.datAdo = '', + this.datAdu = '', + this.datAc = '', + this.datCir = '', + this.datJuv = '', + this.estadoEspera = 'no_presente', + }); + + String get fullName => '$nombre $apellido'.trim(); + + factory PatientModel.fromJson(Map json) { + int? parsedId; + if (json['id'] is int) { + parsedId = json['id'] as int; + } else if (json['id'] != null) { + parsedId = int.tryParse(json['id'].toString()); + } + + return PatientModel( + id: parsedId, + nombre: json['nombre']?.toString().trim() ?? '', + apellido: json['apellido']?.toString().trim() ?? '', + telefono: json['telefono']?.toString().trim() ?? '', + direccion: json['direccion']?.toString().trim() ?? '', + cedula: json['cedula']?.toString().trim() ?? '', + ocupacion: json['ocupacion']?.toString().trim() ?? '', + edad: json['edad']?.toString().trim() ?? '', + fecUltcon: json['fec_ultcon']?.toString().trim() ?? '', + fecReg: json['fec_reg']?.toString().trim() ?? '', + sexo: json['sexo']?.toString().trim() ?? 'Masculino', + seguro: json['seguro']?.toString().trim() ?? 'Sí', + estCivil: json['est_civil']?.toString().trim() ?? 'Soltero', + motCons: json['mot_cons']?.toString().trim() ?? '', + hisEnfAct: json['his_enf_act']?.toString().trim() ?? '', + datPadre: json['dat_padre']?.toString().trim() ?? '', + datEsp: json['dat_esp']?.toString().trim() ?? '', + datHijos: json['dat_hijos']?.toString().trim() ?? '', + datOtrosnp: json['dat_otrosnp']?.toString().trim() ?? '', + datAlerg: json['dat_alerg']?.toString().trim() ?? '', + datTran: json['dat_tran']?.toString().trim() ?? '', + datOtrosp: json['dat_otrosp']?.toString().trim() ?? '', + metDiagnRad: json['met_diagn_rad']?.toString().trim() ?? '', + datLab: json['dat_lab']?.toString().trim() ?? '', + diagnDesc: json['diagn_desc']?.toString().trim() ?? '', + diagnDef: json['diagn_def']?.toString().trim() ?? '', + fecProxcita: json['fec_proxcita']?.toString().trim() ?? '', + tratamiento: json['tratamiento']?.toString().trim() ?? '', + datHer: json['dat_her']?.toString().trim() ?? '', + datMad: json['dat_mad']?.toString().trim() ?? '', + datNi: json['dat_ni']?.toString().trim() ?? '', + datAdo: json['dat_ado']?.toString().trim() ?? '', + datAdu: json['dat_adu']?.toString().trim() ?? '', + datAc: json['dat_ac']?.toString().trim() ?? '', + datCir: json['dat_cir']?.toString().trim() ?? '', + datJuv: json['dat_juv']?.toString().trim() ?? '', + estadoEspera: json['estado_espera']?.toString().trim() ?? 'no_presente', + ); + } + + Map toJson({bool includeId = false}) { + final data = { + 'nombre': nombre, + 'apellido': apellido, + 'telefono': telefono, + 'direccion': direccion, + 'cedula': cedula, + 'ocupacion': ocupacion, + 'edad': edad, + 'fec_ultcon': fecUltcon, + 'fec_reg': fecReg, + 'sexo': sexo, + 'seguro': seguro, + 'est_civil': estCivil, + 'mot_cons': motCons, + 'his_enf_act': hisEnfAct, + 'dat_padre': datPadre, + 'dat_esp': datEsp, + 'dat_hijos': datHijos, + 'dat_otrosnp': datOtrosnp, + 'dat_alerg': datAlerg, + 'dat_tran': datTran, + 'dat_otrosp': datOtrosp, + 'met_diagn_rad': metDiagnRad, + 'dat_lab': datLab, + 'diagn_desc': diagnDesc, + 'diagn_def': diagnDef, + 'fec_proxcita': fecProxcita, + 'tratamiento': tratamiento, + 'dat_her': datHer, + 'dat_mad': datMad, + 'dat_ni': datNi, + 'dat_ado': datAdo, + 'dat_adu': datAdu, + 'dat_ac': datAc, + 'dat_cir': datCir, + 'dat_juv': datJuv, + 'estado_espera': estadoEspera, + }; + if (includeId && id != null) { + data['id'] = id; + } + return data; + } +} diff --git a/lib/features/patient/data/services/patient_service.dart b/lib/features/patient/data/services/patient_service.dart new file mode 100644 index 0000000..e6b67f3 --- /dev/null +++ b/lib/features/patient/data/services/patient_service.dart @@ -0,0 +1,64 @@ +import 'package:dio/dio.dart'; +import '../../../../core/network/api_client.dart'; +import '../models/patient_model.dart'; + +class PatientService { + final ApiClient apiClient; + + PatientService({ApiClient? client}) : apiClient = client ?? ApiClient(); + + Future getPatientById(int id) async { + try { + final response = await apiClient.dio.get('/items/pacientes/$id'); + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data'] as Map; + return PatientModel.fromJson(data); + } + throw Exception('No se encontró el paciente con ID $id'); + } on DioException catch (e) { + final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message; + throw Exception('Error al obtener datos del paciente: $serverMessage'); + } catch (e) { + throw Exception('Error al obtener datos del paciente: $e'); + } + } + + Future createPatient(PatientModel model) async { + try { + final payload = model.toJson(includeId: false); + final response = await apiClient.dio.post( + '/items/pacientes', + data: payload, + ); + if ((response.statusCode == 200 || response.statusCode == 201) && response.data != null) { + final data = response.data['data'] as Map; + return PatientModel.fromJson(data); + } + throw Exception('Error en el servidor al registrar el paciente'); + } on DioException catch (e) { + final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message; + throw Exception('Error al registrar paciente: $serverMessage'); + } catch (e) { + throw Exception('Error al registrar paciente: $e'); + } + } + + Future updatePatient(int id, Map updates) async { + try { + final response = await apiClient.dio.patch( + '/items/pacientes/$id', + data: updates, + ); + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data'] as Map; + return PatientModel.fromJson(data); + } + throw Exception('Error en el servidor al actualizar el paciente'); + } on DioException catch (e) { + final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message; + throw Exception('Error al actualizar paciente: $serverMessage'); + } catch (e) { + throw Exception('Error al actualizar paciente: $e'); + } + } +} diff --git a/lib/features/patient/presentation/store/patient_store.dart b/lib/features/patient/presentation/store/patient_store.dart new file mode 100644 index 0000000..bb39ed7 --- /dev/null +++ b/lib/features/patient/presentation/store/patient_store.dart @@ -0,0 +1,394 @@ +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; +import '../../data/models/patient_model.dart'; +import '../../data/services/patient_service.dart'; + +part 'patient_store.g.dart'; + +// ignore: library_private_types_in_public_api +class PatientStore = _PatientStoreBase with _$PatientStore; + +abstract class _PatientStoreBase with Store { + final PatientService service; + + _PatientStoreBase({PatientService? patientService}) + : service = patientService ?? PatientService(); + + @observable + int? id; + + @observable + bool isLoading = false; + + @observable + bool isSaving = false; + + @observable + String? errorMessage; + + @observable + String? successMessage; + + // Campos Demográficos Superiores + @observable + String fecReg = ''; + + @observable + String cedula = ''; + + @observable + String nombre = ''; + + @observable + String apellido = ''; + + @observable + String direccion = ''; + + @observable + String telefono = ''; + + @observable + String ocupacion = ''; + + @observable + String edad = ''; + + @observable + String sexo = 'Masculino'; + + @observable + String seguro = 'Sí'; + + @observable + String estCivil = 'Soltero'; + + // Pestañas Clínicas + @observable + String motCons = ''; + + @observable + String fecUltcon = ''; + + @observable + String hisEnfAct = ''; + + // Antecedentes Personales Patológicos + @observable + String datNi = 'Ninguna'; + + @observable + String datAdo = 'Ninguna'; + + @observable + String datAdu = 'Ninguna'; + + @observable + String datAlerg = 'Ninguna'; + + @observable + String datTran = 'ninguna'; + + @observable + String datCir = 'ninguna'; + + @observable + String datAc = 'ninguno'; + + @observable + String datOtrosp = 'ninguno'; + + // Antecedentes No Patológicos + @observable + String datMad = 'Viva y sana'; + + @observable + String datEsp = 'Viva y sana'; + + @observable + String datPadre = 'Vivo y sano'; + + @observable + String datHijos = 'Vivos y sanos'; + + @observable + String datHer = 'Vivos y sanos'; + + @observable + String datOtrosnp = 'Ningunos'; + + // Diagnósticos + @observable + String metDiagnRad = ''; + + @observable + String diagnDesc = ''; + + @observable + String diagnDef = ''; + + // Laboratorios y Tratamiento + @observable + String datLab = ''; + + @observable + String tratamiento = ''; + + // Próxima Cita + @observable + String fecProxcita = ''; + + // Estado de Sala de Espera + @observable + String estadoEspera = 'no_presente'; + + @computed + bool get isEditing => id != null; + + @computed + bool get isValid => + nombre.trim().isNotEmpty && + apellido.trim().isNotEmpty && + sexo.trim().isNotEmpty && + seguro.trim().isNotEmpty; + + @computed + int? get diasParaProximaCita { + if (fecProxcita.isEmpty) return null; + try { + DateTime? target; + if (fecProxcita.contains('-')) { + target = DateTime.tryParse(fecProxcita); + } else if (fecProxcita.contains('/')) { + final parts = fecProxcita.split('/'); + if (parts.length == 3) { + final day = int.parse(parts[0]); + final month = int.parse(parts[1]); + final year = int.parse(parts[2]); + target = DateTime(year, month, day); + } + } + if (target == null) return null; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final cleanTarget = DateTime(target.year, target.month, target.day); + return cleanTarget.difference(today).inDays; + } catch (_) { + return null; + } + } + + @action + void initForCreate() { + id = null; + errorMessage = null; + successMessage = null; + isLoading = false; + isSaving = false; + + final now = DateTime.now(); + fecReg = DateFormat('dd/MM/yyyy').format(now); + fecUltcon = DateFormat('dd/MM/yyyy').format(now); + cedula = ''; + nombre = ''; + apellido = ''; + direccion = ''; + telefono = ''; + ocupacion = ''; + edad = ''; + sexo = 'Masculino'; + seguro = 'Sí'; + estCivil = 'Soltero'; + + motCons = ''; + hisEnfAct = ''; + datNi = 'Ninguna'; + datAdo = 'Ninguna'; + datAdu = 'Ninguna'; + datAlerg = 'Ninguna'; + datTran = 'ninguna'; + datCir = 'ninguna'; + datAc = 'ninguno'; + datOtrosp = 'ninguno'; + + datMad = 'Viva y sana'; + datEsp = 'Viva y sana'; + datPadre = 'Vivo y sano'; + datHijos = 'Vivos y sanos'; + datHer = 'Vivos y sanos'; + datOtrosnp = 'Ningunos'; + + metDiagnRad = ''; + diagnDesc = ''; + diagnDef = ''; + datLab = ''; + tratamiento = ''; + fecProxcita = ''; + estadoEspera = 'no_presente'; + } + + @action + void populateFromModel(PatientModel p) { + id = p.id; + fecReg = p.fecReg; + fecUltcon = p.fecUltcon; + cedula = p.cedula; + nombre = p.nombre; + apellido = p.apellido; + direccion = p.direccion; + telefono = p.telefono; + ocupacion = p.ocupacion; + edad = p.edad; + sexo = p.sexo.isNotEmpty ? p.sexo : 'Masculino'; + seguro = p.seguro.isNotEmpty ? p.seguro : 'Sí'; + estCivil = p.estCivil.isNotEmpty ? p.estCivil : 'Soltero'; + + motCons = p.motCons; + hisEnfAct = p.hisEnfAct; + datNi = p.datNi.isNotEmpty ? p.datNi : 'Ninguna'; + datAdo = p.datAdo.isNotEmpty ? p.datAdo : 'Ninguna'; + datAdu = p.datAdu.isNotEmpty ? p.datAdu : 'Ninguna'; + datAlerg = p.datAlerg.isNotEmpty ? p.datAlerg : 'Ninguna'; + datTran = p.datTran.isNotEmpty ? p.datTran : 'ninguna'; + datCir = p.datCir.isNotEmpty ? p.datCir : 'ninguna'; + datAc = p.datAc.isNotEmpty ? p.datAc : 'ninguno'; + datOtrosp = p.datOtrosp.isNotEmpty ? p.datOtrosp : 'ninguno'; + + datMad = p.datMad.isNotEmpty ? p.datMad : 'Viva y sana'; + datEsp = p.datEsp.isNotEmpty ? p.datEsp : 'Viva y sana'; + datPadre = p.datPadre.isNotEmpty ? p.datPadre : 'Vivo y sano'; + datHijos = p.datHijos.isNotEmpty ? p.datHijos : 'Vivos y sanos'; + datHer = p.datHer.isNotEmpty ? p.datHer : 'Vivos y sanos'; + datOtrosnp = p.datOtrosnp.isNotEmpty ? p.datOtrosnp : 'Ningunos'; + + metDiagnRad = p.metDiagnRad; + diagnDesc = p.diagnDesc; + diagnDef = p.diagnDef; + datLab = p.datLab; + tratamiento = p.tratamiento; + fecProxcita = p.fecProxcita; + estadoEspera = p.estadoEspera.isNotEmpty ? p.estadoEspera : 'no_presente'; + } + + @action + Future loadPatient(int patientId) async { + isLoading = true; + errorMessage = null; + try { + final p = await service.getPatientById(patientId); + runInAction(() { + populateFromModel(p); + isLoading = false; + }); + } catch (e) { + runInAction(() { + errorMessage = e.toString().replaceAll('Exception: ', ''); + isLoading = false; + }); + } + } + + @action + void appendCurrentDateToConsultas() { + final dateStr = DateFormat('dd/MM/yyyy').format(DateTime.now()); + if (motCons.isEmpty) { + motCons = '$dateStr: '; + } else { + motCons = '$motCons\n$dateStr: '; + } + } + + @action + void appendCurrentDateToHistorial() { + final dateStr = DateFormat('dd/MM/yyyy').format(DateTime.now()); + if (hisEnfAct.isEmpty) { + hisEnfAct = '$dateStr: '; + } else { + hisEnfAct = '$hisEnfAct\n$dateStr: '; + } + } + + PatientModel toModel() { + return PatientModel( + id: id, + nombre: nombre.trim(), + apellido: apellido.trim(), + telefono: telefono.trim(), + direccion: direccion.trim(), + cedula: cedula.trim(), + ocupacion: ocupacion.trim(), + edad: edad.trim(), + fecUltcon: fecUltcon.trim(), + fecReg: fecReg.trim(), + sexo: sexo.trim(), + seguro: seguro.trim(), + estCivil: estCivil.trim(), + motCons: motCons, + hisEnfAct: hisEnfAct, + datPadre: datPadre, + datEsp: datEsp, + datHijos: datHijos, + datOtrosnp: datOtrosnp, + datAlerg: datAlerg, + datTran: datTran, + datOtrosp: datOtrosp, + metDiagnRad: metDiagnRad, + datLab: datLab, + diagnDesc: diagnDesc, + diagnDef: diagnDef, + fecProxcita: fecProxcita.trim(), + tratamiento: tratamiento, + datHer: datHer, + datMad: datMad, + datNi: datNi, + datAdo: datAdo, + datAdu: datAdu, + datAc: datAc, + datCir: datCir, + estadoEspera: estadoEspera, + ); + } + + @action + Future savePatient() async { + if (!isValid) { + errorMessage = 'Faltan campos obligatorios: Nombre, Apellido, Sexo y Seguro.'; + return false; + } + + // Si el paciente estaba en consulta o en espera y el médico guarda los cambios de la consulta médica, + // la atención concluye formalmente y su estado de sala de espera pasa a 'atendido'. + if (estadoEspera == 'en_consulta' || estadoEspera == 'en_espera') { + estadoEspera = 'atendido'; + } + + isSaving = true; + errorMessage = null; + successMessage = null; + + try { + final model = toModel(); + if (isEditing) { + await service.updatePatient(id!, model.toJson(includeId: false)); + runInAction(() { + successMessage = 'Paciente ${model.fullName} actualizado correctamente'; + isSaving = false; + }); + } else { + final created = await service.createPatient(model); + runInAction(() { + id = created.id; + successMessage = 'Paciente ${model.fullName} guardado correctamente'; + isSaving = false; + }); + } + return true; + } catch (e) { + runInAction(() { + errorMessage = e.toString().replaceAll('Exception: ', ''); + isSaving = false; + }); + return false; + } + } +} diff --git a/lib/features/patient/presentation/store/patient_store.g.dart b/lib/features/patient/presentation/store/patient_store.g.dart new file mode 100644 index 0000000..fbcbf64 --- /dev/null +++ b/lib/features/patient/presentation/store/patient_store.g.dart @@ -0,0 +1,873 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'patient_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$PatientStore on _PatientStoreBase, Store { + Computed? _$isEditingComputed; + + @override + bool get isEditing => (_$isEditingComputed ??= Computed( + () => super.isEditing, + name: '_PatientStoreBase.isEditing', + )).value; + Computed? _$isValidComputed; + + @override + bool get isValid => (_$isValidComputed ??= Computed( + () => super.isValid, + name: '_PatientStoreBase.isValid', + )).value; + Computed? _$diasParaProximaCitaComputed; + + @override + int? get diasParaProximaCita => + (_$diasParaProximaCitaComputed ??= Computed( + () => super.diasParaProximaCita, + name: '_PatientStoreBase.diasParaProximaCita', + )).value; + + late final _$idAtom = Atom(name: '_PatientStoreBase.id', context: context); + + @override + int? get id { + _$idAtom.reportRead(); + return super.id; + } + + @override + set id(int? value) { + _$idAtom.reportWrite(value, super.id, () { + super.id = value; + }); + } + + late final _$isLoadingAtom = Atom( + name: '_PatientStoreBase.isLoading', + context: context, + ); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$isSavingAtom = Atom( + name: '_PatientStoreBase.isSaving', + context: context, + ); + + @override + bool get isSaving { + _$isSavingAtom.reportRead(); + return super.isSaving; + } + + @override + set isSaving(bool value) { + _$isSavingAtom.reportWrite(value, super.isSaving, () { + super.isSaving = value; + }); + } + + late final _$errorMessageAtom = Atom( + name: '_PatientStoreBase.errorMessage', + context: context, + ); + + @override + String? get errorMessage { + _$errorMessageAtom.reportRead(); + return super.errorMessage; + } + + @override + set errorMessage(String? value) { + _$errorMessageAtom.reportWrite(value, super.errorMessage, () { + super.errorMessage = value; + }); + } + + late final _$successMessageAtom = Atom( + name: '_PatientStoreBase.successMessage', + context: context, + ); + + @override + String? get successMessage { + _$successMessageAtom.reportRead(); + return super.successMessage; + } + + @override + set successMessage(String? value) { + _$successMessageAtom.reportWrite(value, super.successMessage, () { + super.successMessage = value; + }); + } + + late final _$fecRegAtom = Atom( + name: '_PatientStoreBase.fecReg', + context: context, + ); + + @override + String get fecReg { + _$fecRegAtom.reportRead(); + return super.fecReg; + } + + @override + set fecReg(String value) { + _$fecRegAtom.reportWrite(value, super.fecReg, () { + super.fecReg = value; + }); + } + + late final _$cedulaAtom = Atom( + name: '_PatientStoreBase.cedula', + context: context, + ); + + @override + String get cedula { + _$cedulaAtom.reportRead(); + return super.cedula; + } + + @override + set cedula(String value) { + _$cedulaAtom.reportWrite(value, super.cedula, () { + super.cedula = value; + }); + } + + late final _$nombreAtom = Atom( + name: '_PatientStoreBase.nombre', + context: context, + ); + + @override + String get nombre { + _$nombreAtom.reportRead(); + return super.nombre; + } + + @override + set nombre(String value) { + _$nombreAtom.reportWrite(value, super.nombre, () { + super.nombre = value; + }); + } + + late final _$apellidoAtom = Atom( + name: '_PatientStoreBase.apellido', + context: context, + ); + + @override + String get apellido { + _$apellidoAtom.reportRead(); + return super.apellido; + } + + @override + set apellido(String value) { + _$apellidoAtom.reportWrite(value, super.apellido, () { + super.apellido = value; + }); + } + + late final _$direccionAtom = Atom( + name: '_PatientStoreBase.direccion', + context: context, + ); + + @override + String get direccion { + _$direccionAtom.reportRead(); + return super.direccion; + } + + @override + set direccion(String value) { + _$direccionAtom.reportWrite(value, super.direccion, () { + super.direccion = value; + }); + } + + late final _$telefonoAtom = Atom( + name: '_PatientStoreBase.telefono', + context: context, + ); + + @override + String get telefono { + _$telefonoAtom.reportRead(); + return super.telefono; + } + + @override + set telefono(String value) { + _$telefonoAtom.reportWrite(value, super.telefono, () { + super.telefono = value; + }); + } + + late final _$ocupacionAtom = Atom( + name: '_PatientStoreBase.ocupacion', + context: context, + ); + + @override + String get ocupacion { + _$ocupacionAtom.reportRead(); + return super.ocupacion; + } + + @override + set ocupacion(String value) { + _$ocupacionAtom.reportWrite(value, super.ocupacion, () { + super.ocupacion = value; + }); + } + + late final _$edadAtom = Atom( + name: '_PatientStoreBase.edad', + context: context, + ); + + @override + String get edad { + _$edadAtom.reportRead(); + return super.edad; + } + + @override + set edad(String value) { + _$edadAtom.reportWrite(value, super.edad, () { + super.edad = value; + }); + } + + late final _$sexoAtom = Atom( + name: '_PatientStoreBase.sexo', + context: context, + ); + + @override + String get sexo { + _$sexoAtom.reportRead(); + return super.sexo; + } + + @override + set sexo(String value) { + _$sexoAtom.reportWrite(value, super.sexo, () { + super.sexo = value; + }); + } + + late final _$seguroAtom = Atom( + name: '_PatientStoreBase.seguro', + context: context, + ); + + @override + String get seguro { + _$seguroAtom.reportRead(); + return super.seguro; + } + + @override + set seguro(String value) { + _$seguroAtom.reportWrite(value, super.seguro, () { + super.seguro = value; + }); + } + + late final _$estCivilAtom = Atom( + name: '_PatientStoreBase.estCivil', + context: context, + ); + + @override + String get estCivil { + _$estCivilAtom.reportRead(); + return super.estCivil; + } + + @override + set estCivil(String value) { + _$estCivilAtom.reportWrite(value, super.estCivil, () { + super.estCivil = value; + }); + } + + late final _$motConsAtom = Atom( + name: '_PatientStoreBase.motCons', + context: context, + ); + + @override + String get motCons { + _$motConsAtom.reportRead(); + return super.motCons; + } + + @override + set motCons(String value) { + _$motConsAtom.reportWrite(value, super.motCons, () { + super.motCons = value; + }); + } + + late final _$fecUltconAtom = Atom( + name: '_PatientStoreBase.fecUltcon', + context: context, + ); + + @override + String get fecUltcon { + _$fecUltconAtom.reportRead(); + return super.fecUltcon; + } + + @override + set fecUltcon(String value) { + _$fecUltconAtom.reportWrite(value, super.fecUltcon, () { + super.fecUltcon = value; + }); + } + + late final _$hisEnfActAtom = Atom( + name: '_PatientStoreBase.hisEnfAct', + context: context, + ); + + @override + String get hisEnfAct { + _$hisEnfActAtom.reportRead(); + return super.hisEnfAct; + } + + @override + set hisEnfAct(String value) { + _$hisEnfActAtom.reportWrite(value, super.hisEnfAct, () { + super.hisEnfAct = value; + }); + } + + late final _$datNiAtom = Atom( + name: '_PatientStoreBase.datNi', + context: context, + ); + + @override + String get datNi { + _$datNiAtom.reportRead(); + return super.datNi; + } + + @override + set datNi(String value) { + _$datNiAtom.reportWrite(value, super.datNi, () { + super.datNi = value; + }); + } + + late final _$datAdoAtom = Atom( + name: '_PatientStoreBase.datAdo', + context: context, + ); + + @override + String get datAdo { + _$datAdoAtom.reportRead(); + return super.datAdo; + } + + @override + set datAdo(String value) { + _$datAdoAtom.reportWrite(value, super.datAdo, () { + super.datAdo = value; + }); + } + + late final _$datAduAtom = Atom( + name: '_PatientStoreBase.datAdu', + context: context, + ); + + @override + String get datAdu { + _$datAduAtom.reportRead(); + return super.datAdu; + } + + @override + set datAdu(String value) { + _$datAduAtom.reportWrite(value, super.datAdu, () { + super.datAdu = value; + }); + } + + late final _$datAlergAtom = Atom( + name: '_PatientStoreBase.datAlerg', + context: context, + ); + + @override + String get datAlerg { + _$datAlergAtom.reportRead(); + return super.datAlerg; + } + + @override + set datAlerg(String value) { + _$datAlergAtom.reportWrite(value, super.datAlerg, () { + super.datAlerg = value; + }); + } + + late final _$datTranAtom = Atom( + name: '_PatientStoreBase.datTran', + context: context, + ); + + @override + String get datTran { + _$datTranAtom.reportRead(); + return super.datTran; + } + + @override + set datTran(String value) { + _$datTranAtom.reportWrite(value, super.datTran, () { + super.datTran = value; + }); + } + + late final _$datCirAtom = Atom( + name: '_PatientStoreBase.datCir', + context: context, + ); + + @override + String get datCir { + _$datCirAtom.reportRead(); + return super.datCir; + } + + @override + set datCir(String value) { + _$datCirAtom.reportWrite(value, super.datCir, () { + super.datCir = value; + }); + } + + late final _$datAcAtom = Atom( + name: '_PatientStoreBase.datAc', + context: context, + ); + + @override + String get datAc { + _$datAcAtom.reportRead(); + return super.datAc; + } + + @override + set datAc(String value) { + _$datAcAtom.reportWrite(value, super.datAc, () { + super.datAc = value; + }); + } + + late final _$datOtrospAtom = Atom( + name: '_PatientStoreBase.datOtrosp', + context: context, + ); + + @override + String get datOtrosp { + _$datOtrospAtom.reportRead(); + return super.datOtrosp; + } + + @override + set datOtrosp(String value) { + _$datOtrospAtom.reportWrite(value, super.datOtrosp, () { + super.datOtrosp = value; + }); + } + + late final _$datMadAtom = Atom( + name: '_PatientStoreBase.datMad', + context: context, + ); + + @override + String get datMad { + _$datMadAtom.reportRead(); + return super.datMad; + } + + @override + set datMad(String value) { + _$datMadAtom.reportWrite(value, super.datMad, () { + super.datMad = value; + }); + } + + late final _$datEspAtom = Atom( + name: '_PatientStoreBase.datEsp', + context: context, + ); + + @override + String get datEsp { + _$datEspAtom.reportRead(); + return super.datEsp; + } + + @override + set datEsp(String value) { + _$datEspAtom.reportWrite(value, super.datEsp, () { + super.datEsp = value; + }); + } + + late final _$datPadreAtom = Atom( + name: '_PatientStoreBase.datPadre', + context: context, + ); + + @override + String get datPadre { + _$datPadreAtom.reportRead(); + return super.datPadre; + } + + @override + set datPadre(String value) { + _$datPadreAtom.reportWrite(value, super.datPadre, () { + super.datPadre = value; + }); + } + + late final _$datHijosAtom = Atom( + name: '_PatientStoreBase.datHijos', + context: context, + ); + + @override + String get datHijos { + _$datHijosAtom.reportRead(); + return super.datHijos; + } + + @override + set datHijos(String value) { + _$datHijosAtom.reportWrite(value, super.datHijos, () { + super.datHijos = value; + }); + } + + late final _$datHerAtom = Atom( + name: '_PatientStoreBase.datHer', + context: context, + ); + + @override + String get datHer { + _$datHerAtom.reportRead(); + return super.datHer; + } + + @override + set datHer(String value) { + _$datHerAtom.reportWrite(value, super.datHer, () { + super.datHer = value; + }); + } + + late final _$datOtrosnpAtom = Atom( + name: '_PatientStoreBase.datOtrosnp', + context: context, + ); + + @override + String get datOtrosnp { + _$datOtrosnpAtom.reportRead(); + return super.datOtrosnp; + } + + @override + set datOtrosnp(String value) { + _$datOtrosnpAtom.reportWrite(value, super.datOtrosnp, () { + super.datOtrosnp = value; + }); + } + + late final _$metDiagnRadAtom = Atom( + name: '_PatientStoreBase.metDiagnRad', + context: context, + ); + + @override + String get metDiagnRad { + _$metDiagnRadAtom.reportRead(); + return super.metDiagnRad; + } + + @override + set metDiagnRad(String value) { + _$metDiagnRadAtom.reportWrite(value, super.metDiagnRad, () { + super.metDiagnRad = value; + }); + } + + late final _$diagnDescAtom = Atom( + name: '_PatientStoreBase.diagnDesc', + context: context, + ); + + @override + String get diagnDesc { + _$diagnDescAtom.reportRead(); + return super.diagnDesc; + } + + @override + set diagnDesc(String value) { + _$diagnDescAtom.reportWrite(value, super.diagnDesc, () { + super.diagnDesc = value; + }); + } + + late final _$diagnDefAtom = Atom( + name: '_PatientStoreBase.diagnDef', + context: context, + ); + + @override + String get diagnDef { + _$diagnDefAtom.reportRead(); + return super.diagnDef; + } + + @override + set diagnDef(String value) { + _$diagnDefAtom.reportWrite(value, super.diagnDef, () { + super.diagnDef = value; + }); + } + + late final _$datLabAtom = Atom( + name: '_PatientStoreBase.datLab', + context: context, + ); + + @override + String get datLab { + _$datLabAtom.reportRead(); + return super.datLab; + } + + @override + set datLab(String value) { + _$datLabAtom.reportWrite(value, super.datLab, () { + super.datLab = value; + }); + } + + late final _$tratamientoAtom = Atom( + name: '_PatientStoreBase.tratamiento', + context: context, + ); + + @override + String get tratamiento { + _$tratamientoAtom.reportRead(); + return super.tratamiento; + } + + @override + set tratamiento(String value) { + _$tratamientoAtom.reportWrite(value, super.tratamiento, () { + super.tratamiento = value; + }); + } + + late final _$fecProxcitaAtom = Atom( + name: '_PatientStoreBase.fecProxcita', + context: context, + ); + + @override + String get fecProxcita { + _$fecProxcitaAtom.reportRead(); + return super.fecProxcita; + } + + @override + set fecProxcita(String value) { + _$fecProxcitaAtom.reportWrite(value, super.fecProxcita, () { + super.fecProxcita = value; + }); + } + + late final _$estadoEsperaAtom = Atom( + name: '_PatientStoreBase.estadoEspera', + context: context, + ); + + @override + String get estadoEspera { + _$estadoEsperaAtom.reportRead(); + return super.estadoEspera; + } + + @override + set estadoEspera(String value) { + _$estadoEsperaAtom.reportWrite(value, super.estadoEspera, () { + super.estadoEspera = value; + }); + } + + late final _$loadPatientAsyncAction = AsyncAction( + '_PatientStoreBase.loadPatient', + context: context, + ); + + @override + Future loadPatient(int patientId) { + return _$loadPatientAsyncAction.run(() => super.loadPatient(patientId)); + } + + late final _$savePatientAsyncAction = AsyncAction( + '_PatientStoreBase.savePatient', + context: context, + ); + + @override + Future savePatient() { + return _$savePatientAsyncAction.run(() => super.savePatient()); + } + + late final _$_PatientStoreBaseActionController = ActionController( + name: '_PatientStoreBase', + context: context, + ); + + @override + void initForCreate() { + final _$actionInfo = _$_PatientStoreBaseActionController.startAction( + name: '_PatientStoreBase.initForCreate', + ); + try { + return super.initForCreate(); + } finally { + _$_PatientStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void populateFromModel(PatientModel p) { + final _$actionInfo = _$_PatientStoreBaseActionController.startAction( + name: '_PatientStoreBase.populateFromModel', + ); + try { + return super.populateFromModel(p); + } finally { + _$_PatientStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void appendCurrentDateToConsultas() { + final _$actionInfo = _$_PatientStoreBaseActionController.startAction( + name: '_PatientStoreBase.appendCurrentDateToConsultas', + ); + try { + return super.appendCurrentDateToConsultas(); + } finally { + _$_PatientStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void appendCurrentDateToHistorial() { + final _$actionInfo = _$_PatientStoreBaseActionController.startAction( + name: '_PatientStoreBase.appendCurrentDateToHistorial', + ); + try { + return super.appendCurrentDateToHistorial(); + } finally { + _$_PatientStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +id: ${id}, +isLoading: ${isLoading}, +isSaving: ${isSaving}, +errorMessage: ${errorMessage}, +successMessage: ${successMessage}, +fecReg: ${fecReg}, +cedula: ${cedula}, +nombre: ${nombre}, +apellido: ${apellido}, +direccion: ${direccion}, +telefono: ${telefono}, +ocupacion: ${ocupacion}, +edad: ${edad}, +sexo: ${sexo}, +seguro: ${seguro}, +estCivil: ${estCivil}, +motCons: ${motCons}, +fecUltcon: ${fecUltcon}, +hisEnfAct: ${hisEnfAct}, +datNi: ${datNi}, +datAdo: ${datAdo}, +datAdu: ${datAdu}, +datAlerg: ${datAlerg}, +datTran: ${datTran}, +datCir: ${datCir}, +datAc: ${datAc}, +datOtrosp: ${datOtrosp}, +datMad: ${datMad}, +datEsp: ${datEsp}, +datPadre: ${datPadre}, +datHijos: ${datHijos}, +datHer: ${datHer}, +datOtrosnp: ${datOtrosnp}, +metDiagnRad: ${metDiagnRad}, +diagnDesc: ${diagnDesc}, +diagnDef: ${diagnDef}, +datLab: ${datLab}, +tratamiento: ${tratamiento}, +fecProxcita: ${fecProxcita}, +estadoEspera: ${estadoEspera}, +isEditing: ${isEditing}, +isValid: ${isValid}, +diasParaProximaCita: ${diasParaProximaCita} + '''; + } +} diff --git a/lib/features/patient/presentation/views/patient_modal_view.dart b/lib/features/patient/presentation/views/patient_modal_view.dart new file mode 100644 index 0000000..47a2edf --- /dev/null +++ b/lib/features/patient/presentation/views/patient_modal_view.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../insurance_auth/presentation/views/insurance_auth_modal_view.dart'; +import '../../../prescription/presentation/views/prescription_modal_view.dart'; +import '../../../referrals/presentation/views/department_referral_modal_view.dart'; +import '../../../referrals/presentation/views/doctor_referral_modal_view.dart'; +import '../../data/models/patient_model.dart'; +import '../store/patient_store.dart'; +import '../widgets/clinical_documents_dropdown.dart'; +import '../widgets/patient_clinical_tabs.dart'; +import '../widgets/patient_demographics_form.dart'; +import '../widgets/secretary_appointment_section.dart'; + +class PatientModalView extends StatefulWidget { + final PatientModel? initialPatient; + final int? patientId; + final ValueChanged? onSaved; + final bool isSecretary; + + const PatientModalView({ + super.key, + this.initialPatient, + this.patientId, + this.onSaved, + this.isSecretary = false, + }); + + @override + State createState() => _PatientModalViewState(); +} + +class _PatientModalViewState extends State { + late final PatientStore _store; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _store = PatientStore(); + + if (widget.initialPatient != null) { + _store.populateFromModel(widget.initialPatient!); + } else if (widget.patientId != null) { + _store.loadPatient(widget.patientId!); + } else { + _store.initForCreate(); + } + } + + Future _handleSave() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final values = _formKey.currentState!.value; + if (values['nombre'] != null) _store.nombre = values['nombre'].toString(); + if (values['apellido'] != null) _store.apellido = values['apellido'].toString(); + if (values['cedula'] != null) _store.cedula = values['cedula'].toString(); + if (values['telefono'] != null) _store.telefono = values['telefono'].toString(); + if (values['direccion'] != null) _store.direccion = values['direccion'].toString(); + if (values['ocupacion'] != null) _store.ocupacion = values['ocupacion'].toString(); + if (values['edad'] != null) _store.edad = values['edad'].toString(); + if (values['sexo'] != null) _store.sexo = values['sexo'].toString(); + if (values['seguro'] != null) _store.seguro = values['seguro'].toString(); + if (values['est_civil'] != null) _store.estCivil = values['est_civil'].toString(); + + final success = await _store.savePatient(); + if (success && mounted) { + if (widget.onSaved != null) { + widget.onSaved!(_store.toModel()); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_store.successMessage ?? 'Operación completada exitosamente'), + backgroundColor: const Color(0xFF00A65A), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.of(context).pop(_store.toModel()); + } + } else { + _store.errorMessage = 'Por favor complete los campos obligatorios resaltados en rojo.'; + } + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) { + final title = widget.isSecretary + ? (_store.isEditing ? 'Ficha de Recepción' : 'Nuevo Registro de Paciente') + : (_store.isEditing ? 'Editar Paciente' : 'Registro De Paciente'); + final subtitle = widget.isSecretary + ? 'Recepción, datos personales y programación de citas' + : (_store.isEditing + ? 'Actualizando ficha clínica del paciente ID: ${_store.id ?? ''}' + : 'Ingrese los datos generales y antecedentes del paciente'); + final mediaQuery = MediaQuery.sizeOf(context); + final modalHeight = (mediaQuery.height * 0.92).clamp(740.0, 900.0); + final modalWidth = (mediaQuery.width * 0.92).clamp(980.0, 1160.0); + + return MedicalModalDialog( + maxWidth: modalWidth, + maxHeight: modalHeight, + title: title, + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _store.isEditing ? Icons.edit_note_rounded : Icons.person_add_alt_1_rounded, + color: AppColors.primary, + size: 20, + ), + ), + subtitle: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + actions: [ + if (_store.estadoEspera == 'en_consulta') ...[ + Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF7DD3FC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.medical_services_rounded, size: 13, color: Color(0xFF0369A1)), + SizedBox(width: 5), + Text( + 'En Consulta', + style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w700, color: Color(0xFF0369A1)), + ), + ], + ), + ), + const SizedBox(width: 8), + ] else if (_store.estadoEspera == 'en_espera') ...[ + Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF86EFAC)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_top_rounded, size: 13, color: Color(0xFF15803D)), + SizedBox(width: 5), + Text( + 'En Espera', + style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w700, color: Color(0xFF15803D)), + ), + ], + ), + ), + const SizedBox(width: 8), + ] else if (_store.estadoEspera == 'atendido') ...[ + Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + alignment: Alignment.center, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_rounded, size: 13, color: Color(0xFF64748B)), + SizedBox(width: 5), + Text( + 'Atendido', + style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: Color(0xFF64748B)), + ), + ], + ), + ), + const SizedBox(width: 8), + ], + if (!widget.isSecretary) + ClinicalDocumentsDropdown( + onSelect: (docType) { + if (docType == ClinicalDocumentType.recetaMedica) { + final patientName = '${_store.nombre} ${_store.apellido}'.trim(); + showMedicalModal( + context: context, + builder: (modalCtx) => PrescriptionModalView( + patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre', + ), + ); + } else if (docType == ClinicalDocumentType.autorizacionSeguro) { + final patientName = '${_store.nombre} ${_store.apellido}'.trim(); + final diagnosis = _store.diagnDesc.trim().isNotEmpty + ? _store.diagnDesc.trim() + : (_store.diagnDef.trim().isNotEmpty ? _store.diagnDef.trim() : null); + showMedicalModal( + context: context, + builder: (modalCtx) => InsuranceAuthModalView( + patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre', + initialDiagnosis: diagnosis, + ), + ); + } else if (docType == ClinicalDocumentType.referirDoctor) { + final patientName = '${_store.nombre} ${_store.apellido}'.trim(); + showMedicalModal( + context: context, + builder: (modalCtx) => DoctorReferralModalView( + patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre', + ), + ); + } else if (docType == ClinicalDocumentType.referirDepartamento) { + final patientName = '${_store.nombre} ${_store.apellido}'.trim(); + showMedicalModal( + context: context, + builder: (modalCtx) => DepartmentReferralModalView( + patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre', + ), + ); + } + }, + ), + ], + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _store.errorMessage != null + ? AppColors.error.withValues(alpha: 0.12) + : (_store.successMessage != null + ? const Color(0xFF00A65A).withValues(alpha: 0.12) + : AppColors.primary.withValues(alpha: 0.1)), + shape: BoxShape.circle, + ), + child: Icon( + _store.errorMessage != null + ? Icons.error_outline_rounded + : (_store.successMessage != null + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded), + size: 15, + color: _store.errorMessage != null + ? AppColors.error + : (_store.successMessage != null + ? const Color(0xFF00A65A) + : AppColors.primary), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _store.errorMessage ?? + (_store.successMessage ?? + (_store.isSaving + ? 'Guardando datos en el servidor...' + : (_store.estadoEspera == 'en_consulta' + ? 'Consulta médica activa · Al guardar pasará automáticamente a Atendido' + : (_store.isEditing + ? 'Realizando cambios en paciente' + : 'Listo para registrar')))), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _store.errorMessage != null + ? AppColors.error + : (_store.successMessage != null + ? const Color(0xFF00A65A) + : AppColors.textSecondary), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ), + child: _store.isLoading + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(strokeWidth: 3), + SizedBox(height: 16), + Text('Cargando ficha del paciente...', style: TextStyle(fontSize: 13)), + ], + ), + ) + : FormBuilder( + key: _formKey, + child: Column( + children: [ + PatientDemographicsForm( + store: _store, + formKey: _formKey, + onSave: _handleSave, + ), + if (widget.isSecretary) + Expanded( + child: SingleChildScrollView( + child: SecretaryAppointmentSection(store: _store), + ), + ) + else + Expanded( + child: PatientClinicalTabs(store: _store), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/patient/presentation/widgets/clinical_documents_dropdown.dart b/lib/features/patient/presentation/widgets/clinical_documents_dropdown.dart new file mode 100644 index 0000000..f8841cb --- /dev/null +++ b/lib/features/patient/presentation/widgets/clinical_documents_dropdown.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +enum ClinicalDocumentType { + recetaMedica, + autorizacionSeguro, + referirDoctor, + referirDepartamento, +} + +class ClinicalDocumentsDropdown extends StatelessWidget { + final ValueChanged onSelect; + + const ClinicalDocumentsDropdown({ + super.key, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + tooltip: 'Documentos Clínicos e Impresión', + offset: const Offset(0, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + elevation: 6, + color: Colors.white, + onSelected: onSelect, + itemBuilder: (context) => [ + PopupMenuItem( + value: ClinicalDocumentType.recetaMedica, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.medication_rounded, + color: Color(0xFF00A65A), + size: 18, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Receta Médica', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + Text( + 'Formato media carta institucional', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ], + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: ClinicalDocumentType.autorizacionSeguro, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.verified_user_outlined, + color: Color(0xFF00A65A), + size: 18, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Autorización De Seguro', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + Text( + 'Solicitud oficial de cobertura ARS', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ], + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: ClinicalDocumentType.referirDoctor, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.person_pin_circle_outlined, + color: Color(0xFF00A65A), + size: 18, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Referir a Doctor(a)', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + Text( + 'Interconsulta médica a especialista', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ], + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: ClinicalDocumentType.referirDepartamento, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.apartment_rounded, + color: Color(0xFF00A65A), + size: 18, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Referir a Departamento', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + Text( + 'Referimiento a servicio institucional', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.description_outlined, color: AppColors.primary, size: 16), + SizedBox(width: 6), + Text( + 'Documentos Clínicos', + style: TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 12.5, + ), + ), + SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down_rounded, color: AppColors.textSecondary, size: 16), + ], + ), + ), + ); + } +} diff --git a/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart b/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart new file mode 100644 index 0000000..8f9a65d --- /dev/null +++ b/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart @@ -0,0 +1,939 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../store/patient_store.dart'; + +class PatientClinicalTabs extends StatefulWidget { + final PatientStore store; + + const PatientClinicalTabs({super.key, required this.store}); + + @override + State createState() => _PatientClinicalTabsState(); +} + +class _PatientClinicalTabsState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final TextEditingController _motConsController; + late final FocusNode _motConsFocusNode; + late final TextEditingController _hisEnfActController; + late final FocusNode _hisEnfActFocusNode; + ReactionDisposer? _disposerMotCons; + ReactionDisposer? _disposerHisEnfAct; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 7, vsync: this); + _motConsController = TextEditingController(text: widget.store.motCons); + _motConsFocusNode = FocusNode(); + _hisEnfActController = TextEditingController(text: widget.store.hisEnfAct); + _hisEnfActFocusNode = FocusNode(); + + _disposerMotCons = reaction((_) => widget.store.motCons, (String val) { + if (_motConsController.text != val) { + _motConsController.text = val; + } + }); + + _disposerHisEnfAct = reaction((_) => widget.store.hisEnfAct, (String val) { + if (_hisEnfActController.text != val) { + _hisEnfActController.text = val; + } + }); + } + + @override + void dispose() { + _disposerMotCons?.call(); + _disposerHisEnfAct?.call(); + _tabController.dispose(); + _motConsController.dispose(); + _motConsFocusNode.dispose(); + _hisEnfActController.dispose(); + _hisEnfActFocusNode.dispose(); + super.dispose(); + } + + void _insertDateAtCursor({ + required TextEditingController controller, + required FocusNode focusNode, + required ValueChanged onUpdateStore, + }) { + final now = DateTime.now(); + final dateStr = DateFormat('dd/MM/yyyy').format(now); + final insertionText = '$dateStr: '; + + final currentText = controller.text; + final selection = controller.selection; + + int start = selection.start; + int end = selection.end; + + // Si el cursor no es válido o no está enfocado, insertar al final + if (start < 0 || end < 0) { + start = currentText.length; + end = currentText.length; + } + + if (start > end) { + final temp = start; + start = end; + end = temp; + } + + final before = currentText.substring(0, start); + final after = currentText.substring(end); + final newText = '$before$insertionText$after'; + final newOffset = start + insertionText.length; + + controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newOffset), + ); + + onUpdateStore(newText); + focusNode.requestFocus(); + } + + InputDecoration _cleanTextAreaDecoration(String hintText) { + return InputDecoration( + hintText: hintText, + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ); + } + + InputDecoration _cleanDropdownDecoration(String label) { + return InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + floatingLabelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + hintStyle: const TextStyle( + fontSize: 13.5, + color: Color(0xFF94A3B8), + ), + ); + } + + Widget _buildClinicalTextArea({ + required String name, + String? initialValue, + TextEditingController? controller, + FocusNode? focusNode, + required ValueChanged onChanged, + required String hintText, + VoidCallback? onInsertDate, + String? dateLabel, + }) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: FormBuilderTextField( + name: name, + controller: controller, + initialValue: controller == null ? initialValue : null, + focusNode: focusNode, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), + decoration: _cleanTextAreaDecoration(hintText), + onChanged: (v) => onChanged(v ?? ''), + ), + ), + if (onInsertDate != null) ...[ + const SizedBox(width: 16), + SizedBox( + width: 200, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (dateLabel != null && dateLabel.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Icon(Icons.history_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + dateLabel, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Entrada de Consulta', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textSecondary), + ), + const SizedBox(height: 4), + const Text( + 'Inserte el sello de tiempo para documentar una nueva nota clínica.', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, height: 1.3), + ), + const SizedBox(height: 10), + ElevatedButton.icon( + onPressed: onInsertDate, + icon: const Icon(Icons.add_alarm_rounded, size: 16), + label: const Text('Fecha actual', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildDropdownItem({ + required String name, + required String label, + required String initialValue, + required List items, + required ValueChanged onChanged, + }) { + final list = items.contains(initialValue) + ? items + : (initialValue.isNotEmpty ? [initialValue, ...items] : items); + return FormBuilderDropdown( + name: name, + initialValue: initialValue.isNotEmpty ? initialValue : null, + decoration: _cleanDropdownDecoration(label), + hint: const Text( + 'Seleccionar...', + style: TextStyle(fontSize: 13.5, color: Color(0xFF94A3B8)), + ), + style: const TextStyle(fontSize: 13.5, color: AppColors.textPrimary, fontWeight: FontWeight.w500), + onChanged: (v) { + if (v != null) onChanged(v); + }, + items: list.map((item) { + return DropdownMenuItem( + value: item, + child: Text( + item, + style: const TextStyle(fontSize: 13.5), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ); + } + + Widget _buildAntecedentesTab(PatientStore s) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Patológicos + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.healing_rounded, size: 18, color: AppColors.primary), + SizedBox(width: 8), + Text( + 'Antecedentes Personales Patológicos', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _buildDropdownItem( + name: 'dat_ni', + label: 'Infancia', + initialValue: s.datNi, + items: const ['sarampión', 'varicela', 'Tos ferina', 'Hepatitis', 'Ninguna'], + onChanged: (v) => s.datNi = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_ado', + label: 'Adolescencia', + initialValue: s.datAdo, + items: const ['Asma', 'Trastornos alimenticios', 'TBC', 'VIH', 'Ninguna'], + onChanged: (v) => s.datAdo = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_adu', + label: 'Adultez', + initialValue: s.datAdu, + items: const [ + 'TBC', + 'VIH', + 'Hepatopatías', + 'Cardiopatías', + 'Diabetes', + 'HTA', + 'Dislipidemias', + 'Ninguna', + ], + onChanged: (v) => s.datAdu = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_alerg', + label: 'Alergias', + initialValue: s.datAlerg, + items: const ['Ninguna', 'Penicilina', 'Sulfas', 'AINEs'], + onChanged: (v) => s.datAlerg = v, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildDropdownItem( + name: 'dat_tran', + label: 'Transfusión sanguínea', + initialValue: s.datTran, + items: const ['1 ves', '2 veces', '3 veces', 'ninguna'], + onChanged: (v) => s.datTran = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_cir', + label: 'Intervenciones quirúrgicas', + initialValue: s.datCir, + items: const ['ninguna', 'Apendicectomía', 'Cesárea', 'Colecistectomía'], + onChanged: (v) => s.datCir = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_ac', + label: 'Accidentes', + initialValue: s.datAc, + items: const ['Si', 'ninguno'], + onChanged: (v) => s.datAc = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_otrosp', + label: 'Otros patológicos', + initialValue: s.datOtrosp, + items: const ['ninguno'], + onChanged: (v) => s.datOtrosp = v, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + + // No patológicos + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.family_restroom_rounded, size: 18, color: AppColors.primary), + SizedBox(width: 8), + Text( + 'Antecedentes Personales No Patológicos (Familiares)', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _buildDropdownItem( + name: 'dat_mad', + label: 'Madre', + initialValue: s.datMad, + items: const ['Viva y sana', 'Fallecida', 'Enferma'], + onChanged: (v) => s.datMad = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_esp', + label: 'Pareja', + initialValue: s.datEsp, + items: const ['Viva y sana', 'Fallecida', 'Enferma'], + onChanged: (v) => s.datEsp = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_padre', + label: 'Padre', + initialValue: s.datPadre, + items: const ['Vivo y sano', 'Fallecido', 'Enfermo'], + onChanged: (v) => s.datPadre = v, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildDropdownItem( + name: 'dat_hijos', + label: 'Hijos', + initialValue: s.datHijos, + items: const ['Vivos y sanos', 'Enfermos', 'Ninguno'], + onChanged: (v) => s.datHijos = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_her', + label: 'Hermanos', + initialValue: s.datHer, + items: const ['Vivos y sanos', 'Fallecidos', 'Enfermos', 'Ninguno'], + onChanged: (v) => s.datHer = v, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildDropdownItem( + name: 'dat_otrosnp', + label: 'Otros no patológicos', + initialValue: s.datOtrosnp, + items: const ['Ningunos'], + onChanged: (v) => s.datOtrosnp = v, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDiagnosticosTab(PatientStore s) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.biotech_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 6), + Text( + 'Métodos diagnósticos, radiológicos e invasivos', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: FormBuilderTextField( + name: 'met_diagn_rad', + initialValue: s.metDiagnRad, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 13), + decoration: _cleanTextAreaDecoration('Describa radiografías, ecografías, resonancias o procedimientos invasivos...'), + onChanged: (v) => s.metDiagnRad = v ?? '', + ), + ), + const SizedBox(height: 12), + const Row( + children: [ + Icon(Icons.star_outline_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 6), + Text( + 'Diagnósticos a destacar', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: FormBuilderTextField( + name: 'diagn_desc', + initialValue: s.diagnDesc, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 13), + decoration: _cleanTextAreaDecoration('Patologías principales, hallazgos o diagnósticos relevantes...'), + onChanged: (v) => s.diagnDesc = v ?? '', + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.verified_outlined, size: 16, color: AppColors.primary), + SizedBox(width: 6), + Text( + 'Diagnóstico definitivo', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: FormBuilderTextField( + name: 'diagn_def', + initialValue: s.diagnDef, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 13), + decoration: _cleanTextAreaDecoration('Conclusión diagnóstica definitiva y codificación clínica...'), + onChanged: (v) => s.diagnDef = v ?? '', + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildProximaCitaTab(PatientStore s) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ElevatedButton.icon( + onPressed: () async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: now.add(const Duration(days: 7)), + firstDate: now.subtract(const Duration(days: 30)), + lastDate: now.add(const Duration(days: 365 * 3)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + s.fecProxcita = DateFormat('dd/MM/yyyy').format(picked); + } + }, + icon: const Icon(Icons.calendar_month_rounded, size: 18), + label: const Text('Seleccionar Fecha de Cita', style: TextStyle(fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + elevation: 0, + ), + ), + const SizedBox(width: 14), + if (s.fecProxcita.isNotEmpty) + TextButton.icon( + onPressed: () => s.fecProxcita = '', + icon: const Icon(Icons.clear_rounded, size: 16, color: AppColors.error), + label: const Text('Quitar próxima cita', style: TextStyle(color: AppColors.error, fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.event_available_rounded, size: 22, color: AppColors.primary), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Control de Citas del Paciente', + style: TextStyle(fontSize: 12, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + Text( + s.fecProxcita.isNotEmpty + ? 'Próxima cita programada: ${s.fecProxcita}' + : 'Sin próxima cita programada', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + if (s.diasParaProximaCita != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: s.diasParaProximaCita! < 0 + ? AppColors.error.withValues(alpha: 0.1) + : (s.diasParaProximaCita! == 0 + ? AppColors.warning.withValues(alpha: 0.1) + : AppColors.success.withValues(alpha: 0.1)), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Tiempo restante para la cita: ${s.diasParaProximaCita} días', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: s.diasParaProximaCita! < 0 + ? AppColors.error + : (s.diasParaProximaCita! == 0 ? AppColors.warning : AppColors.success), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = widget.store; + + return Column( + children: [ + // Barra de Pestañas Moderna con Estilo Limpio + Container( + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border( + bottom: BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textSecondary, + labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5), + unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12.5), + indicatorColor: AppColors.primary, + indicatorWeight: 2.5, + tabAlignment: TabAlignment.start, + tabs: const [ + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notes_rounded, size: 15), + SizedBox(width: 6), + Text('Consultas'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history_edu_rounded, size: 15), + SizedBox(width: 6), + Text('Hist. enfermedad actual'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.medical_information_outlined, size: 15), + SizedBox(width: 6), + Text('Antec. patológicos'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.biotech_outlined, size: 15), + SizedBox(width: 6), + Text('Diagnósticos'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.science_outlined, size: 15), + SizedBox(width: 6), + Text('Laboratorios'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.medication_outlined, size: 15), + SizedBox(width: 6), + Text('Tratamiento'), + ], + ), + ), + Tab( + height: 38, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_outlined, size: 15), + SizedBox(width: 6), + Text('Próxima cita'), + ], + ), + ), + ], + ), + ), + Expanded( + child: Observer( + builder: (_) => TabBarView( + controller: _tabController, + children: [ + // 1. Consultas + _buildClinicalTextArea( + name: 'mot_cons', + controller: _motConsController, + focusNode: _motConsFocusNode, + hintText: 'Registro de consultas clínicas, motivo y evolución...', + dateLabel: s.fecUltcon.isNotEmpty ? 'Última consulta: ${s.fecUltcon}' : null, + onInsertDate: () { + _insertDateAtCursor( + controller: _motConsController, + focusNode: _motConsFocusNode, + onUpdateStore: (v) => s.motCons = v, + ); + }, + onChanged: (v) => s.motCons = v, + ), + + // 2. Hist. enfermedad actual + _buildClinicalTextArea( + name: 'his_enf_act', + controller: _hisEnfActController, + focusNode: _hisEnfActFocusNode, + hintText: 'Historia y cronología de la enfermedad actual...', + onInsertDate: () { + _insertDateAtCursor( + controller: _hisEnfActController, + focusNode: _hisEnfActFocusNode, + onUpdateStore: (v) => s.hisEnfAct = v, + ); + }, + onChanged: (v) => s.hisEnfAct = v, + ), + + // 3. Antec. patológicos + _buildAntecedentesTab(s), + + // 4. Diagnósticos + _buildDiagnosticosTab(s), + + // 5. Laboratorios + _buildClinicalTextArea( + name: 'dat_lab', + initialValue: s.datLab, + hintText: 'Resultados de laboratorio, hemogramas, analíticas...', + onChanged: (v) => s.datLab = v, + ), + + // 6. Tratamiento + _buildClinicalTextArea( + name: 'tratamiento', + initialValue: s.tratamiento, + hintText: 'Plan terapéutico, recetas, dosis y recomendaciones...', + onChanged: (v) => s.tratamiento = v, + ), + + // 7. Próxima cita + _buildProximaCitaTab(s), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/patient/presentation/widgets/patient_demographics_form.dart b/lib/features/patient/presentation/widgets/patient_demographics_form.dart new file mode 100644 index 0000000..1b6c5a9 --- /dev/null +++ b/lib/features/patient/presentation/widgets/patient_demographics_form.dart @@ -0,0 +1,487 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; +import 'package:toggle_switch/toggle_switch.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../store/patient_store.dart'; + +class PatientDemographicsForm extends StatelessWidget { + /// Altura estricta y compartida para todos los elementos de la ficha demográfica: + /// los 8 campos de texto, las 3 secciones de toggle (sexo, seguro, estado civil) + /// y el botón Guardar Paciente siempre tendrán exactamente la misma altura. + static const double unifiedRowHeight = 46.0; + + final PatientStore store; + final GlobalKey formKey; + final VoidCallback onSave; + + const PatientDemographicsForm({ + super.key, + required this.store, + required this.formKey, + required this.onSave, + }); + + InputDecoration _cleanDecoration({ + required String label, + IconData? icon, + bool isRequired = false, + String? hintText, + bool readOnly = false, + }) { + return InputDecoration( + labelText: isRequired ? '$label *' : label, + hintText: hintText, + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.auto, + filled: true, + fillColor: readOnly ? const Color(0xFFF1F5F9) : Colors.white, + prefixIcon: icon != null + ? Icon(icon, size: 16, color: isRequired ? AppColors.primary : AppColors.textMuted) + : null, + prefixIconConstraints: const BoxConstraints(minWidth: 34, minHeight: 34), + contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.error, width: 1.5), + ), + labelStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isRequired ? AppColors.primary : AppColors.textSecondary, + ), + hintStyle: const TextStyle(fontSize: 11.5, color: Color(0xFF94A3B8)), + ); + } + + Widget _buildCompactSelector({ + required BuildContext context, + required String title, + required IconData titleIcon, + required Widget child, + bool isRequired = false, + }) { + return Container( + height: unifiedRowHeight, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(titleIcon, size: 14, color: isRequired ? AppColors.primary : AppColors.textMuted), + const SizedBox(width: 4), + Text( + isRequired ? '$title *' : title, + style: TextStyle( + fontSize: 11.0, + fontWeight: FontWeight.w700, + color: isRequired ? AppColors.primary : AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(width: 4), + Flexible( + child: Align( + alignment: Alignment.centerRight, + child: child, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = store; + + final cedulaFormatter = MaskTextInputFormatter( + mask: '###-#######-#', + filter: {'#': RegExp(r'[0-9]')}, + ); + + final telefonoFormatter = MaskTextInputFormatter( + mask: '###-###-####', + filter: {'#': RegExp(r'[0-9]')}, + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: Color(0xFFE2E8F0), width: 1.2), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sub-bloque 1 (Columna 1): Fecha de reg, Nombre, Dirección, Ocupación + Expanded( + flex: 7, + child: Column( + children: [ + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'fec_reg', + initialValue: s.fecReg, + readOnly: true, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + decoration: _cleanDecoration( + label: 'Fecha de Registro', + icon: Icons.calendar_today_rounded, + readOnly: true, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'nombre', + initialValue: s.nombre, + textCapitalization: TextCapitalization.words, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + decoration: _cleanDecoration( + label: 'Nombre', + icon: Icons.person_outline_rounded, + isRequired: true, + hintText: 'Ej. Juan Alberto', + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'Requerido'), + FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'), + ]), + onChanged: (v) => s.nombre = v ?? '', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'direccion', + initialValue: s.direccion, + style: const TextStyle(fontSize: 12), + decoration: _cleanDecoration( + label: 'Dirección', + icon: Icons.location_on_outlined, + hintText: 'Calle, número o sector', + ), + onChanged: (v) => s.direccion = v ?? '', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'ocupacion', + initialValue: s.ocupacion, + style: const TextStyle(fontSize: 12), + decoration: _cleanDecoration( + label: 'Ocupación', + icon: Icons.work_outline_rounded, + hintText: 'Profesión u oficio', + ), + onChanged: (v) => s.ocupacion = v ?? '', + ), + ), + ], + ), + ), + const SizedBox(width: 12), + + // Sub-bloque 2 (Columna 2): Cédula o Pasaporte, Apellido, Teléfono, Edad + Expanded( + flex: 7, + child: Column( + children: [ + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'cedula', + initialValue: s.cedula, + inputFormatters: [cedulaFormatter], + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + decoration: _cleanDecoration( + label: 'Cédula o Pasaporte', + icon: Icons.badge_outlined, + hintText: '001-0000000-0', + ), + onChanged: (v) => s.cedula = v ?? '', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'apellido', + initialValue: s.apellido, + textCapitalization: TextCapitalization.words, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + decoration: _cleanDecoration( + label: 'Apellido', + icon: Icons.badge_rounded, + isRequired: true, + hintText: 'Ej. Pérez Gómez', + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: 'Requerido'), + FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'), + ]), + onChanged: (v) => s.apellido = v ?? '', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'telefono', + initialValue: s.telefono, + inputFormatters: [telefonoFormatter], + keyboardType: TextInputType.phone, + style: const TextStyle(fontSize: 12), + decoration: _cleanDecoration( + label: 'Teléfono', + icon: Icons.phone_outlined, + hintText: '809-555-0101', + ), + onChanged: (v) => s.telefono = v ?? '', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + child: FormBuilderTextField( + name: 'edad', + initialValue: s.edad, + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 12), + decoration: _cleanDecoration( + label: 'Edad', + icon: Icons.cake_outlined, + hintText: 'Años', + ), + onChanged: (v) => s.edad = v ?? '', + ), + ), + ], + ), + ), + const SizedBox(width: 12), + + // Sub-bloque 3 (Columna 3): Sexo, Asegurado, Estado Civil, Botón Guardar + Expanded( + flex: 10, + child: Column( + children: [ + _buildCompactSelector( + context: context, + title: 'Sexo', + titleIcon: Icons.wc_rounded, + isRequired: true, + child: FormBuilderField( + name: 'sexo', + initialValue: s.sexo, + validator: FormBuilderValidators.required(errorText: 'Requerido'), + builder: (FormFieldState field) { + final currentVal = field.value ?? s.sexo; + final initialIndex = currentVal.toLowerCase().contains('fem') ? 1 : 0; + return ToggleSwitch( + key: ValueKey('sexo_$currentVal'), + customWidths: const [108.0, 108.0], + minHeight: 28.0, + cornerRadius: 6.0, + radiusStyle: true, + fontSize: 10.5, + iconSize: 13.0, + centerText: true, + initialLabelIndex: initialIndex, + totalSwitches: 2, + labels: const ['Masculino', 'Femenino'], + icons: const [Icons.male_rounded, Icons.female_rounded], + activeBgColor: const [AppColors.primary], + activeFgColor: Colors.white, + inactiveBgColor: const Color(0xFFE2E8F0), + inactiveFgColor: AppColors.textSecondary, + animate: true, + curve: Curves.easeInOut, + onToggle: (index) { + final val = (index == 1) ? 'Femenino' : 'Masculino'; + field.didChange(val); + s.sexo = val; + }, + ); + }, + ), + ), + const SizedBox(height: 8), + _buildCompactSelector( + context: context, + title: 'Seguro', + titleIcon: Icons.health_and_safety_outlined, + isRequired: true, + child: FormBuilderField( + name: 'seguro', + initialValue: s.seguro, + validator: FormBuilderValidators.required(errorText: 'Requerido'), + builder: (FormFieldState field) { + final currentVal = field.value ?? s.seguro; + final cleanVal = currentVal.trim().toLowerCase(); + final isNo = cleanVal == 'no' || cleanVal.contains('sin seguro') || cleanVal.contains('no asegurado'); + final initialIndex = isNo ? 1 : 0; + return ToggleSwitch( + key: ValueKey('seguro_$currentVal'), + customWidths: const [116.0, 116.0], + minHeight: 28.0, + cornerRadius: 6.0, + radiusStyle: true, + fontSize: 10.5, + iconSize: 12.0, + centerText: true, + initialLabelIndex: initialIndex, + totalSwitches: 2, + labels: const ['Con Seguro', 'Sin Seguro'], + icons: const [Icons.check_rounded, Icons.close_rounded], + activeBgColor: const [AppColors.primary], + activeFgColor: Colors.white, + inactiveBgColor: const Color(0xFFE2E8F0), + inactiveFgColor: AppColors.textSecondary, + animate: true, + curve: Curves.easeInOut, + onToggle: (index) { + final val = (index == 1) ? 'Sin Seguro' : 'Con Seguro'; + field.didChange(val); + s.seguro = val; + }, + ); + }, + ), + ), + const SizedBox(height: 8), + _buildCompactSelector( + context: context, + title: 'Estado Civil', + titleIcon: Icons.favorite_border_rounded, + child: FormBuilderField( + name: 'est_civil', + initialValue: s.estCivil, + builder: (FormFieldState field) { + const options = ['Casado', 'Soltero', 'Otros']; + final currentVal = field.value ?? s.estCivil; + int initialIndex = 1; + if (currentVal.toLowerCase().contains('casad')) { + initialIndex = 0; + } else if (currentVal.toLowerCase().contains('otro')) { + initialIndex = 2; + } + return ToggleSwitch( + key: ValueKey('est_civil_$currentVal'), + customWidths: const [86.0, 90.0, 76.0], + minHeight: 28.0, + cornerRadius: 6.0, + radiusStyle: true, + fontSize: 10.5, + iconSize: 11.5, + centerText: true, + initialLabelIndex: initialIndex, + totalSwitches: 3, + labels: options, + icons: const [Icons.people_alt_outlined, Icons.person_outline_rounded, Icons.more_horiz_rounded], + activeBgColor: const [AppColors.primary], + activeFgColor: Colors.white, + inactiveBgColor: const Color(0xFFE2E8F0), + inactiveFgColor: AppColors.textSecondary, + animate: true, + curve: Curves.easeInOut, + onToggle: (index) { + final selected = options[index ?? 1]; + field.didChange(selected); + s.estCivil = selected; + }, + ); + }, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: unifiedRowHeight, + width: double.infinity, + child: Observer( + builder: (_) => ElevatedButton( + onPressed: s.isSaving ? null : onSave, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + elevation: 1, + shadowColor: const Color(0xFF00A65A).withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: s.isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline_rounded, size: 16), + const SizedBox(width: 6), + Text( + s.estadoEspera == 'en_consulta' + ? 'Finalizar Consulta' + : (s.isEditing ? 'Guardar Cambios' : 'Guardar Paciente'), + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + diff --git a/lib/features/patient/presentation/widgets/secretary_appointment_section.dart b/lib/features/patient/presentation/widgets/secretary_appointment_section.dart new file mode 100644 index 0000000..00ca9ec --- /dev/null +++ b/lib/features/patient/presentation/widgets/secretary_appointment_section.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../store/patient_store.dart'; + +class SecretaryAppointmentSection extends StatelessWidget { + final PatientStore store; + + const SecretaryAppointmentSection({ + super.key, + required this.store, + }); + + void _setDate(DateTime date) { + store.fecProxcita = DateFormat('dd/MM/yyyy').format(date); + } + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + + return Observer( + builder: (_) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Título de la sección + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.event_available_rounded, + color: Color(0xFF00A65A), + size: 20, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Gestión de Citas y Turno del Paciente', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + 'Coordine la próxima visita o turno del paciente en el consultorio', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + + // 2. Estado actual de la cita + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Icon( + store.fecProxcita.isNotEmpty + ? Icons.check_circle_rounded + : Icons.info_outline_rounded, + color: store.fecProxcita.isNotEmpty + ? const Color(0xFF00A65A) + : AppColors.textMuted, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + store.fecProxcita.isNotEmpty + ? 'Próxima Cita Programada: ${store.fecProxcita}' + : 'Sin fecha de cita programada', + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: store.fecProxcita.isNotEmpty + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + if (store.diasParaProximaCita != null) ...[ + const SizedBox(height: 2), + Text( + store.diasParaProximaCita! < 0 + ? 'La cita programada venció hace ${store.diasParaProximaCita!.abs()} días.' + : (store.diasParaProximaCita! == 0 + ? '¡Cita pautada para el día de HOY!' + : 'Faltan ${store.diasParaProximaCita} días para la cita.'), + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: store.diasParaProximaCita! < 0 + ? AppColors.error + : (store.diasParaProximaCita! == 0 + ? const Color(0xFFE65100) + : const Color(0xFF00A65A)), + ), + ), + ], + ], + ), + ), + if (store.fecProxcita.isNotEmpty) + TextButton.icon( + onPressed: () => store.fecProxcita = '', + icon: const Icon(Icons.clear_rounded, size: 16, color: AppColors.error), + label: const Text( + 'Quitar Cita', + style: TextStyle(color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 3. Botones para selección de fecha + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: now.add(const Duration(days: 7)), + firstDate: now.subtract(const Duration(days: 30)), + lastDate: now.add(const Duration(days: 365 * 3)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF00A65A), + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + _setDate(picked); + } + }, + icon: const Icon(Icons.calendar_month_rounded, size: 16), + label: const Text('Elegir en Calendario', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + OutlinedButton( + onPressed: () => _setDate(now), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + child: const Text('Para Hoy', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)), + ), + OutlinedButton( + onPressed: () => _setDate(now.add(const Duration(days: 1))), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + child: const Text('Mañana', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)), + ), + OutlinedButton( + onPressed: () => _setDate(now.add(const Duration(days: 7))), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + child: const Text('En 7 días', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)), + ), + OutlinedButton( + onPressed: () => _setDate(now.add(const Duration(days: 15))), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + child: const Text('En 15 días', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)), + ), + OutlinedButton( + onPressed: () => _setDate(now.add(const Duration(days: 30))), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + child: const Text('En 1 mes', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)), + ), + ], + ), + const SizedBox(height: 18), + + // 4. Nota de Confidencialidad + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFBFDBFE)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.shield_outlined, color: Color(0xFF2563EB), size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Privacidad de Datos: Los diagnósticos, notas de consulta y prescripciones médicas son de gestión exclusiva del médico tratante y se mantienen protegidos.', + style: TextStyle( + fontSize: 11.5, + color: Color(0xFF1E40AF), + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/patient_search/data/models/patient_search_item_model.dart b/lib/features/patient_search/data/models/patient_search_item_model.dart new file mode 100644 index 0000000..f6a942d --- /dev/null +++ b/lib/features/patient_search/data/models/patient_search_item_model.dart @@ -0,0 +1,64 @@ +class PatientSearchItemModel { + final int id; + final String nombre; + final String apellido; + final String cedula; + final String telefono; + final String edad; + final String sexo; + final String fecUltcon; + final String seguro; + final String motCons; + + const PatientSearchItemModel({ + required this.id, + required this.nombre, + required this.apellido, + required this.cedula, + required this.telefono, + required this.edad, + required this.sexo, + required this.fecUltcon, + required this.seguro, + required this.motCons, + }); + + String get fullName => '$nombre $apellido'.trim(); + + factory PatientSearchItemModel.fromJson(Map json) { + int parsedId = 0; + if (json['id'] is int) { + parsedId = json['id'] as int; + } else if (json['id'] != null) { + parsedId = int.tryParse(json['id'].toString()) ?? 0; + } + + return PatientSearchItemModel( + id: parsedId, + nombre: json['nombre']?.toString().trim() ?? '', + apellido: json['apellido']?.toString().trim() ?? '', + cedula: json['cedula']?.toString().trim() ?? '', + telefono: json['telefono']?.toString().trim() ?? '', + edad: json['edad']?.toString().trim() ?? '', + sexo: json['sexo']?.toString().trim() ?? '', + fecUltcon: json['fec_ultcon']?.toString().trim() ?? '', + seguro: json['seguro']?.toString().trim() ?? '', + motCons: json['mot_cons']?.toString().trim() ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'nombre': nombre, + 'apellido': apellido, + 'cedula': cedula, + 'telefono': telefono, + 'edad': edad, + 'sexo': sexo, + 'fec_ultcon': fecUltcon, + 'seguro': seguro, + 'mot_cons': motCons, + }; + } +} diff --git a/lib/features/patient_search/data/services/patient_search_service.dart b/lib/features/patient_search/data/services/patient_search_service.dart new file mode 100644 index 0000000..472fc6e --- /dev/null +++ b/lib/features/patient_search/data/services/patient_search_service.dart @@ -0,0 +1,52 @@ +import '../../../../core/network/api_client.dart'; +import '../models/patient_search_item_model.dart'; + +class PatientSearchService { + final ApiClient apiClient; + + PatientSearchService({ApiClient? client}) : apiClient = client ?? ApiClient(); + + Future> searchPatients(String rawQuery, {int limit = 25}) async { + final cleanQuery = rawQuery.trim(); + if (cleanQuery.isEmpty) return []; + + try { + final words = cleanQuery.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + final queryParams = [ + 'limit=$limit', + 'sort=-id', + 'fields=id,nombre,apellido,cedula,telefono,edad,sexo,fec_ultcon,seguro,mot_cons', + ]; + + if (words.length == 1) { + final w = Uri.encodeQueryComponent(words.first); + queryParams.add('filter[_or][0][nombre][_icontains]=$w'); + queryParams.add('filter[_or][1][apellido][_icontains]=$w'); + queryParams.add('filter[_or][2][cedula][_icontains]=$w'); + queryParams.add('filter[_or][3][telefono][_icontains]=$w'); + if (int.tryParse(words.first) != null) { + queryParams.add('filter[_or][4][id][_eq]=${words.first}'); + } + } else { + for (var i = 0; i < words.length; i++) { + final w = Uri.encodeQueryComponent(words[i]); + queryParams.add('filter[_and][$i][_or][0][nombre][_icontains]=$w'); + queryParams.add('filter[_and][$i][_or][1][apellido][_icontains]=$w'); + } + } + + final url = '/items/pacientes?${queryParams.join('&')}'; + final response = await apiClient.dio.get(url); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data['data'] as List? ?? []; + return data + .map((item) => PatientSearchItemModel.fromJson(item as Map)) + .toList(); + } + return []; + } catch (e) { + throw Exception('Error al consultar pacientes en Directus: $e'); + } + } +} diff --git a/lib/features/patient_search/presentation/store/patient_search_store.dart b/lib/features/patient_search/presentation/store/patient_search_store.dart new file mode 100644 index 0000000..708e793 --- /dev/null +++ b/lib/features/patient_search/presentation/store/patient_search_store.dart @@ -0,0 +1,71 @@ +import 'package:mobx/mobx.dart'; +import '../../data/models/patient_search_item_model.dart'; +import '../../data/services/patient_search_service.dart'; + +part 'patient_search_store.g.dart'; + +// ignore: library_private_types_in_public_api +class PatientSearchStore = _PatientSearchStoreBase with _$PatientSearchStore; + +abstract class _PatientSearchStoreBase with Store { + final PatientSearchService service; + + _PatientSearchStoreBase({PatientSearchService? searchService}) + : service = searchService ?? PatientSearchService(); + + @observable + String query = ''; + + @observable + bool isLoading = false; + + @observable + bool hasSearched = false; + + @observable + String? errorMessage; + + @observable + ObservableList results = ObservableList(); + + @computed + int get resultsCount => results.length; + + @action + void setQuery(String value) { + query = value; + } + + @action + Future search(String rawQuery) async { + final clean = rawQuery.trim(); + if (clean.isEmpty) return; + + query = clean; + isLoading = true; + errorMessage = null; + + try { + final items = await service.searchPatients(clean); + runInAction(() { + results = ObservableList.of(items); + hasSearched = true; + isLoading = false; + }); + } catch (e) { + runInAction(() { + errorMessage = e.toString().replaceAll('Exception: ', ''); + hasSearched = true; + isLoading = false; + }); + } + } + + @action + void clear() { + query = ''; + hasSearched = false; + errorMessage = null; + results.clear(); + } +} diff --git a/lib/features/patient_search/presentation/store/patient_search_store.g.dart b/lib/features/patient_search/presentation/store/patient_search_store.g.dart new file mode 100644 index 0000000..0f610ec --- /dev/null +++ b/lib/features/patient_search/presentation/store/patient_search_store.g.dart @@ -0,0 +1,160 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'patient_search_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$PatientSearchStore on _PatientSearchStoreBase, Store { + Computed? _$resultsCountComputed; + + @override + int get resultsCount => (_$resultsCountComputed ??= Computed( + () => super.resultsCount, + name: '_PatientSearchStoreBase.resultsCount', + )).value; + + late final _$queryAtom = Atom( + name: '_PatientSearchStoreBase.query', + context: context, + ); + + @override + String get query { + _$queryAtom.reportRead(); + return super.query; + } + + @override + set query(String value) { + _$queryAtom.reportWrite(value, super.query, () { + super.query = value; + }); + } + + late final _$isLoadingAtom = Atom( + name: '_PatientSearchStoreBase.isLoading', + context: context, + ); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$hasSearchedAtom = Atom( + name: '_PatientSearchStoreBase.hasSearched', + context: context, + ); + + @override + bool get hasSearched { + _$hasSearchedAtom.reportRead(); + return super.hasSearched; + } + + @override + set hasSearched(bool value) { + _$hasSearchedAtom.reportWrite(value, super.hasSearched, () { + super.hasSearched = value; + }); + } + + late final _$errorMessageAtom = Atom( + name: '_PatientSearchStoreBase.errorMessage', + context: context, + ); + + @override + String? get errorMessage { + _$errorMessageAtom.reportRead(); + return super.errorMessage; + } + + @override + set errorMessage(String? value) { + _$errorMessageAtom.reportWrite(value, super.errorMessage, () { + super.errorMessage = value; + }); + } + + late final _$resultsAtom = Atom( + name: '_PatientSearchStoreBase.results', + context: context, + ); + + @override + ObservableList get results { + _$resultsAtom.reportRead(); + return super.results; + } + + @override + set results(ObservableList value) { + _$resultsAtom.reportWrite(value, super.results, () { + super.results = value; + }); + } + + late final _$searchAsyncAction = AsyncAction( + '_PatientSearchStoreBase.search', + context: context, + ); + + @override + Future search(String rawQuery) { + return _$searchAsyncAction.run(() => super.search(rawQuery)); + } + + late final _$_PatientSearchStoreBaseActionController = ActionController( + name: '_PatientSearchStoreBase', + context: context, + ); + + @override + void setQuery(String value) { + final _$actionInfo = _$_PatientSearchStoreBaseActionController.startAction( + name: '_PatientSearchStoreBase.setQuery', + ); + try { + return super.setQuery(value); + } finally { + _$_PatientSearchStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void clear() { + final _$actionInfo = _$_PatientSearchStoreBaseActionController.startAction( + name: '_PatientSearchStoreBase.clear', + ); + try { + return super.clear(); + } finally { + _$_PatientSearchStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +query: ${query}, +isLoading: ${isLoading}, +hasSearched: ${hasSearched}, +errorMessage: ${errorMessage}, +results: ${results}, +resultsCount: ${resultsCount} + '''; + } +} diff --git a/lib/features/patient_search/presentation/views/patient_search_modal_view.dart b/lib/features/patient_search/presentation/views/patient_search_modal_view.dart new file mode 100644 index 0000000..9ce3485 --- /dev/null +++ b/lib/features/patient_search/presentation/views/patient_search_modal_view.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../patient/presentation/views/patient_modal_view.dart'; +import '../../data/models/patient_search_item_model.dart'; +import '../store/patient_search_store.dart'; +import '../widgets/search_empty_state.dart'; +import '../widgets/search_input_bar.dart'; +import '../widgets/search_results_list.dart'; + +class PatientSearchModalView extends StatefulWidget { + final ValueChanged? onPatientSelected; + final bool isSecretary; + + const PatientSearchModalView({ + super.key, + this.onPatientSelected, + this.isSecretary = false, + }); + + @override + State createState() => _PatientSearchModalViewState(); +} + +class _PatientSearchModalViewState extends State { + late final PatientSearchStore _store; + + @override + void initState() { + super.initState(); + _store = PatientSearchStore(); + } + + void _onSelectPatient(PatientSearchItemModel patient) { + if (widget.onPatientSelected != null) { + widget.onPatientSelected!(patient); + } else { + Navigator.of(context).pop(patient); + showMedicalModal( + context: context, + builder: (_) => PatientModalView( + patientId: patient.id, + isSecretary: widget.isSecretary, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return MedicalModalDialog( + title: 'Búsqueda de Pacientes', + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.person_search_rounded, + color: AppColors.primary, + size: 20, + ), + ), + subtitle: Observer( + builder: (_) { + if (_store.isLoading) { + return const Text( + 'Consultando registros...', + style: TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ); + } + if (_store.hasSearched) { + return Text( + '${_store.resultsCount} ${_store.resultsCount == 1 ? 'coincidencia encontrada' : 'coincidencias encontradas'}', + style: const TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ); + } + return const Text( + 'Localice pacientes por nombre, apellido, cédula o teléfono', + style: TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ); + }, + ), + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: AppColors.cardBg, + border: Border( + top: BorderSide(color: AppColors.border), + ), + ), + child: const Row( + children: [ + Icon(Icons.info_outline_rounded, size: 14, color: AppColors.textMuted), + SizedBox(width: 6), + Text( + 'Presione Enter para buscar · Esc para cerrar', + style: TextStyle( + fontSize: 11.5, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Observer( + builder: (_) => SearchInputBar( + isLoading: _store.isLoading, + onSearch: (q) => _store.search(q), + onClear: () => _store.clear(), + ), + ), + ), + Expanded( + child: Observer( + builder: (_) { + if (_store.isLoading) { + return const SearchEmptyState(type: SearchStateViewType.loading); + } + if (_store.errorMessage != null) { + return SearchEmptyState( + type: SearchStateViewType.error, + errorMessage: _store.errorMessage, + onRetry: () => _store.search(_store.query), + ); + } + if (!_store.hasSearched) { + return const SearchEmptyState(type: SearchStateViewType.initial); + } + if (_store.results.isEmpty) { + return SearchEmptyState( + type: SearchStateViewType.empty, + query: _store.query, + ); + } + return SearchResultsList( + patients: _store.results, + onPatientSelected: _onSelectPatient, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/patient_search/presentation/widgets/search_empty_state.dart b/lib/features/patient_search/presentation/widgets/search_empty_state.dart new file mode 100644 index 0000000..4deec27 --- /dev/null +++ b/lib/features/patient_search/presentation/widgets/search_empty_state.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +enum SearchStateViewType { + initial, + loading, + empty, + error, +} + +class SearchEmptyState extends StatelessWidget { + final SearchStateViewType type; + final String? query; + final String? errorMessage; + final VoidCallback? onRetry; + + const SearchEmptyState({ + super.key, + required this.type, + this.query, + this.errorMessage, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + switch (type) { + case SearchStateViewType.loading: + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 38, + height: 38, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + const SizedBox(height: 18), + Text( + 'Buscando pacientes...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 6), + const Text( + 'Consultando base de datos médica', + style: TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + + case SearchStateViewType.initial: + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.person_search_rounded, + size: 44, + color: AppColors.primary, + ), + ), + const SizedBox(height: 16), + const Text( + 'Localizador Rápido de Pacientes', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: const Text( + 'Escriba nombre, apellido, cédula o teléfono y presione Enter o el botón Buscar.', + style: TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_return_rounded, size: 14, color: AppColors.textMuted), + SizedBox(width: 6), + Text( + 'Presione Enter para iniciar la búsqueda', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ), + ); + + case SearchStateViewType.empty: + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.search_off_rounded, + size: 40, + color: AppColors.warning, + ), + ), + const SizedBox(height: 14), + const Text( + 'No se encontraron coincidencias', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Text( + query != null && query!.isNotEmpty + ? 'No hay registros que coincidan con "$query". Intente con un término más corto o verifique la ortografía.' + : 'No se encontraron resultados para los criterios ingresados.', + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + + case SearchStateViewType.error: + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.error_outline_rounded, + size: 40, + color: AppColors.error, + ), + ), + const SizedBox(height: 14), + const Text( + 'Error al consultar el servidor', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text( + errorMessage ?? 'Ocurrió un error inesperado al conectar con el servidor.', + style: const TextStyle( + fontSize: 12, + color: AppColors.error, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Reintentar búsqueda'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ], + ), + ), + ); + } + } +} diff --git a/lib/features/patient_search/presentation/widgets/search_input_bar.dart b/lib/features/patient_search/presentation/widgets/search_input_bar.dart new file mode 100644 index 0000000..42098b4 --- /dev/null +++ b/lib/features/patient_search/presentation/widgets/search_input_bar.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +class SearchInputBar extends StatefulWidget { + final ValueChanged onSearch; + final VoidCallback onClear; + final bool isLoading; + + const SearchInputBar({ + super.key, + required this.onSearch, + required this.onClear, + this.isLoading = false, + }); + + @override + State createState() => _SearchInputBarState(); +} + +class _SearchInputBarState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _submit() { + final text = _controller.text.trim(); + if (text.isNotEmpty) { + widget.onSearch(text); + } + } + + void _clear() { + _controller.clear(); + widget.onClear(); + _focusNode.requestFocus(); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + color: AppColors.primary, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() {}), + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + decoration: const InputDecoration( + hintText: 'Nombre, apellido, cédula o teléfono...', + hintStyle: TextStyle( + fontSize: 13, + color: AppColors.textMuted, + fontWeight: FontWeight.w400, + ), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.close_rounded, size: 18, color: AppColors.textMuted), + tooltip: 'Limpiar', + onPressed: _clear, + ), + const SizedBox(width: 6), + ElevatedButton( + onPressed: widget.isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: widget.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Buscar', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(width: 6), + Icon(Icons.keyboard_return_rounded, size: 15), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/patient_search/presentation/widgets/search_results_list.dart b/lib/features/patient_search/presentation/widgets/search_results_list.dart new file mode 100644 index 0000000..ae6cb92 --- /dev/null +++ b/lib/features/patient_search/presentation/widgets/search_results_list.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../data/models/patient_search_item_model.dart'; + +class SearchResultsList extends StatefulWidget { + final List patients; + final ValueChanged? onPatientSelected; + + const SearchResultsList({ + super.key, + required this.patients, + this.onPatientSelected, + }); + + @override + State createState() => _SearchResultsListState(); +} + +class _SearchResultsListState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: widget.patients.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final patient = widget.patients[index]; + return _PatientSearchCard( + patient: patient, + onTap: () { + if (widget.onPatientSelected != null) { + widget.onPatientSelected!(patient); + } + }, + ); + }, + ), + ); + } +} + +class _PatientSearchCard extends StatefulWidget { + final PatientSearchItemModel patient; + final VoidCallback onTap; + + const _PatientSearchCard({ + required this.patient, + required this.onTap, + }); + + @override + State<_PatientSearchCard> createState() => _PatientSearchCardState(); +} + +class _PatientSearchCardState extends State<_PatientSearchCard> { + bool _isHovered = false; + + String _getInitials(String name, String lastName) { + final first = name.isNotEmpty ? name[0].toUpperCase() : ''; + final second = lastName.isNotEmpty ? lastName[0].toUpperCase() : ''; + return '$first$second'; + } + + @override + Widget build(BuildContext context) { + final patient = widget.patient; + final initials = _getInitials(patient.nombre, patient.apellido); + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _isHovered ? AppColors.primary.withValues(alpha: 0.04) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isHovered ? AppColors.primary.withValues(alpha: 0.4) : AppColors.border, + width: _isHovered ? 1.2 : 1.0, + ), + boxShadow: _isHovered + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : [], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar con iniciales + CircleAvatar( + radius: 20, + backgroundColor: _isHovered + ? AppColors.primary + : AppColors.primaryContainer.withValues(alpha: 0.7), + child: Text( + initials.isNotEmpty ? initials : 'P', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: _isHovered ? Colors.white : AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + + // Información Principal + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nombre del paciente + Text( + patient.fullName.isNotEmpty ? patient.fullName : 'Sin Nombre Registrado', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + + // Detalles en Wrap + Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (patient.cedula.isNotEmpty) + _InfoBadge( + icon: Icons.badge_outlined, + text: patient.cedula, + color: AppColors.textSecondary, + ), + if (patient.telefono.isNotEmpty) + _InfoBadge( + icon: Icons.phone_outlined, + text: patient.telefono, + color: AppColors.textSecondary, + ), + if (patient.edad.isNotEmpty) + _InfoBadge( + icon: Icons.cake_outlined, + text: ' años', + color: AppColors.textSecondary, + ), + if (patient.sexo.isNotEmpty) + _InfoBadge( + icon: Icons.person_outline_rounded, + text: patient.sexo == 'M' + ? 'Masculino' + : (patient.sexo == 'F' ? 'Femenino' : patient.sexo), + color: AppColors.textSecondary, + ), + if (patient.seguro.isNotEmpty) + _InfoBadge( + icon: Icons.health_and_safety_outlined, + text: patient.seguro, + color: AppColors.primary, + ), + ], + ), + + if (patient.fecUltcon.isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.history_rounded, size: 13, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + 'Última consulta: ', + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: 8), + + // Botón o flecha de acción + IconButton( + icon: const Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: AppColors.textMuted, + ), + tooltip: 'Ver paciente', + onPressed: widget.onTap, + ), + ], + ), + ), + ), + ); + } +} + +class _InfoBadge extends StatelessWidget { + final IconData icon; + final String text; + final Color color; + + const _InfoBadge({ + required this.icon, + required this.text, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color.withValues(alpha: 0.85)), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11.5, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} diff --git a/lib/features/prescription/presentation/views/prescription_modal_view.dart b/lib/features/prescription/presentation/views/prescription_modal_view.dart new file mode 100644 index 0000000..ecbbd1c --- /dev/null +++ b/lib/features/prescription/presentation/views/prescription_modal_view.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../config/presentation/store/config_store.dart'; +import '../../services/prescription_pdf_service.dart'; + +class PrescriptionModalView extends StatefulWidget { + final String patientName; + final ConfigStore? configStore; + + const PrescriptionModalView({ + super.key, + required this.patientName, + this.configStore, + }); + + @override + State createState() => _PrescriptionModalViewState(); +} + +class _PrescriptionModalViewState extends State { + late final TextEditingController _patientController; + late final TextEditingController _prescriptionController; + late final FocusNode _prescriptionFocusNode; + late final ConfigStore _configStore; + bool _isPrinting = false; + String? _statusMessage; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _patientController = TextEditingController(text: widget.patientName); + _prescriptionController = TextEditingController(); + _prescriptionFocusNode = FocusNode(); + _configStore = widget.configStore ?? ConfigStore(); + _configStore.loadConfig(); + } + + @override + void dispose() { + _patientController.dispose(); + _prescriptionController.dispose(); + _prescriptionFocusNode.dispose(); + super.dispose(); + } + + void _insertBullet() { + final text = _prescriptionController.text; + final selection = _prescriptionController.selection; + final bullet = text.isEmpty || text.endsWith('\n') ? '• ' : '\n• '; + + int start = selection.start; + int end = selection.end; + if (start < 0 || end < 0) { + start = text.length; + end = text.length; + } + + final newText = text.replaceRange(start, end, bullet); + final newOffset = start + bullet.length; + + _prescriptionController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newOffset), + ); + _prescriptionFocusNode.requestFocus(); + } + + Future _handlePrint() async { + final patient = _patientController.text.trim(); + final prescription = _prescriptionController.text.trim(); + + if (patient.isEmpty) { + setState(() { + _statusMessage = 'Debe indicar el nombre del paciente.'; + _hasError = true; + }); + return; + } + + if (prescription.isEmpty) { + setState(() { + _statusMessage = 'Debe redactar los medicamentos e indicaciones de la receta.'; + _hasError = true; + }); + return; + } + + setState(() { + _isPrinting = true; + _statusMessage = 'Generando receta médica en formato oficial...'; + _hasError = false; + }); + + try { + await PrescriptionPdfService.printPrescription( + doctorName: _configStore.nombreDoctor, + specialty: _configStore.especialidad, + subspecialty: _configStore.descripEspecialidad, + generalContact: _configStore.descripGeneral, + patientName: patient, + prescriptionText: prescription, + ); + + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Receta generada con éxito.'; + _hasError = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Error al generar la receta: $e'; + _hasError = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat('dd/MM/yyyy').format(DateTime.now()); + + return Observer( + builder: (_) { + return MedicalModalDialog( + maxWidth: 820, + maxHeight: 650, + title: 'Emisión de Receta Médica', + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.medication_rounded, + color: Color(0xFF00A65A), + size: 20, + ), + ), + subtitle: const Text( + 'Redacte la prescripción para imprimir en talonario media carta institucional', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _hasError + ? AppColors.error.withValues(alpha: 0.12) + : const Color(0xFF00A65A).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + _hasError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + size: 15, + color: _hasError ? AppColors.error : const Color(0xFF00A65A), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage ?? + (_isPrinting + ? 'Preparando documento para impresión...' + : 'Listo para emitir la receta médica'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _hasError ? AppColors.error : AppColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _isPrinting ? null : _handlePrint, + icon: _isPrinting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.print_rounded, size: 16), + label: Text( + _isPrinting ? 'Generando...' : 'Crear / Imprimir Receta', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Membrete Informativo del Médico (Vista preliminar institucional) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + _configStore.nombreDoctor.isNotEmpty + ? _configStore.nombreDoctor + : 'Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + if (_configStore.especialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.especialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + if (_configStore.descripEspecialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.descripEspecialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 4), + Text( + 'Fecha: $formattedDate', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Campo Paciente + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Rx', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF00A65A), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _patientController, + decoration: InputDecoration( + labelText: 'Paciente', + hintText: 'Nombre del paciente', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Barra de herramientas de redacción + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Medicamentos y Posología:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + TextButton.icon( + onPressed: _insertBullet, + icon: const Icon(Icons.format_list_bulleted_rounded, size: 16), + label: const Text('Insertar viñeta (•)', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Lienzo de prescripción + TextField( + controller: _prescriptionController, + focusNode: _prescriptionFocusNode, + maxLines: 12, + minLines: 8, + style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: '1. Nombre del medicamento, concentración\n - Dosis y frecuencia (ej. Tomar 1 tableta cada 8 horas por 7 días)\n - Indicaciones especiales (con comidas, en ayunas, etc.)\n\n2. Siguiente medicamento...', + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/prescription/services/prescription_pdf_service.dart b/lib/features/prescription/services/prescription_pdf_service.dart new file mode 100644 index 0000000..400b49e --- /dev/null +++ b/lib/features/prescription/services/prescription_pdf_service.dart @@ -0,0 +1,299 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class PrescriptionPdfService { + /// Genera el documento PDF de la receta médica en formato Media Carta Horizontal + /// (8.5 x 5.5 pulgadas / 612 x 396 pt). + static Future buildPrescriptionPdf({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String patientName, + required String prescriptionText, + DateTime? date, + pw.Font? fontRegular, + pw.Font? fontBold, + pw.Font? fontTitle, + String? logoSvg, + }) async { + final pdf = pw.Document(title: 'Receta Médica - $patientName'); + final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now()); + + // Formato exacto e idéntico a AlfaMedical: + // Media Carta Horizontal (8.5 x 5.5 pulgadas / 612 x 396 pt) + // Márgenes idénticos a AlfaMedical (ReportLab): + // leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18. + const pageFormat = PdfPageFormat( + 8.5 * PdfPageFormat.inch, + 5.5 * PdfPageFormat.inch, + marginLeft: 72, + marginRight: 72, + marginTop: 18, + marginBottom: 18, + ); + + // Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet + pw.Font regular = fontRegular ?? pw.Font.helvetica(); + pw.Font bold = fontBold ?? pw.Font.helveticaBold(); + pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic(); + + if (fontRegular == null || fontBold == null || fontTitle == null) { + try { + regular = await PdfGoogleFonts.robotoRegular(); + bold = await PdfGoogleFonts.robotoBold(); + titleFont = await PdfGoogleFonts.playfairDisplayItalic(); + } catch (_) { + // Fallback garantizado sin crash si está offline o en test + regular = pw.Font.helvetica(); + bold = pw.Font.helveticaBold(); + titleFont = pw.Font.timesBoldItalic(); + } + } + + String? svgContent = logoSvg; + if (svgContent == null) { + try { + svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg'); + } catch (_) { + svgContent = null; + } + } + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // 1. Cabecera con Nombre del Doctor y Badge Superior de Hidalyx Med + pw.Stack( + children: [ + // Nombre y especialidad del doctor centrado + pw.Align( + alignment: pw.Alignment.center, + child: pw.Column( + children: [ + pw.Text( + doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa', + style: pw.TextStyle( + font: titleFont, + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColor.fromHex('1E293B'), + ), + ), + pw.SizedBox(height: 3), + if (specialty.isNotEmpty) + pw.Text( + specialty.toUpperCase(), + style: pw.TextStyle( + font: bold, + fontSize: 10.5, + letterSpacing: 0.5, + color: PdfColor.fromHex('0F172A'), + ), + ), + if (subspecialty.isNotEmpty) ...[ + pw.SizedBox(height: 2), + pw.Text( + subspecialty.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 8.5, + color: PdfColor.fromHex('475569'), + ), + ), + ], + pw.SizedBox(height: 3), + pw.Text( + 'Fecha: $formattedDate', + style: pw.TextStyle( + font: regular, + fontSize: 9.5, + color: PdfColor.fromHex('334155'), + ), + ), + ], + ), + ), + + // Badge sutil de Hidalyx Med en la esquina superior derecha + pw.Positioned( + top: 0, + right: 0, + child: pw.Row( + mainAxisSize: pw.MainAxisSize.min, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + if (svgContent != null && svgContent.isNotEmpty) ...[ + pw.Opacity( + opacity: 0.6, + child: pw.SvgImage( + svg: svgContent, + width: 7.5, + height: 7.5, + ), + ), + pw.SizedBox(width: 3), + ], + pw.Text( + 'hidalyx.com', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 6), + + // Línea divisoria decorativa del membrete + pw.Container( + height: 1, + color: PdfColor.fromHex('CBD5E1'), + ), + pw.SizedBox(height: 8), + + // 2. Encabezado de la Prescripción: Símbolo Rx y Paciente + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: pw.BoxDecoration( + color: PdfColor.fromHex('E2E8F0'), + borderRadius: pw.BorderRadius.circular(4), + ), + child: pw.Text( + 'Rx', + style: pw.TextStyle( + font: bold, + fontSize: 12, + color: PdfColor.fromHex('00A65A'), + ), + ), + ), + pw.SizedBox(width: 8), + pw.Expanded( + child: pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: 'Paciente: ', + style: pw.TextStyle( + font: bold, + fontSize: 11, + color: PdfColor.fromHex('1E293B'), + ), + ), + pw.TextSpan( + text: patientName.isNotEmpty ? patientName : '___________________________', + style: pw.TextStyle( + font: regular, + fontSize: 11, + color: PdfColor.fromHex('0F172A'), + ), + ), + ], + ), + ), + ), + ], + ), + pw.SizedBox(height: 8), + + // 3. Contenido de la Receta / Prescripción Médica + pw.Expanded( + child: pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text( + prescriptionText.trim().isNotEmpty + ? prescriptionText.trim() + : 'Sin prescripciones anotadas.', + style: pw.TextStyle( + font: regular, + fontSize: 10, + lineSpacing: 3, + color: PdfColor.fromHex('1E293B'), + ), + ), + ), + ), + pw.SizedBox(height: 6), + + // 4. Pie de página con Contacto del Consultorio + if (generalContact.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.only(top: 4), + child: pw.Text( + generalContact.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 6.8, + color: PdfColor.fromHex('64748B'), + lineSpacing: 1.5, + ), + ), + ), + ], + + pw.SizedBox(height: 3), + + // Confirmación institucional + pw.Center( + child: pw.Text( + 'Documento médico emitido electrónicamente', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ), + ], + ); + }, + ), + ); + + return pdf.save(); + } + + /// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF. + static Future printPrescription({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String patientName, + required String prescriptionText, + DateTime? date, + }) async { + final pdfBytes = await buildPrescriptionPdf( + doctorName: doctorName, + specialty: specialty, + subspecialty: subspecialty, + generalContact: generalContact, + patientName: patientName, + prescriptionText: prescriptionText, + date: date, + ); + + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + name: 'Receta_${patientName.replaceAll(' ', '_')}', + ); + } +} diff --git a/lib/features/referrals/presentation/views/department_referral_modal_view.dart b/lib/features/referrals/presentation/views/department_referral_modal_view.dart new file mode 100644 index 0000000..34f1bc2 --- /dev/null +++ b/lib/features/referrals/presentation/views/department_referral_modal_view.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../config/presentation/store/config_store.dart'; +import '../../services/department_referral_pdf_service.dart'; + +class DepartmentReferralModalView extends StatefulWidget { + final String patientName; + final ConfigStore? configStore; + + const DepartmentReferralModalView({ + super.key, + required this.patientName, + this.configStore, + }); + + @override + State createState() => _DepartmentReferralModalViewState(); +} + +class _DepartmentReferralModalViewState extends State { + late final TextEditingController _referralController; + late final ConfigStore _configStore; + + bool _isPrinting = false; + String? _statusMessage; + bool _hasError = false; + + @override + void initState() { + super.initState(); + // Plantilla exacta por defecto de AlfaMedical (index.py líneas 1530-1553) + final initialContent = [ + 'Paciente: ${widget.patientName}', + '', + 'Referido al departamento de anestesiologia', + 'Plan: Realizar evaluación pre-anestesica', + '', + 'Para realizar: ', + '', + '', + 'Suite 408, 4ta planta. ', + 'Gracias.', + ].join('\n'); + + _referralController = TextEditingController(text: initialContent); + _configStore = widget.configStore ?? ConfigStore(); + _configStore.loadConfig(); + } + + @override + void dispose() { + _referralController.dispose(); + super.dispose(); + } + + Future _handlePrint() async { + final text = _referralController.text.trim(); + + if (text.isEmpty) { + setState(() { + _statusMessage = 'Debe redactar el contenido del referimiento.'; + _hasError = true; + }); + return; + } + + setState(() { + _isPrinting = true; + _statusMessage = 'Generando documento de referimiento a departamento...'; + _hasError = false; + }); + + try { + await DepartmentReferralPdfService.printDepartmentReferral( + doctorName: _configStore.nombreDoctor, + specialty: _configStore.especialidad, + subspecialty: _configStore.descripEspecialidad, + generalContact: _configStore.descripGeneral, + referralText: text, + ); + + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Referimiento generado con éxito.'; + _hasError = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Error al generar el referimiento: $e'; + _hasError = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat('dd/MM/yyyy').format(DateTime.now()); + + return Observer( + builder: (_) { + return MedicalModalDialog( + maxWidth: 820, + maxHeight: 650, + title: 'Referir a Departamento', + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.apartment_rounded, + color: Color(0xFF00A65A), + size: 20, + ), + ), + subtitle: const Text( + 'Emisión oficial de referimiento médico a departamento o servicio institucional', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _hasError + ? AppColors.error.withValues(alpha: 0.12) + : const Color(0xFF00A65A).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + _hasError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + size: 15, + color: _hasError ? AppColors.error : const Color(0xFF00A65A), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage ?? + (_isPrinting + ? 'Preparando documento para impresión...' + : 'Listo para emitir el referimiento a departamento'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _hasError ? AppColors.error : AppColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _isPrinting ? null : _handlePrint, + icon: _isPrinting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.print_rounded, size: 16), + label: Text( + _isPrinting ? 'Generando...' : 'Crear / Imprimir Referimiento', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Membrete Informativo del Médico + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + _configStore.nombreDoctor.isNotEmpty + ? _configStore.nombreDoctor + : 'Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + if (_configStore.especialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.especialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + if (_configStore.descripEspecialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.descripEspecialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 4), + Text( + 'Fecha: $formattedDate', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + const Text( + 'Detalle del Referimiento a Departamento:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + + // Área de texto multilínea idéntica a ScrolledText de AlfaMedical + TextField( + controller: _referralController, + maxLines: 12, + minLines: 9, + style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Redacte el referimiento a departamento...', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/referrals/presentation/views/doctor_referral_modal_view.dart b/lib/features/referrals/presentation/views/doctor_referral_modal_view.dart new file mode 100644 index 0000000..ab70ce2 --- /dev/null +++ b/lib/features/referrals/presentation/views/doctor_referral_modal_view.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/medical_modal.dart'; +import '../../../config/presentation/store/config_store.dart'; +import '../../services/doctor_referral_pdf_service.dart'; + +class DoctorReferralModalView extends StatefulWidget { + final String patientName; + final ConfigStore? configStore; + + const DoctorReferralModalView({ + super.key, + required this.patientName, + this.configStore, + }); + + @override + State createState() => _DoctorReferralModalViewState(); +} + +class _DoctorReferralModalViewState extends State { + late final TextEditingController _referralController; + late final ConfigStore _configStore; + + bool _isPrinting = false; + String? _statusMessage; + bool _hasError = false; + + @override + void initState() { + super.initState(); + // Plantilla exacta por defecto de AlfaMedical (index.py líneas 1404-1419) + final initialContent = [ + 'Paciente: ${widget.patientName}', + '', + 'Referido a Doctor (a) Miguelina Pichardo', + 'Medicina Interna', + '', + 'Plan: Realizar evaluación cardiovascular para realizar: ', + '', + '', + 'Gracias.', + ].join('\n'); + + _referralController = TextEditingController(text: initialContent); + _configStore = widget.configStore ?? ConfigStore(); + _configStore.loadConfig(); + } + + @override + void dispose() { + _referralController.dispose(); + super.dispose(); + } + + Future _handlePrint() async { + final text = _referralController.text.trim(); + + if (text.isEmpty) { + setState(() { + _statusMessage = 'Debe redactar el contenido del referimiento.'; + _hasError = true; + }); + return; + } + + setState(() { + _isPrinting = true; + _statusMessage = 'Generando documento de referimiento a doctor(a)...'; + _hasError = false; + }); + + try { + await DoctorReferralPdfService.printDoctorReferral( + doctorName: _configStore.nombreDoctor, + specialty: _configStore.especialidad, + subspecialty: _configStore.descripEspecialidad, + generalContact: _configStore.descripGeneral, + referralText: text, + ); + + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Referimiento generado con éxito.'; + _hasError = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isPrinting = false; + _statusMessage = 'Error al generar el referimiento: $e'; + _hasError = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat('dd/MM/yyyy').format(DateTime.now()); + + return Observer( + builder: (_) { + return MedicalModalDialog( + maxWidth: 820, + maxHeight: 650, + title: 'Referir a Doctor (a)', + titleIcon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.person_pin_circle_rounded, + color: Color(0xFF00A65A), + size: 20, + ), + ), + subtitle: const Text( + 'Emisión oficial de interconsulta médica dirigida a un especialista', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + bottomBar: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _hasError + ? AppColors.error.withValues(alpha: 0.12) + : const Color(0xFF00A65A).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + _hasError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + size: 15, + color: _hasError ? AppColors.error : const Color(0xFF00A65A), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage ?? + (_isPrinting + ? 'Preparando documento para impresión...' + : 'Listo para emitir el referimiento a doctor(a)'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _hasError ? AppColors.error : AppColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'ESC para salir', + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _isPrinting ? null : _handlePrint, + icon: _isPrinting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.print_rounded, size: 16), + label: Text( + _isPrinting ? 'Generando...' : 'Crear / Imprimir Referimiento', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00A65A), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Membrete Informativo del Médico + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + _configStore.nombreDoctor.isNotEmpty + ? _configStore.nombreDoctor + : 'Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + if (_configStore.especialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.especialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + if (_configStore.descripEspecialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.descripEspecialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 4), + Text( + 'Fecha: $formattedDate', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + const Text( + 'Detalle de la Interconsulta / Referimiento:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + + // Área de texto multilínea idéntica a ScrolledText de AlfaMedical + TextField( + controller: _referralController, + maxLines: 12, + minLines: 9, + style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Redacte el referimiento médico...', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/referrals/services/department_referral_pdf_service.dart b/lib/features/referrals/services/department_referral_pdf_service.dart new file mode 100644 index 0000000..f9af8bd --- /dev/null +++ b/lib/features/referrals/services/department_referral_pdf_service.dart @@ -0,0 +1,245 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class DepartmentReferralPdfService { + /// Genera el documento PDF de Referimiento a Departamento en formato Media Carta Horizontal + /// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical. + static Future buildDepartmentReferralPdf({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String referralText, + DateTime? date, + pw.Font? fontRegular, + pw.Font? fontBold, + pw.Font? fontTitle, + String? logoSvg, + }) async { + final pdf = pw.Document(title: 'Referir a Departamento'); + final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now()); + + // Formato exacto e idéntico a AlfaMedical (ReportLab): + // pagesize = (8.5*inch, 5.5*inch) + // leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18. + const pageFormat = PdfPageFormat( + 8.5 * PdfPageFormat.inch, + 5.5 * PdfPageFormat.inch, + marginLeft: 72, + marginRight: 72, + marginTop: 18, + marginBottom: 18, + ); + + // Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet + pw.Font regular = fontRegular ?? pw.Font.helvetica(); + pw.Font bold = fontBold ?? pw.Font.helveticaBold(); + pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic(); + + if (fontRegular == null || fontBold == null || fontTitle == null) { + try { + regular = await PdfGoogleFonts.robotoRegular(); + bold = await PdfGoogleFonts.robotoBold(); + titleFont = await PdfGoogleFonts.playfairDisplayItalic(); + } catch (_) { + regular = pw.Font.helvetica(); + bold = pw.Font.helveticaBold(); + titleFont = pw.Font.timesBoldItalic(); + } + } + + String? svgContent = logoSvg; + if (svgContent == null) { + try { + svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg'); + } catch (_) { + svgContent = null; + } + } + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med + pw.Stack( + children: [ + pw.Align( + alignment: pw.Alignment.center, + child: pw.Column( + children: [ + pw.Text( + doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa', + style: pw.TextStyle( + font: titleFont, + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColor.fromHex('1E293B'), + ), + ), + pw.SizedBox(height: 3), + if (specialty.isNotEmpty) + pw.Text( + specialty.toUpperCase(), + style: pw.TextStyle( + font: bold, + fontSize: 10.5, + letterSpacing: 0.5, + color: PdfColor.fromHex('0F172A'), + ), + ), + if (subspecialty.isNotEmpty) ...[ + pw.SizedBox(height: 2), + pw.Text( + subspecialty.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 8.5, + color: PdfColor.fromHex('475569'), + ), + ), + ], + pw.SizedBox(height: 3), + pw.Text( + 'Fecha: $formattedDate', + style: pw.TextStyle( + font: regular, + fontSize: 9.5, + color: PdfColor.fromHex('334155'), + ), + ), + ], + ), + ), + + // Badge sutil de Hidalyx Med en la esquina superior derecha + pw.Positioned( + top: 0, + right: 0, + child: pw.Row( + mainAxisSize: pw.MainAxisSize.min, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + if (svgContent != null && svgContent.isNotEmpty) ...[ + pw.Opacity( + opacity: 0.6, + child: pw.SvgImage( + svg: svgContent, + width: 7.5, + height: 7.5, + ), + ), + pw.SizedBox(width: 3), + ], + pw.Text( + 'hidalyx.com', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 6), + + // Línea divisoria decorativa del membrete + pw.Container( + height: 1, + color: PdfColor.fromHex('CBD5E1'), + ), + pw.SizedBox(height: 8), + + // 2. Contenido del Referimiento (Idéntico a AlfaMedical) + pw.Expanded( + child: pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text( + referralText.trim().isNotEmpty + ? referralText.trim() + : 'Sin detalles de referimiento.', + style: pw.TextStyle( + font: regular, + fontSize: 10.5, + lineSpacing: 3.5, + color: PdfColor.fromHex('1E293B'), + ), + ), + ), + ), + pw.SizedBox(height: 6), + + // 3. Pie de página con Contacto del Consultorio + if (generalContact.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.only(top: 4), + child: pw.Text( + generalContact.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 6.8, + color: PdfColor.fromHex('64748B'), + lineSpacing: 1.5, + ), + ), + ), + ], + + pw.SizedBox(height: 3), + + // Confirmación institucional + pw.Center( + child: pw.Text( + 'Documento médico emitido electrónicamente', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ), + ], + ); + }, + ), + ); + + return pdf.save(); + } + + /// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF. + static Future printDepartmentReferral({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String referralText, + DateTime? date, + }) async { + final pdfBytes = await buildDepartmentReferralPdf( + doctorName: doctorName, + specialty: specialty, + subspecialty: subspecialty, + generalContact: generalContact, + referralText: referralText, + date: date, + ); + + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + name: 'Referimiento_Departamento', + ); + } +} diff --git a/lib/features/referrals/services/doctor_referral_pdf_service.dart b/lib/features/referrals/services/doctor_referral_pdf_service.dart new file mode 100644 index 0000000..89217f4 --- /dev/null +++ b/lib/features/referrals/services/doctor_referral_pdf_service.dart @@ -0,0 +1,245 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class DoctorReferralPdfService { + /// Genera el documento PDF de Referimiento a Doctor (a) en formato Media Carta Horizontal + /// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical. + static Future buildDoctorReferralPdf({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String referralText, + DateTime? date, + pw.Font? fontRegular, + pw.Font? fontBold, + pw.Font? fontTitle, + String? logoSvg, + }) async { + final pdf = pw.Document(title: 'Referir a Doctor (a)'); + final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now()); + + // Formato exacto e idéntico a AlfaMedical (ReportLab): + // pagesize = (8.5*inch, 5.5*inch) + // leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18. + const pageFormat = PdfPageFormat( + 8.5 * PdfPageFormat.inch, + 5.5 * PdfPageFormat.inch, + marginLeft: 72, + marginRight: 72, + marginTop: 18, + marginBottom: 18, + ); + + // Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet + pw.Font regular = fontRegular ?? pw.Font.helvetica(); + pw.Font bold = fontBold ?? pw.Font.helveticaBold(); + pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic(); + + if (fontRegular == null || fontBold == null || fontTitle == null) { + try { + regular = await PdfGoogleFonts.robotoRegular(); + bold = await PdfGoogleFonts.robotoBold(); + titleFont = await PdfGoogleFonts.playfairDisplayItalic(); + } catch (_) { + regular = pw.Font.helvetica(); + bold = pw.Font.helveticaBold(); + titleFont = pw.Font.timesBoldItalic(); + } + } + + String? svgContent = logoSvg; + if (svgContent == null) { + try { + svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg'); + } catch (_) { + svgContent = null; + } + } + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med + pw.Stack( + children: [ + pw.Align( + alignment: pw.Alignment.center, + child: pw.Column( + children: [ + pw.Text( + doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa', + style: pw.TextStyle( + font: titleFont, + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColor.fromHex('1E293B'), + ), + ), + pw.SizedBox(height: 3), + if (specialty.isNotEmpty) + pw.Text( + specialty.toUpperCase(), + style: pw.TextStyle( + font: bold, + fontSize: 10.5, + letterSpacing: 0.5, + color: PdfColor.fromHex('0F172A'), + ), + ), + if (subspecialty.isNotEmpty) ...[ + pw.SizedBox(height: 2), + pw.Text( + subspecialty.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 8.5, + color: PdfColor.fromHex('475569'), + ), + ), + ], + pw.SizedBox(height: 3), + pw.Text( + 'Fecha: $formattedDate', + style: pw.TextStyle( + font: regular, + fontSize: 9.5, + color: PdfColor.fromHex('334155'), + ), + ), + ], + ), + ), + + // Badge sutil de Hidalyx Med en la esquina superior derecha + pw.Positioned( + top: 0, + right: 0, + child: pw.Row( + mainAxisSize: pw.MainAxisSize.min, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + if (svgContent != null && svgContent.isNotEmpty) ...[ + pw.Opacity( + opacity: 0.6, + child: pw.SvgImage( + svg: svgContent, + width: 7.5, + height: 7.5, + ), + ), + pw.SizedBox(width: 3), + ], + pw.Text( + 'hidalyx.com', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 6), + + // Línea divisoria decorativa del membrete + pw.Container( + height: 1, + color: PdfColor.fromHex('CBD5E1'), + ), + pw.SizedBox(height: 8), + + // 2. Contenido del Referimiento (Idéntico a AlfaMedical con fontSize 13-14) + pw.Expanded( + child: pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text( + referralText.trim().isNotEmpty + ? referralText.trim() + : 'Sin detalles de referimiento.', + style: pw.TextStyle( + font: regular, + fontSize: 10.5, + lineSpacing: 3.5, + color: PdfColor.fromHex('1E293B'), + ), + ), + ), + ), + pw.SizedBox(height: 6), + + // 3. Pie de página con Contacto del Consultorio + if (generalContact.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.only(top: 4), + child: pw.Text( + generalContact.toUpperCase(), + textAlign: pw.TextAlign.center, + style: pw.TextStyle( + font: regular, + fontSize: 6.8, + color: PdfColor.fromHex('64748B'), + lineSpacing: 1.5, + ), + ), + ), + ], + + pw.SizedBox(height: 3), + + // Confirmación institucional + pw.Center( + child: pw.Text( + 'Documento médico emitido electrónicamente', + style: pw.TextStyle( + font: regular, + fontSize: 6.0, + color: PdfColor.fromHex('94A3B8'), + ), + ), + ), + ], + ); + }, + ), + ); + + return pdf.save(); + } + + /// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF. + static Future printDoctorReferral({ + required String doctorName, + required String specialty, + required String subspecialty, + required String generalContact, + required String referralText, + DateTime? date, + }) async { + final pdfBytes = await buildDoctorReferralPdf( + doctorName: doctorName, + specialty: specialty, + subspecialty: subspecialty, + generalContact: generalContact, + referralText: referralText, + date: date, + ); + + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + name: 'Referimiento_Doctor', + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..51a4720 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:hidalyx_drluiscastro/core/router/app_router.dart'; +import 'package:hidalyx_drluiscastro/core/theme/app_theme.dart'; + +import '../../features/auth/data/services/auth_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final authService = AuthService(); + final session = await authService.loadSavedSession(); + final initialRoute = (session != null && session.accessToken.isNotEmpty) ? '/' : '/login'; + + runApp(HidalyxApp(initialRoute: initialRoute)); +} + +class HidalyxApp extends StatelessWidget { + final String initialRoute; + + const HidalyxApp({super.key, this.initialRoute = '/login'}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Dr. Luis Castro - Hidalyx Med', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + routerConfig: AppRouter.getRouter(initialRoute), + ); + } +} + diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e526c2e --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "hidalyx_drluiscastro") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.hidalyx.hidalyx_drluiscastro") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..ce0e550 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..0c2c3c3 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + printing +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..684cc17 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "hidalyx_drluiscastro"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "hidalyx_drluiscastro"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..60a7b00 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import printing +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..29bf54d --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* hidalyx_drluiscastro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "hidalyx_drluiscastro.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* hidalyx_drluiscastro.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* hidalyx_drluiscastro.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.hidalyx.hidalyxDrluiscastro.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hidalyx_drluiscastro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hidalyx_drluiscastro"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.hidalyx.hidalyxDrluiscastro.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hidalyx_drluiscastro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hidalyx_drluiscastro"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.hidalyx.hidalyxDrluiscastro.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hidalyx_drluiscastro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hidalyx_drluiscastro"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..068e823 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..e058a2c --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = hidalyx_drluiscastro + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.hidalyx.hidalyxDrluiscastro + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.hidalyx. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..12391be --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,871 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + sha256: "1233251b4bc1d5deb245745d2a89dcebf4cdd382e1ec3f21f1c6703b700e574f" + url: "https://pub.dev" + source: hosted + version: "10.3.0+2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_mobx: + dependency: "direct main" + description: + name: flutter_mobx + sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + form_builder_validators: + dependency: "direct main" + description: + name: form_builder_validators + sha256: cd9a06b35abaed8bf050467c1239e3ee1c8795f42d4cb6568c08dbb8a4d6bd36 + url: "https://pub.dev" + source: hosted + version: "11.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + mask_text_input_formatter: + dependency: "direct main" + description: + name: mask_text_input_formatter + sha256: "978c58ec721c25621ceb468e633f4eef64b64d45424ac4540e0565d4f7c800cd" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + mobx: + dependency: "direct main" + description: + name: mobx + sha256: a14558d22e2d3b0edf9151c75b67638c3907123046e96e1d1ee05e23c6a82a56 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + mobx_codegen: + dependency: "direct dev" + description: + name: mobx_codegen + sha256: e90238a74e1e6cd7886228552006db73cd7768ce1584ffa13112332df8565853 + url: "https://pub.dev" + source: hosted + version: "2.7.8" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + url: "https://pub.dev" + source: hosted + version: "0.7.8" + toggle_switch: + dependency: "direct main" + description: + name: toggle_switch + sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..813692b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,98 @@ +name: hidalyx_drluiscastro +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + dio: ^5.11.1 + flutter_form_builder: ^10.3.0+2 + form_builder_validators: ^11.3.0 + mask_text_input_formatter: ^2.9.0 + toggle_switch: ^2.3.0 + mobx: ^2.6.1 + flutter_mobx: ^2.3.0 + go_router: ^17.5.0 + flutter_svg: ^2.3.0 + intl: ^0.20.2 + shared_preferences: ^2.5.5 + pdf: ^3.11.1 + printing: ^5.13.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + build_runner: ^2.15.1 + mobx_codegen: ^2.7.8 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + - assets/fonts/ + + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + fonts: + - family: Poppins + fonts: + - asset: assets/fonts/Poppins-Regular.ttf + weight: 400 + - asset: assets/fonts/Poppins-Medium.ttf + weight: 500 + - asset: assets/fonts/Poppins-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Poppins-Bold.ttf + weight: 700 + diff --git a/test/directus_integration_test.dart b/test/directus_integration_test.dart new file mode 100644 index 0000000..c74d994 --- /dev/null +++ b/test/directus_integration_test.dart @@ -0,0 +1,192 @@ +// ignore_for_file: avoid_print +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/network/api_client.dart'; +import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model.dart'; +import 'package:hidalyx_drluiscastro/features/patient/data/services/patient_service.dart'; +import 'package:hidalyx_drluiscastro/features/patient_search/data/services/patient_search_service.dart'; + +void main() { + test('Directus Live Integration: Authenticate, create patient LUIS MIGUEL HIDALGO SALCEDO, and verify', () async { + final apiClient = ApiClient(); + + // 1. Iniciar sesión en Directus + print('\n[DIRECTUS TEST] 1. Autenticando con credenciales...'); + final loginRes = await apiClient.dio.post( + '/auth/login', + data: { + 'email': 'castroluis01@hotmail.com', + 'password': 'LUIS', + }, + ); + expect(loginRes.statusCode, 200); + final token = loginRes.data['data']['access_token'] as String; + expect(token.isNotEmpty, isTrue); + apiClient.setAuthToken(token); + print('✅ [DIRECTUS TEST] Token obtenido correctamente: ${token.substring(0, 15)}...'); + + // 2. Verificar estado actual de la colección pacientes + print('\n[DIRECTUS TEST] 2. Consultando últimos pacientes en Directus...'); + final listRes = await apiClient.dio.get('/items/pacientes?sort=-id&limit=5'); + expect(listRes.statusCode, 200); + final currentList = listRes.data['data'] as List; + print('Últimos 5 pacientes en base de datos:'); + for (final p in currentList) { + print(' -> ID: ${p['id']} | ${p['nombre']} ${p['apellido']} | Cédula: ${p['cedula']} | Reg: ${p['fec_reg']}'); + } + + // 3. Crear el paciente LUIS MIGUEL HIDALGO SALCEDO + print('\n[DIRECTUS TEST] 3. Creando paciente LUIS MIGUEL HIDALGO SALCEDO en Directus...'); + final patientService = PatientService(client: apiClient); + + final patientToCreate = PatientModel( + nombre: 'LUIS MIGUEL', + apellido: 'HIDALGO SALCEDO', + sexo: 'Masculino', + seguro: 'Sí', + estCivil: 'Soltero', + edad: '28', + cedula: '001-1234567-8', + telefono: '809-555-0199', + direccion: 'Santo Domingo, Distrito Nacional', + ocupacion: 'Ingeniero de Software', + fecReg: '18/09/2026', + fecUltcon: '18/09/2026', + motCons: '18/09/2026: Consulta preventiva general y chequeo urológico.', + hisEnfAct: '18/09/2026: Paciente asintomático, buen estado general.', + datNi: 'Ninguna', + datAdo: 'Ninguna', + datAdu: 'Ninguna', + datAlerg: 'Ninguna', + datTran: 'ninguna', + datCir: 'ninguna', + datAc: 'ninguno', + datOtrosp: 'ninguno', + datMad: 'Viva y sana', + datEsp: 'Viva y sana', + datPadre: 'Vivo y sano', + datHijos: 'Vivos y sanos', + datHer: 'Vivos y sanos', + datOtrosnp: 'Ningunos', + metDiagnRad: '', + datLab: '', + diagnDesc: 'Evaluación urológica de rutina.', + diagnDef: 'Sin hallazgos patológicos.', + fecProxcita: '18/10/2026', + tratamiento: '', + ); + + PatientModel created; + try { + created = await patientService.createPatient(patientToCreate); + print('✅ [DIRECTUS TEST] Paciente creado exitosamente en Directus con ID: ${created.id}'); + print(' - Nombre completo: ${created.fullName}'); + print(' - Cédula: ${created.cedula}'); + print(' - Teléfono: ${created.telefono}'); + print(' - Dirección: ${created.direccion}'); + print(' - Fecha Registro: ${created.fecReg}'); + print(' - Próxima Cita: ${created.fecProxcita}'); + } catch (e) { + print('❌ [DIRECTUS TEST] Fallo al crear paciente: $e'); + rethrow; + } + + expect(created.id, isNotNull); + expect(created.id! > 0, isTrue); + + // 4. Verificar obtención directa por ID + print('\n[DIRECTUS TEST] 4. Verificando getPatientById(${created.id})...'); + final fetched = await patientService.getPatientById(created.id!); + print('✅ [DIRECTUS TEST] Paciente recuperado por ID:'); + print(' - ID: ${fetched.id} | Nombre: ${fetched.fullName} | Diagnóstico: ${fetched.diagnDef}'); + expect(fetched.nombre, 'LUIS MIGUEL'); + expect(fetched.apellido, 'HIDALGO SALCEDO'); + + // 5. Verificar búsqueda mediante PatientSearchService + print('\n[DIRECTUS TEST] 5. Verificando búsqueda con PatientSearchService...'); + final searchService = PatientSearchService(client: apiClient); + final searchResults = await searchService.searchPatients('LUIS MIGUEL HIDALGO'); + print('Resultados de búsqueda para "LUIS MIGUEL HIDALGO": ${searchResults.length}'); + for (final item in searchResults) { + print(' -> ID: ${item.id} | ${item.fullName} | Cédula: ${item.cedula}'); + } + expect(searchResults.any((p) => p.id == created.id), isTrue); + + print('\n🎉🎉🎉 [DIRECTUS TEST] TODO VERIFICADO Y CONFIRMADO EN DIRECTUS CON ÉXITO 🎉🎉🎉\n'); + }); + + test('Directus Auth Refresh: login, refresh token, and verify new access token', () async { + final apiClient = ApiClient(); + final loginRes = await apiClient.dio.post( + '/auth/login', + data: { + 'email': 'castroluis01@hotmail.com', + 'password': 'LUIS', + }, + ); + final initialAccessToken = loginRes.data['data']['access_token'] as String; + final refreshToken = loginRes.data['data']['refresh_token'] as String; + print('Initial access token: ${initialAccessToken.substring(0, 15)}...'); + print('Refresh token: ${refreshToken.substring(0, 15)}...'); + + final refreshRes = await apiClient.dio.post( + '/auth/refresh', + data: { + 'refresh_token': refreshToken, + 'mode': 'json', + }, + ); + expect(refreshRes.statusCode, 200); + final newAccessToken = refreshRes.data['data']['access_token'] as String; + final newRefreshToken = refreshRes.data['data']['refresh_token'] as String?; + print('New access token: ${newAccessToken.substring(0, 15)}...'); + print('New refresh token: ${newRefreshToken?.substring(0, 15)}...'); + expect(newAccessToken.isNotEmpty, isTrue); + + // Verify using new access token to query /users/me + apiClient.setAuthToken(newAccessToken); + final userRes = await apiClient.dio.get('/users/me'); + expect(userRes.statusCode, 200); + print('✅ User verification with refreshed token: ${userRes.data['data']['email']}'); + }); + + test('Directus Config: Authenticate, read configuration, and update', () async { + final apiClient = ApiClient(); + print('\n[DIRECTUS CONFIG TEST] 1. Autenticando...'); + final loginRes = await apiClient.dio.post( + '/auth/login', + data: { + 'email': 'castroluis01@hotmail.com', + 'password': 'LUIS', + }, + ); + expect(loginRes.statusCode, 200); + final token = loginRes.data['data']['access_token'] as String; + apiClient.setAuthToken(token); + + print('\n[DIRECTUS CONFIG TEST] 2. Consultando configuracion actual...'); + final configRes = await apiClient.dio.get('/items/configuracion'); + expect(configRes.statusCode, 200); + final configData = configRes.data['data'] as Map; + print('Configuración actual:'); + print(' -> Doctor: ${configData['nombre_doctor']}'); + print(' -> Especialidad: ${configData['especialidad']}'); + print(' -> Subespecialidad: ${configData['descrip_especialidad']}'); + print(' -> Contacto: ${configData['descrip_general']}'); + + expect(configData['nombre_doctor'], isNotNull); + expect(configData['especialidad'], isNotNull); + + print('\n[DIRECTUS CONFIG TEST] 3. Actualizando configuracion vía PATCH...'); + final patchRes = await apiClient.dio.patch( + '/items/configuracion', + data: { + 'nombre_doctor': configData['nombre_doctor'], + 'especialidad': configData['especialidad'], + 'descrip_especialidad': configData['descrip_especialidad'], + 'descrip_general': configData['descrip_general'], + }, + ); + expect(patchRes.statusCode, 200); + print('✅ [DIRECTUS CONFIG TEST] Configuración confirmada y actualizada correctamente.'); + }); +} diff --git a/test/features/appointments_modal_test.dart b/test/features/appointments_modal_test.dart new file mode 100644 index 0000000..f005aaa --- /dev/null +++ b/test/features/appointments_modal_test.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/widgets/medical_modal.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/data/models/patient_summary_model.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/data/services/dashboard_service.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/views/appointments_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/today_appointments_card.dart'; + +class MockDashboardService extends DashboardService { + final Map> appointmentsByDate; + + MockDashboardService({Map>? data}) + : appointmentsByDate = data ?? {}; + + String _formatKey(DateTime d) { + final day = d.day.toString().padLeft(2, '0'); + final month = d.month.toString().padLeft(2, '0'); + final year = d.year.toString(); + return '$day/$month/$year'; + } + + @override + Future> getAppointmentsByDate(DateTime date, {int limit = 100}) async { + final key = _formatKey(date); + return appointmentsByDate[key] ?? []; + } + + @override + Future> getTodayAppointments({int limit = 5}) async { + return getAppointmentsByDate(DateTime.now(), limit: limit); + } +} + +void main() { + group('AppointmentsModalView Tests', () { + final now = DateTime.now(); + final todayDay = now.day.toString().padLeft(2, '0'); + final todayMonth = now.month.toString().padLeft(2, '0'); + final todayKey = '$todayDay/$todayMonth/${now.year}'; + + final List mockTodayPatients = [ + const PatientSummaryModel( + id: 101, + nombre: 'CARLOS', + apellido: 'MARTINEZ', + cedula: '001-9999999-1', + telefono: '809-555-1122', + fecReg: '20/09/2026', + fecProxCita: '20/09/2026', + seguro: 'Sí', + motCons: 'Seguimiento postquirúrgico', + ), + const PatientSummaryModel( + id: 102, + nombre: 'ANA', + apellido: 'PERDOMO', + cedula: '402-8888888-2', + telefono: '809-555-3344', + fecReg: '20/09/2026', + fecProxCita: '20/09/2026', + seguro: 'No', + motCons: 'Chequeo urológico anual', + ), + ]; + + final otherDate = DateTime(2026, 10, 25); + const otherDateKey = '25/10/2026'; + final List mockOtherPatients = [ + const PatientSummaryModel( + id: 201, + nombre: 'RAMON', + apellido: 'VALDEZ', + cedula: '001-7777777-3', + telefono: '809-555-5566', + fecReg: '25/10/2026', + fecProxCita: '25/10/2026', + seguro: 'Sí', + motCons: 'Control de litiasis renal', + ), + ]; + + late MockDashboardService mockService; + + setUp(() { + mockService = MockDashboardService(data: { + todayKey: mockTodayPatients, + otherDateKey: mockOtherPatients, + }); + }); + + testWidgets('renders modal header, footer, and active filter indicator as "Hoy"', (tester) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppointmentsModalView( + service: mockService, + initialDate: now, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Check titles & subtitles + expect(find.text('Agenda de Citas Médicas'), findsOneWidget); + expect(find.text('Consulte y gestione los pacientes citados por fecha de consulta'), findsOneWidget); + + // Verify that when filtered by today, it displays "Hoy (DD/MM/YYYY)" + expect(find.textContaining('Hoy ($todayKey)'), findsOneWidget); + + // Verify patient records for today are rendered + expect(find.text('CARLOS MARTINEZ'), findsOneWidget); + expect(find.text('ANA PERDOMO'), findsOneWidget); + expect(find.text('Seguimiento postquirúrgico'), findsOneWidget); + expect(find.text('Chequeo urológico anual'), findsOneWidget); + + // Verify footer buttons & chip + expect(find.text('ESC para salir'), findsOneWidget); + expect(find.text('Cerrar'), findsOneWidget); + }); + + testWidgets('displays specific date filter when viewing date other than today', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppointmentsModalView( + service: mockService, + initialDate: otherDate, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Should show the specific date without "Hoy" in the filter badge + expect(find.textContaining('Filtro: 25/10/2026'), findsOneWidget); + expect(find.textContaining('Hoy'), findsWidgets); // The "Hoy" quick button is present, but the filter text is 25/10/2026 + + // Verify patient for that specific date + expect(find.text('RAMON VALDEZ'), findsOneWidget); + expect(find.text('Control de litiasis renal'), findsOneWidget); + expect(find.text('CARLOS MARTINEZ'), findsNothing); + }); + + testWidgets('navigation buttons change date and "Hoy" button resets to current day', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppointmentsModalView( + service: mockService, + initialDate: otherDate, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Filtro: 25/10/2026'), findsOneWidget); + + // Tap Día siguiente chevron + await tester.tap(find.byTooltip('Día siguiente')); + await tester.pumpAndSettle(); + expect(find.textContaining('Filtro: 26/10/2026'), findsOneWidget); + expect(find.text('No hay citas registradas para el 26/10/2026'), findsOneWidget); + + // Tap Día anterior chevron twice + await tester.tap(find.byTooltip('Día anterior')); + await tester.pumpAndSettle(); + expect(find.textContaining('Filtro: 25/10/2026'), findsOneWidget); + + // Tap "Hoy" button to return to today + await tester.tap(find.text('Hoy')); + await tester.pumpAndSettle(); + + // Must now show "Hoy (DD/MM/YYYY)" again + expect(find.textContaining('Filtro: Hoy ($todayKey)'), findsOneWidget); + expect(find.text('CARLOS MARTINEZ'), findsOneWidget); + }); + + testWidgets('footer container adheres to institutional padding (horizontal: 20, vertical: 12)', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppointmentsModalView( + service: mockService, + initialDate: now, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final footerFinder = find.ancestor( + of: find.text('Cerrar'), + matching: find.byType(Container), + ); + expect(footerFinder, findsWidgets); + + final footerContainer = tester.widget(footerFinder.first); + expect(footerContainer.padding, equals(const EdgeInsets.symmetric(horizontal: 20, vertical: 12))); + }); + + testWidgets('closes modal with Cerrar button and with ESC key', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () => showMedicalModal( + context: ctx, + builder: (_) => AppointmentsModalView( + service: mockService, + initialDate: now, + ), + ), + child: const Text('Abrir Citas'), + ), + ), + ), + ), + ); + + // 1. Open modal and close via Cerrar button + await tester.tap(find.text('Abrir Citas')); + await tester.pumpAndSettle(); + expect(find.text('Agenda de Citas Médicas'), findsOneWidget); + + await tester.tap(find.text('Cerrar')); + await tester.pumpAndSettle(); + expect(find.text('Agenda de Citas Médicas'), findsNothing); + + // 2. Open modal and close via ESC key + await tester.tap(find.text('Abrir Citas')); + await tester.pumpAndSettle(); + expect(find.text('Agenda de Citas Médicas'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Agenda de Citas Médicas'), findsNothing); + }); + + testWidgets('TodayAppointmentsCard item tap invokes onPatientTap callback', (tester) async { + PatientSummaryModel? tappedPatient; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TodayAppointmentsCard( + appointments: mockTodayPatients, + onPatientTap: (p) => tappedPatient = p, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('CARLOS MARTINEZ'), findsOneWidget); + + await tester.tap(find.text('CARLOS MARTINEZ')); + await tester.pumpAndSettle(); + + expect(tappedPatient, isNotNull); + expect(tappedPatient!.id, equals(101)); + expect(tappedPatient!.nombre, equals('CARLOS')); + }); + }); +} diff --git a/test/features/auth_refresh_test.dart b/test/features/auth_refresh_test.dart new file mode 100644 index 0000000..fcf0d4c --- /dev/null +++ b/test/features/auth_refresh_test.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/network/api_client.dart'; +import 'package:hidalyx_drluiscastro/features/auth/data/models/auth_session_model.dart'; +import 'package:hidalyx_drluiscastro/features/auth/data/services/auth_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockAdapter implements HttpClientAdapter { + int attempts = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + attempts++; + if (options.headers['Authorization'] == 'Bearer refreshed_token') { + return ResponseBody.fromString( + '{"status":"success"}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + return ResponseBody.fromString( + '{"errors":[{"message":"Token expired."}]}', + 401, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('AuthSessionModel copyWith', () { + test('updates accessToken and preserves other fields', () { + const session = AuthSessionModel( + accessToken: 'old_access', + refreshToken: 'old_refresh', + userId: 'usr_1', + firstName: 'Luis', + lastName: 'Castro', + email: 'castro@hidalyx.com', + ); + + final updated = session.copyWith( + accessToken: 'new_access', + refreshToken: 'new_refresh', + ); + + expect(updated.accessToken, 'new_access'); + expect(updated.refreshToken, 'new_refresh'); + expect(updated.userId, 'usr_1'); + expect(updated.fullName, 'Luis Castro'); + }); + }); + + group('AuthService Token Refresh flow', () { + test('refreshToken updates SharedPreferences and ApiClient when refresh succeeds', () async { + final dio = Dio(); + final apiClient = ApiClient.withDio(dio); + final authService = AuthService(client: apiClient); + + // Pre-guardar sesión con refresh token + final prefs = await SharedPreferences.getInstance(); + const initialSession = AuthSessionModel( + accessToken: 'expired_access_token', + refreshToken: 'valid_mock_refresh_token', + userId: 'u1', + firstName: 'Luis', + lastName: 'Castro', + email: 'castroluis01@hotmail.com', + ); + await prefs.setString('hidalyx_med_auth_session', jsonEncode(initialSession.toJson())); + + expect(authService.apiClient.onTokenRefresh, isNotNull); + }); + + test('loadSavedSession returns null when session contains empty tokens', () async { + final authService = AuthService(); + final prefs = await SharedPreferences.getInstance(); + const emptySession = AuthSessionModel( + accessToken: '', + refreshToken: null, + userId: 'u1', + firstName: 'Luis', + lastName: 'Castro', + email: 'test@example.com', + ); + await prefs.setString('hidalyx_med_auth_session', jsonEncode(emptySession.toJson())); + + final result = await authService.loadSavedSession(); + expect(result, isNull); + }); + + test('ApiClient QueuedInterceptorsWrapper intercepts 401 and retries with refreshed token', () async { + final dio = Dio(BaseOptions(baseUrl: 'https://mock.api')); + final mockAdapter = MockAdapter(); + dio.httpClientAdapter = mockAdapter; + + final client = ApiClient.withDio(dio); + bool refreshCalled = false; + + // Configurar callback de refresco + client.onTokenRefresh = () async { + refreshCalled = true; + return 'refreshed_token'; + }; + + // Realizar una petición protegida + final response = await client.dio.get('/items/pacientes'); + + expect(response.statusCode, 200); + expect(response.data['status'], 'success'); + expect(refreshCalled, isTrue); + expect(mockAdapter.attempts, 2); + expect(client.dio.options.headers['Authorization'], 'Bearer refreshed_token'); + }); + + test('ApiClient strips Authorization header on /auth/login request', () async { + final dio = Dio(BaseOptions(baseUrl: 'https://mock.api')); + RequestOptions? interceptedOptions; + dio.httpClientAdapter = _CustomAdapter((options) { + interceptedOptions = options; + return ResponseBody.fromString('{"data":{"access_token":"token_abc"}}', 200); + }); + + final client = ApiClient.withDio(dio); + client.setAuthToken('stale_expired_token'); + + // POST to /auth/login + await client.dio.post('/auth/login', data: {'email': 'test@test.com'}); + + expect(interceptedOptions, isNotNull); + expect(interceptedOptions!.headers.containsKey('Authorization'), isFalse); + }); + + test('ApiClient does NOT trigger onTokenRefresh when /auth/logout fails with 401', () async { + final dio = Dio(BaseOptions(baseUrl: 'https://mock.api')); + dio.httpClientAdapter = _CustomAdapter((options) { + return ResponseBody.fromString('{"errors":[{"message":"Token expired."}]}', 401); + }); + + final client = ApiClient.withDio(dio); + bool refreshCalled = false; + client.onTokenRefresh = () async { + refreshCalled = true; + return 'should_not_be_called'; + }; + + try { + await client.dio.post('/auth/logout', data: {'refresh_token': 'expired_rf'}); + } catch (_) {} + + expect(refreshCalled, isFalse); + }); + }); +} + +class _CustomAdapter implements HttpClientAdapter { + final ResponseBody Function(RequestOptions options) handler; + _CustomAdapter(this.handler); + + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) async { + return handler(options); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/features/config_test.dart b/test/features/config_test.dart new file mode 100644 index 0000000..dc96007 --- /dev/null +++ b/test/features/config_test.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/widgets/medical_modal.dart'; +import 'package:hidalyx_drluiscastro/features/config/data/services/config_service.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/store/config_store.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/views/config_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/data/models/doctor_info_model.dart'; + +class MockConfigService extends ConfigService { + DoctorInfoModel currentConfig; + bool shouldThrowOnSave = false; + bool shouldThrowOnLoad = false; + + MockConfigService({ + DoctorInfoModel? initialConfig, + }) : currentConfig = initialConfig ?? + const DoctorInfoModel( + id: 1, + nombreDoctor: 'Dr. Luis A. Castro Sosa', + especialidad: 'Cirujano Urólogo', + descripEspecialidad: 'Enfermedades Vías Urinarias, Riñones', + descripGeneral: 'Santiago, R.D. Tel: 809-583-2394', + ); + + @override + Future getConfig() async { + if (shouldThrowOnLoad) { + throw Exception('Error al cargar configuración de prueba'); + } + return currentConfig; + } + + @override + Future updateConfig(DoctorInfoModel model) async { + if (shouldThrowOnSave) { + throw Exception('Error al guardar configuración de prueba'); + } + currentConfig = model.copyWith(id: 1); + return currentConfig; + } +} + +void main() { + group('DoctorInfoModel Serialization & Immutability', () { + test('serializes and deserializes properly from Directus format', () { + final json = { + 'id': 1, + 'nombre_doctor': 'Dr. Luis A. Castro Sosa', + 'especialidad': 'Cirujano Urólogo', + 'descrip_especialidad': 'Urología Oncológica y Laparoscopía', + 'descrip_general': 'Consultorio 204, Santiago. Tel: 809-555-0100', + }; + + final model = DoctorInfoModel.fromJson(json); + + expect(model.id, equals(1)); + expect(model.nombreDoctor, equals('Dr. Luis A. Castro Sosa')); + expect(model.especialidad, equals('Cirujano Urólogo')); + expect(model.descripEspecialidad, equals('Urología Oncológica y Laparoscopía')); + expect(model.descripGeneral, equals('Consultorio 204, Santiago. Tel: 809-555-0100')); + + // toJson without ID for Directus PATCH singleton + final jsonWithoutId = model.toJson(); + expect(jsonWithoutId.containsKey('id'), isFalse); + expect(jsonWithoutId['nombre_doctor'], equals('Dr. Luis A. Castro Sosa')); + expect(jsonWithoutId['especialidad'], equals('Cirujano Urólogo')); + + // toJson with ID + final jsonWithId = model.toJson(includeId: true); + expect(jsonWithId['id'], equals(1)); + }); + + test('copyWith properly modifies target fields while preserving others', () { + const original = DoctorInfoModel( + id: 1, + nombreDoctor: 'Dr. Luis Castro', + especialidad: 'Urólogo', + descripEspecialidad: 'General', + descripGeneral: 'Santiago', + ); + + final updated = original.copyWith( + especialidad: 'Urología Robótica', + ); + + expect(updated.id, equals(1)); + expect(updated.nombreDoctor, equals('Dr. Luis Castro')); + expect(updated.especialidad, equals('Urología Robótica')); + expect(updated.descripEspecialidad, equals('General')); + expect(updated.descripGeneral, equals('Santiago')); + }); + }); + + group('ConfigStore State Management & Validation', () { + test('validation requires nombreDoctor and especialidad', () { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + + expect(store.isValid, isFalse); + + store.nombreDoctor = 'Dr. Luis Castro'; + expect(store.isValid, isFalse); + + store.especialidad = 'Cirujano Urólogo'; + expect(store.isValid, isTrue); + + store.nombreDoctor = ' '; + expect(store.isValid, isFalse); + }); + + test('loadConfig successfully loads data into store observables', () async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + + expect(store.isLoading, isFalse); + final loadFuture = store.loadConfig(); + expect(store.isLoading, isTrue); + await loadFuture; + + expect(store.isLoading, isFalse); + expect(store.id, equals(1)); + expect(store.nombreDoctor, equals('Dr. Luis A. Castro Sosa')); + expect(store.especialidad, equals('Cirujano Urólogo')); + expect(store.errorMessage, isNull); + }); + + test('loadConfig sets errorMessage on failure', () async { + final mockService = MockConfigService()..shouldThrowOnLoad = true; + final store = ConfigStore(configService: mockService); + + await store.loadConfig(); + + expect(store.isLoading, isFalse); + expect(store.errorMessage, isNotNull); + expect(store.errorMessage, contains('Error al cargar configuración')); + }); + + test('saveConfig validates and updates via service', () async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + + // Attempt save with invalid state + final invalidResult = await store.saveConfig(); + expect(invalidResult, isNull); + expect(store.errorMessage, isNotNull); + + // Set valid state and save + store.nombreDoctor = 'Dr. Juan Perez'; + store.especialidad = 'Cardiólogo'; + store.descripEspecialidad = 'Arritmias y Marcapasos'; + store.descripGeneral = 'Clínica Corominas'; + + final savedResult = await store.saveConfig(); + expect(savedResult, isNotNull); + expect(savedResult!.nombreDoctor, equals('Dr. Juan Perez')); + expect(savedResult.especialidad, equals('Cardiólogo')); + expect(store.successMessage, isNotNull); + expect(store.isSaving, isFalse); + expect(store.errorMessage, isNull); + }); + + test('saveConfig handles service error gracefully', () async { + final mockService = MockConfigService()..shouldThrowOnSave = true; + final store = ConfigStore(configService: mockService); + + store.nombreDoctor = 'Dr. Luis Castro'; + store.especialidad = 'Urología'; + + final result = await store.saveConfig(); + expect(result, isNull); + expect(store.isSaving, isFalse); + expect(store.errorMessage, contains('Error al guardar configuración')); + }); + }); + + group('ConfigModalView Widget Tests', () { + testWidgets('renders all fields and sections properly', (tester) async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + await store.loadConfig(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConfigModalView( + store: store, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Check titles & subtitles + expect(find.text('Configuración del Consultorio'), findsOneWidget); + expect(find.text('Identidad del Profesional'), findsOneWidget); + expect(find.text('Áreas de Enfoque y Subespecialidades'), findsOneWidget); + expect(find.text('Datos del Consultorio y Contacto'), findsOneWidget); + + // Check buttons and footer elements + expect(find.text('Cancelar'), findsOneWidget); + expect(find.text('Guardar Configuración'), findsOneWidget); + expect(find.text('ESC para salir'), findsOneWidget); + + // Check populated initial values + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('Cirujano Urólogo'), findsOneWidget); + }); + + testWidgets('footer container has established padding separating buttons from extremities', (tester) async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + await store.loadConfig(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConfigModalView( + store: store, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find the footer Container that holds the buttons and ESC hint + final footerFinder = find.ancestor( + of: find.text('Guardar Configuración'), + matching: find.byType(Container), + ); + expect(footerFinder, findsWidgets); + + final footerContainer = tester.widget(footerFinder.first); + expect(footerContainer.padding, equals(const EdgeInsets.symmetric(horizontal: 20, vertical: 12))); + }); + + testWidgets('cancels and closes when Cancelar button is pressed', (tester) async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + await store.loadConfig(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () => showMedicalModal( + context: ctx, + builder: (_) => ConfigModalView(store: store), + ), + child: const Text('Abrir Modal'), + ), + ), + ), + ), + ); + + // Open modal + await tester.tap(find.text('Abrir Modal')); + await tester.pumpAndSettle(); + expect(find.text('Configuración del Consultorio'), findsOneWidget); + + // Tap Cancelar + await tester.tap(find.text('Cancelar')); + await tester.pumpAndSettle(); + + // Modal is dismissed + expect(find.text('Configuración del Consultorio'), findsNothing); + }); + + testWidgets('closes modal when ESC key is pressed', (tester) async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + await store.loadConfig(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () => showMedicalModal( + context: ctx, + builder: (_) => ConfigModalView(store: store), + ), + child: const Text('Abrir Modal'), + ), + ), + ), + ), + ); + + // Open modal + await tester.tap(find.text('Abrir Modal')); + await tester.pumpAndSettle(); + expect(find.text('Configuración del Consultorio'), findsOneWidget); + + // Simulate ESC key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // Modal must be dismissed via ESC + expect(find.text('Configuración del Consultorio'), findsNothing); + }); + + testWidgets('saves modified configuration and triggers onSaved callback', (tester) async { + final mockService = MockConfigService(); + final store = ConfigStore(configService: mockService); + await store.loadConfig(); + + DoctorInfoModel? savedModel; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () => showMedicalModal( + context: ctx, + builder: (_) => ConfigModalView( + store: store, + onSaved: (m) => savedModel = m, + ), + ), + child: const Text('Abrir Modal'), + ), + ), + ), + ), + ); + + // Open modal + await tester.tap(find.text('Abrir Modal')); + await tester.pumpAndSettle(); + + // Enter updated doctor name + final nameField = find.widgetWithText(TextField, 'Dr. Luis A. Castro Sosa'); + await tester.enterText(nameField, 'Dr. Luis Alberto Castro'); + await tester.pumpAndSettle(); + + // Tap Guardar + await tester.tap(find.text('Guardar Configuración')); + await tester.pumpAndSettle(); + + // Verify callback was called with updated name and modal closed + expect(savedModel, isNotNull); + expect(savedModel!.nombreDoctor, equals('Dr. Luis Alberto Castro')); + expect(find.text('Configuración del Consultorio'), findsNothing); + expect(find.text('Configuración del consultorio guardada correctamente'), findsOneWidget); + }); + }); +} diff --git a/test/features/department_referral_test.dart b/test/features/department_referral_test.dart new file mode 100644 index 0000000..cf6c102 --- /dev/null +++ b/test/features/department_referral_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/store/config_store.dart'; +import 'package:hidalyx_drluiscastro/features/referrals/presentation/views/department_referral_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/referrals/services/department_referral_pdf_service.dart'; + +void main() { + group('DepartmentReferralModalView Tests', () { + testWidgets('renders modal pre-populated with AlfaMedical department template', (tester) async { + final configStore = ConfigStore(); + configStore.nombreDoctor = 'Dr. Luis A. Castro Sosa'; + configStore.especialidad = 'Cirujano Urólogo'; + configStore.descripEspecialidad = 'Enfermedades Vías Urinarias'; + configStore.descripGeneral = 'Santiago, RD'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DepartmentReferralModalView( + patientName: 'CARLOS DIAZ', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Referir a Departamento'), findsOneWidget); + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('CIRUJANO URÓLOGO'), findsOneWidget); + expect(find.text('Crear / Imprimir Referimiento'), findsOneWidget); + expect(find.text('ESC para salir'), findsOneWidget); + + // Verify default AlfaMedical department template content in the text field + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, contains('Paciente: CARLOS DIAZ')); + expect(textField.controller?.text, contains('Referido al departamento de anestesiologia')); + expect(textField.controller?.text, contains('Plan: Realizar evaluación pre-anestesica')); + expect(textField.controller?.text, contains('Para realizar:')); + expect(textField.controller?.text, contains('Suite 408, 4ta planta.')); + expect(textField.controller?.text, contains('Gracias.')); + }); + + testWidgets('shows validation message when text is empty', (tester) async { + final configStore = ConfigStore(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DepartmentReferralModalView( + patientName: 'CARLOS DIAZ', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Clear text field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + final printBtn = find.text('Crear / Imprimir Referimiento'); + await tester.tap(printBtn); + await tester.pump(); + + expect(find.text('Debe redactar el contenido del referimiento.'), findsOneWidget); + }); + }); + + group('DepartmentReferralPdfService Tests', () { + test('builds department referral pdf bytes with 8.5x5.5 inch format', () async { + final bytes = await DepartmentReferralPdfService.buildDepartmentReferralPdf( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + subspecialty: 'Enfermedades Vias Urinarias', + generalContact: 'Santiago, RD. Tel: 809-583-2394', + referralText: 'Paciente: CARLOS DIAZ\n\nReferido al departamento de anestesiologia\nPlan: Realizar evaluación pre-anestesica\n\nPara realizar: Cistoscopia\n\nSuite 408, 4ta planta.\nGracias.', + ); + + expect(bytes, isNotEmpty); + expect(bytes.sublist(0, 4), equals([0x25, 0x50, 0x44, 0x46])); + }); + }); +} diff --git a/test/features/doctor_referral_test.dart b/test/features/doctor_referral_test.dart new file mode 100644 index 0000000..1231a81 --- /dev/null +++ b/test/features/doctor_referral_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/store/config_store.dart'; +import 'package:hidalyx_drluiscastro/features/referrals/presentation/views/doctor_referral_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/referrals/services/doctor_referral_pdf_service.dart'; + +void main() { + group('DoctorReferralModalView Tests', () { + testWidgets('renders modal pre-populated with AlfaMedical template', (tester) async { + final configStore = ConfigStore(); + configStore.nombreDoctor = 'Dr. Luis A. Castro Sosa'; + configStore.especialidad = 'Cirujano Urólogo'; + configStore.descripEspecialidad = 'Enfermedades Vías Urinarias'; + configStore.descripGeneral = 'Santiago, RD'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DoctorReferralModalView( + patientName: 'CARLOS DIAZ', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Referir a Doctor (a)'), findsOneWidget); + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('CIRUJANO URÓLOGO'), findsOneWidget); + expect(find.text('Crear / Imprimir Referimiento'), findsOneWidget); + expect(find.text('ESC para salir'), findsOneWidget); + + // Verify default AlfaMedical template content in the text field + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, contains('Paciente: CARLOS DIAZ')); + expect(textField.controller?.text, contains('Referido a Doctor (a) Miguelina Pichardo')); + expect(textField.controller?.text, contains('Medicina Interna')); + expect(textField.controller?.text, contains('Plan: Realizar evaluación cardiovascular para realizar:')); + expect(textField.controller?.text, contains('Gracias.')); + }); + + testWidgets('shows validation message when text is empty', (tester) async { + final configStore = ConfigStore(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DoctorReferralModalView( + patientName: 'CARLOS DIAZ', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Clear text field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + final printBtn = find.text('Crear / Imprimir Referimiento'); + await tester.tap(printBtn); + await tester.pump(); + + expect(find.text('Debe redactar el contenido del referimiento.'), findsOneWidget); + }); + }); + + group('DoctorReferralPdfService Tests', () { + test('builds doctor referral pdf bytes with 8.5x5.5 inch format', () async { + final bytes = await DoctorReferralPdfService.buildDoctorReferralPdf( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + subspecialty: 'Enfermedades Vias Urinarias', + generalContact: 'Santiago, RD. Tel: 809-583-2394', + referralText: 'Paciente: CARLOS DIAZ\n\nReferido a Doctor (a) Miguelina Pichardo\nMedicina Interna\n\nPlan: Evaluación cardiovascular previa.', + ); + + expect(bytes, isNotEmpty); + expect(bytes.sublist(0, 4), equals([0x25, 0x50, 0x44, 0x46])); + }); + }); +} diff --git a/test/features/insurance_auth_test.dart b/test/features/insurance_auth_test.dart new file mode 100644 index 0000000..0436186 --- /dev/null +++ b/test/features/insurance_auth_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/store/config_store.dart'; +import 'package:hidalyx_drluiscastro/features/insurance_auth/presentation/views/insurance_auth_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/insurance_auth/services/insurance_auth_pdf_service.dart'; + +void main() { + group('InsuranceAuthModalView Tests', () { + testWidgets('renders all fields matching AlfaMedical specification', (tester) async { + final configStore = ConfigStore(); + configStore.nombreDoctor = 'Dr. Luis A. Castro Sosa'; + configStore.especialidad = 'Cirujano Urólogo'; + configStore.descripEspecialidad = 'Enfermedades Vías Urinarias'; + configStore.descripGeneral = 'Santiago, RD'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InsuranceAuthModalView( + patientName: 'MARIA SANTOS', + initialDiagnosis: 'Litiasis renal derecha', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Autorización De Seguro (ARS)'), findsOneWidget); + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('CIRUJANO URÓLOGO'), findsOneWidget); + expect(find.text('MARIA SANTOS'), findsOneWidget); + expect(find.text('Litiasis renal derecha'), findsOneWidget); + expect(find.text('Crear / Imprimir Autorización'), findsOneWidget); + expect(find.text('ESC para salir'), findsOneWidget); + }); + + testWidgets('shows validation message when required fields are missing', (tester) async { + final configStore = ConfigStore(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InsuranceAuthModalView( + patientName: '', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final printBtn = find.text('Crear / Imprimir Autorización'); + await tester.tap(printBtn); + await tester.pump(); + + expect(find.text('Debe indicar el nombre del paciente.'), findsOneWidget); + }); + }); + + group('InsuranceAuthPdfService Tests', () { + test('builds insurance auth pdf bytes with 8.5x5.5 inch format', () async { + final bytes = await InsuranceAuthPdfService.buildInsuranceAuthPdf( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + subspecialty: 'Enfermedades Vias Urinarias', + generalContact: 'Santiago, RD. Tel: 809-583-2394', + arsName: 'Humano Seguros, S.A.', + patientName: 'MARIA SANTOS', + surgeryText: 'Ureterolitotomía endoscópica láser', + diagnosisText: 'Cálculo ureteral obstructivo', + anesthesia: 'Raquídea', + daysOfStay: '1', + ); + + expect(bytes, isNotEmpty); + expect(bytes.sublist(0, 4), equals([0x25, 0x50, 0x44, 0x46])); + }); + }); +} diff --git a/test/features/patient_search_test.dart b/test/features/patient_search_test.dart new file mode 100644 index 0000000..12025a1 --- /dev/null +++ b/test/features/patient_search_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/widgets/medical_modal.dart'; +import 'package:hidalyx_drluiscastro/features/patient_search/data/models/patient_search_item_model.dart'; +import 'package:hidalyx_drluiscastro/features/patient_search/data/services/patient_search_service.dart'; +import 'package:hidalyx_drluiscastro/features/patient_search/presentation/store/patient_search_store.dart'; +import 'package:hidalyx_drluiscastro/features/patient_search/presentation/widgets/search_input_bar.dart'; + +class MockPatientSearchService extends PatientSearchService { + @override + Future> searchPatients(String rawQuery, {int limit = 25}) async { + if (rawQuery == 'error') { + throw Exception('Simulated network error'); + } + if (rawQuery == 'Carlos') { + return [ + const PatientSearchItemModel( + id: 13717, + nombre: 'CARLOS JUAN', + apellido: 'ARIAS GOMEZ', + cedula: '001-1234567-8', + telefono: '809-555-0101', + edad: '45', + sexo: 'M', + fecUltcon: '15/09/2026', + seguro: 'SENASA', + motCons: 'Control Rutinario', + ), + ]; + } + return []; + } +} + +void main() { + group('PatientSearchItemModel', () { + test('correctly parses JSON and provides full name', () { + final json = { + 'id': '123', + 'nombre': 'JUAN', + 'apellido': 'PEREZ', + 'cedula': '001-0000000-0', + 'telefono': '8090000000', + 'edad': '30', + 'sexo': 'M', + 'fec_ultcon': '01/01/2026', + 'seguro': 'HUMANO', + 'mot_cons': 'Chequeo', + }; + + final model = PatientSearchItemModel.fromJson(json); + expect(model.id, equals(123)); + expect(model.fullName, equals('JUAN PEREZ')); + expect(model.cedula, equals('001-0000000-0')); + }); + }); + + group('PatientSearchStore', () { + test('successful search populates results and updates hasSearched', () async { + final store = PatientSearchStore(searchService: MockPatientSearchService()); + expect(store.hasSearched, isFalse); + expect(store.resultsCount, equals(0)); + + await store.search('Carlos'); + + expect(store.hasSearched, isTrue); + expect(store.isLoading, isFalse); + expect(store.errorMessage, isNull); + expect(store.resultsCount, equals(1)); + expect(store.results.first.fullName, equals('CARLOS JUAN ARIAS GOMEZ')); + }); + + test('error handling updates errorMessage', () async { + final store = PatientSearchStore(searchService: MockPatientSearchService()); + await store.search('error'); + + expect(store.hasSearched, isTrue); + expect(store.isLoading, isFalse); + expect(store.errorMessage, contains('Simulated network error')); + expect(store.resultsCount, equals(0)); + }); + + test('clear resets the store state', () async { + final store = PatientSearchStore(searchService: MockPatientSearchService()); + await store.search('Carlos'); + expect(store.resultsCount, equals(1)); + + store.clear(); + expect(store.resultsCount, equals(0)); + expect(store.hasSearched, isFalse); + expect(store.query, isEmpty); + }); + }); + + group('SearchInputBar widget', () { + testWidgets('triggers onSearch ONLY when Enter is pressed or Buscar is clicked', (tester) async { + String? searchedQuery; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchInputBar( + onSearch: (q) => searchedQuery = q, + onClear: () {}, + ), + ), + ), + ); + + // Typing alone should not trigger search + await tester.enterText(find.byType(TextField), 'Carlos Arias'); + await tester.pump(); + expect(searchedQuery, isNull); + + // Submitting via Enter key + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(searchedQuery, equals('Carlos Arias')); + }); + }); + + group('MedicalModalDialog and showMedicalModal behavior', () { + testWidgets('modal does NOT close when tapping outside (barrierDismissible is false)', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: context, + builder: (_) => const Text('Modal Body'), + ); + }, + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + expect(find.text('Modal Body'), findsOneWidget); + + // Tap on the outside barrier (top-left corner outside dialog) + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + + // Modal MUST still be present + expect(find.text('Modal Body'), findsOneWidget); + }); + + testWidgets('modal closes when close icon (X) button is clicked', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: context, + builder: (_) => const MedicalModalDialog( + title: 'Test Modal', + child: Text('Content'), + ), + ); + }, + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + expect(find.text('Test Modal'), findsOneWidget); + + // Click the top-right close button (X) + await tester.tap(find.byTooltip('Cerrar (Esc)')); + await tester.pumpAndSettle(); + + // Modal must be closed + expect(find.text('Test Modal'), findsNothing); + }); + + testWidgets('modal closes when ESC key is pressed', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: context, + builder: (_) => const MedicalModalDialog( + title: 'Test Modal', + child: Text('Content'), + ), + ); + }, + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + expect(find.text('Test Modal'), findsOneWidget); + + // Send ESC key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // Modal must be closed + expect(find.text('Test Modal'), findsNothing); + }); + }); +} diff --git a/test/features/patient_test.dart b/test/features/patient_test.dart new file mode 100644 index 0000000..9b755ee --- /dev/null +++ b/test/features/patient_test.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/core/widgets/medical_modal.dart'; +import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model.dart'; +import 'package:hidalyx_drluiscastro/features/patient/data/services/patient_service.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/store/patient_store.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/views/patient_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/patient_demographics_form.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:toggle_switch/toggle_switch.dart'; + +class MockPatientService extends PatientService { + PatientModel? storedPatient; + + @override + Future getPatientById(int id) async { + return const PatientModel( + id: 99, + nombre: 'MARIA', + apellido: 'SANTOS', + cedula: '402-0000000-1', + telefono: '809-555-9999', + direccion: 'Santiago', + ocupacion: 'Docente', + edad: '35', + sexo: 'Femenino', + seguro: 'Sí', + estCivil: 'Casado', + ); + } + + @override + Future createPatient(PatientModel model) async { + storedPatient = model; + return PatientModel.fromJson({ + ...model.toJson(), + 'id': 101, + }); + } + + @override + Future updatePatient(int id, Map updates) async { + return PatientModel.fromJson({ + ...updates, + 'id': id, + }); + } +} + +void main() { + group('PatientModel serialization', () { + test('serializes and deserializes properly from Directus schema', () { + final json = { + 'id': 50, + 'nombre': 'CARLOS', + 'apellido': 'ARIAS', + 'telefono': '809-555-1234', + 'direccion': 'Calle 1 #2', + 'cedula': '001-0000000-0', + 'ocupacion': 'Ingeniero', + 'edad': '40', + 'sexo': 'Masculino', + 'seguro': 'Sí', + 'est_civil': 'Casado', + 'mot_cons': 'Dolor de cabeza', + }; + + final model = PatientModel.fromJson(json); + expect(model.id, equals(50)); + expect(model.fullName, equals('CARLOS ARIAS')); + expect(model.sexo, equals('Masculino')); + expect(model.seguro, equals('Sí')); + + final outJson = model.toJson(includeId: true); + expect(outJson['id'], equals(50)); + expect(outJson['nombre'], equals('CARLOS')); + expect(outJson['mot_cons'], equals('Dolor de cabeza')); + }); + }); + + group('PatientStore business logic', () { + test('validation requires nombre, apellido, sexo and seguro', () { + final store = PatientStore(patientService: MockPatientService()); + store.initForCreate(); + expect(store.isValid, isFalse); + + store.nombre = 'Ana'; + expect(store.isValid, isFalse); + + store.apellido = 'Gomez'; + expect(store.isValid, isTrue); // sexo and seguro default to 'Masculino'/'Sí' + }); + + test('calculates remaining days for proxima cita correctly', () { + final store = PatientStore(patientService: MockPatientService()); + store.initForCreate(); + + expect(store.diasParaProximaCita, isNull); + + final now = DateTime.now(); + final futureDate = now.add(const Duration(days: 10)); + final day = futureDate.day.toString().padLeft(2, '0'); + final month = futureDate.month.toString().padLeft(2, '0'); + final year = futureDate.year.toString(); + + store.fecProxcita = '$day/$month/$year'; + expect(store.diasParaProximaCita, equals(10)); + }); + + test('creates new patient successfully', () async { + final mock = MockPatientService(); + final store = PatientStore(patientService: mock); + store.initForCreate(); + store.nombre = 'Pedro'; + store.apellido = 'Martinez'; + + final success = await store.savePatient(); + expect(success, isTrue); + expect(store.id, equals(101)); + expect(store.successMessage, contains('guardado correctamente')); + }); + + test('updates existing patient successfully', () async { + final mock = MockPatientService(); + final store = PatientStore(patientService: mock); + await store.loadPatient(99); + + expect(store.isEditing, isTrue); + expect(store.nombre, equals('MARIA')); + expect(store.apellido, equals('SANTOS')); + + store.ocupacion = 'Directora'; + final success = await store.savePatient(); + expect(success, isTrue); + expect(store.successMessage, contains('actualizado correctamente')); + }); + }); + + group('PatientModalView strict closure tests', () { + testWidgets('modal adheres to strict ESC and (X) close rules', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: context, + builder: (_) => const PatientModalView(), + ); + }, + child: const Text('Registrar'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Registrar')); + await tester.pumpAndSettle(); + expect(find.text('Registro De Paciente'), findsOneWidget); + + // Outside tap does NOT close + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.text('Registro De Paciente'), findsOneWidget); + + // ESC key closes the modal + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Registro De Paciente'), findsNothing); + }); + + testWidgets('date button inserts current date at cursor in Consultas', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: context, + builder: (_) => const PatientModalView(), + ); + }, + child: const Text('Registrar'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Registrar')); + await tester.pumpAndSettle(); + + // Find the Fecha actual button + final insertBtn = find.widgetWithText(ElevatedButton, 'Fecha actual'); + expect(insertBtn, findsOneWidget); + + await tester.tap(insertBtn); + await tester.pumpAndSettle(); + + // Verify that current year is present in the field + final currentYear = DateTime.now().year.toString(); + expect(find.textContaining(currentYear), findsWidgets); + }); + + testWidgets('renders and switches ToggleSwitch for Sexo, Seguro, and Est. Civil', (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () { + showMedicalModal( + context: ctx, + builder: (_) => const PatientModalView(), + ); + }, + child: const Text('Registrar'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Registrar')); + await tester.pumpAndSettle(); + + // Verify ToggleSwitch instances exist for Sexo, Seguro, and Est. Civil + expect(find.byType(ToggleSwitch), findsNWidgets(3)); + + // Tap Femenino + await tester.tap(find.text('Femenino')); + await tester.pumpAndSettle(); + + // Tap Sin Seguro + await tester.tap(find.text('Sin Seguro')); + await tester.pumpAndSettle(); + + // Tap Casado + await tester.tap(find.text('Casado')); + await tester.pumpAndSettle(); + + // Verify the 8 demographic text fields have unifiedRowHeight matching toggle containers and save button + final textFields = find.byType(FormBuilderTextField); + + // Verify all 8 fields, 3 toggle selector boxes, and the Save button have unifiedRowHeight + for (int i = 0; i < 8; i++) { + final decFinder = find.descendant(of: textFields.at(i), matching: find.byType(InputDecorator)); + final customPaintFinder = find.descendant(of: decFinder, matching: find.byType(CustomPaint)); + final rb = customPaintFinder.evaluate().first.renderObject as RenderBox; + final translation = rb.localToGlobal(Offset.zero); + final rect = translation & rb.size; + expect(rect.height, equals(PatientDemographicsForm.unifiedRowHeight)); + } + + // Verify the 3 toggle selector visible boxes have unifiedRowHeight + final selectorBoxes = find.byWidgetPredicate((w) => w is Container && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).color == const Color(0xFFF8FAFC)); + for (int i = 0; i < 3; i++) { + final boxRect = tester.getRect(selectorBoxes.at(i)); + expect(boxRect.height, equals(PatientDemographicsForm.unifiedRowHeight)); + } + + // Verify the Save button has unifiedRowHeight + final saveBtn = find.widgetWithText(ElevatedButton, 'Guardar Paciente'); + expect(tester.getRect(saveBtn).height, equals(PatientDemographicsForm.unifiedRowHeight)); + + // Row-by-row pixel-exact alignment check for all 4 rows + for (int row = 0; row < 4; row++) { + // Col 1 field + final f1Dec = find.descendant(of: textFields.at(row), matching: find.byType(InputDecorator)); + final f1Cp = find.descendant(of: f1Dec, matching: find.byType(CustomPaint)); + final f1Rb = f1Cp.evaluate().first.renderObject as RenderBox; + final f1Rect = f1Rb.localToGlobal(Offset.zero) & f1Rb.size; + + // Col 2 field + final f2Dec = find.descendant(of: textFields.at(row + 4), matching: find.byType(InputDecorator)); + final f2Cp = find.descendant(of: f2Dec, matching: find.byType(CustomPaint)); + final f2Rb = f2Cp.evaluate().first.renderObject as RenderBox; + final f2Rect = f2Rb.localToGlobal(Offset.zero) & f2Rb.size; + + // Col 3 item (selector box for rows 0..2, saveBtn for row 3) + final Rect f3Rect = row < 3 ? tester.getRect(selectorBoxes.at(row)) : tester.getRect(saveBtn); + + // Verify tops match + expect(f1Rect.top, equals(f2Rect.top), reason: 'Row $row tops Col1 vs Col2'); + expect(f1Rect.top, equals(f3Rect.top), reason: 'Row $row tops Col1 vs Col3'); + + // Verify bottoms match + expect(f1Rect.bottom, equals(f2Rect.bottom), reason: 'Row $row bottoms Col1 vs Col2'); + expect(f1Rect.bottom, equals(f3Rect.bottom), reason: 'Row $row bottoms Col1 vs Col3'); + + // Verify heights match exactly + expect(f1Rect.height, equals(f3Rect.height), reason: 'Row $row heights Col1 vs Col3'); + } + }); + + testWidgets('OutlineInputBorder and Toggle Container alignment check', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SizedBox( + height: 51, + child: FormBuilder( + child: FormBuilderTextField( + name: 'test', + initialValue: 'Texto', + decoration: InputDecoration( + labelText: 'Nombre *', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + margin: const EdgeInsets.only(top: 7), + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Text('Toggle Container'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(FormBuilderTextField), findsOneWidget); + expect(find.text('Toggle Container'), findsOneWidget); + }); + }); + + group('PatientStore consultation workflow', () { + test('savePatient transitions estadoEspera from en_consulta to atendido', () async { + final mockService = MockPatientService(); + final store = PatientStore(patientService: mockService); + store.id = 10; + store.nombre = 'JUAN'; + store.apellido = 'PEREZ'; + store.sexo = 'Masculino'; + store.seguro = 'Sí'; + store.estadoEspera = 'en_consulta'; + + final success = await store.savePatient(); + + expect(success, isTrue); + expect(store.estadoEspera, 'atendido'); + }); + + test('savePatient transitions estadoEspera from en_espera to atendido', () async { + final mockService = MockPatientService(); + final store = PatientStore(patientService: mockService); + store.id = 11; + store.nombre = 'PEDRO'; + store.apellido = 'GOMEZ'; + store.sexo = 'Masculino'; + store.seguro = 'Sí'; + store.estadoEspera = 'en_espera'; + + final success = await store.savePatient(); + + expect(success, isTrue); + expect(store.estadoEspera, 'atendido'); + }); + }); +} diff --git a/test/features/prescription_test.dart b/test/features/prescription_test.dart new file mode 100644 index 0000000..cfd3768 --- /dev/null +++ b/test/features/prescription_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/config/presentation/store/config_store.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/clinical_documents_dropdown.dart'; +import 'package:hidalyx_drluiscastro/features/prescription/presentation/views/prescription_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/prescription/services/prescription_pdf_service.dart'; + +void main() { + group('ClinicalDocumentsDropdown Tests', () { + testWidgets('renders dropdown button with title and icon', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ClinicalDocumentsDropdown( + onSelect: (_) {}, + ), + ), + ), + ); + + expect(find.text('Documentos Clínicos'), findsOneWidget); + expect(find.byIcon(Icons.description_outlined), findsOneWidget); + }); + + testWidgets('opens menu and displays Receta Médica option', (tester) async { + ClinicalDocumentType? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ClinicalDocumentsDropdown( + onSelect: (type) => selected = type, + ), + ), + ), + ); + + await tester.tap(find.text('Documentos Clínicos')); + await tester.pumpAndSettle(); + + expect(find.text('Receta Médica'), findsOneWidget); + expect(find.text('Formato media carta institucional'), findsOneWidget); + expect(find.text('Autorización De Seguro'), findsOneWidget); + expect(find.text('Referir a Doctor(a)'), findsOneWidget); + expect(find.text('Referir a Departamento'), findsOneWidget); + + await tester.tap(find.text('Receta Médica')); + await tester.pumpAndSettle(); + + expect(selected, equals(ClinicalDocumentType.recetaMedica)); + }); + }); + + group('PrescriptionModalView Tests', () { + testWidgets('renders patient name, doctor header and prescription fields', (tester) async { + final configStore = ConfigStore(); + configStore.nombreDoctor = 'Dr. Luis A. Castro Sosa'; + configStore.especialidad = 'Cirujano Urólogo'; + configStore.descripEspecialidad = 'Enfermedades Vías Urinarias'; + configStore.descripGeneral = 'Santiago, RD'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PrescriptionModalView( + patientName: 'JUAN PEREZ', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Emisión de Receta Médica'), findsOneWidget); + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('CIRUJANO URÓLOGO'), findsOneWidget); + expect(find.text('ENFERMEDADES VÍAS URINARIAS'), findsOneWidget); + expect(find.text('JUAN PEREZ'), findsOneWidget); + expect(find.text('Crear / Imprimir Receta'), findsOneWidget); + expect(find.text('ESC para salir'), findsOneWidget); + }); + + testWidgets('inserts bullet when clicking viñeta button', (tester) async { + final configStore = ConfigStore(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PrescriptionModalView( + patientName: 'CARLOS ARIAS', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final bulletBtn = find.text('Insertar viñeta (•)'); + expect(bulletBtn, findsOneWidget); + + await tester.tap(bulletBtn); + await tester.pump(); + + expect(find.text('• '), findsOneWidget); + }); + }); + + group('PrescriptionPdfService Tests', () { + test('builds prescription pdf bytes with correct page format', () async { + final bytes = await PrescriptionPdfService.buildPrescriptionPdf( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + subspecialty: 'Enfermedades Vias Urinarias', + generalContact: 'Santiago, RD. Tel: 809-583-2394', + patientName: 'JUAN PEREZ', + prescriptionText: '1. Ciprofloxacina 500mg\nTomar 1 tableta cada 12 horas por 7 días.', + ); + + expect(bytes, isNotEmpty); + // Valid PDF magic header %PDF + expect(bytes.sublist(0, 4), equals([0x25, 0x50, 0x44, 0x46])); + }); + }); +} diff --git a/test/features/secretary_role_test.dart b/test/features/secretary_role_test.dart new file mode 100644 index 0000000..0ecd5cf --- /dev/null +++ b/test/features/secretary_role_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/auth/data/models/auth_session_model.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/dashboard_app_bar.dart'; +import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/views/patient_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/clinical_documents_dropdown.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/patient_clinical_tabs.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/secretary_appointment_section.dart'; + +void main() { + group('AuthSessionModel Role Detection Tests', () { + test('correctly identifies Doctor vs Secretary roles', () { + const doctorSession = AuthSessionModel( + accessToken: 'token_123', + userId: 'u1', + firstName: 'Luis A.', + lastName: 'Castro Sosa', + email: 'castro@hidalyx.com', + roleName: 'Doctor', + ); + expect(doctorSession.isSecretary, isFalse); + expect(doctorSession.isDoctorOrAdmin, isTrue); + + const secretarySession = AuthSessionModel( + accessToken: 'token_456', + userId: 'u2', + firstName: 'Ana', + lastName: 'Pérez', + email: 'secretaria@hidalyx.com', + roleName: 'Secretaria', + ); + expect(secretarySession.isSecretary, isTrue); + expect(secretarySession.isDoctorOrAdmin, isFalse); + + const receptionistSession = AuthSessionModel( + accessToken: 'token_789', + userId: 'u3', + firstName: 'Maria', + lastName: 'Gomez', + email: 'recepcion@hidalyx.com', + roleName: 'Recepcionista', + ); + expect(receptionistSession.isSecretary, isTrue); + + const assistantSession = AuthSessionModel( + accessToken: 'token_101', + userId: 'u4', + firstName: 'Carmen', + lastName: 'Díaz', + email: 'asistente@hidalyx.com', + roleName: 'Asistente Médico', + ); + expect(assistantSession.isSecretary, isTrue); + }); + }); + + group('DashboardAppBar Role-based Tests', () { + testWidgets('renders RECEPCIÓN badge and hides Configuración for secretary', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + appBar: DashboardAppBar( + doctorName: 'Ana Pérez', + specialty: 'Recepción y Consultas', + isSecretary: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('RECEPCIÓN'), findsOneWidget); + expect(find.text('Ana Pérez'), findsOneWidget); + + // Direct quick action buttons are immediately visible without opening a menu + expect(find.text('Nuevo Paciente'), findsOneWidget); + expect(find.text('Buscar Paciente'), findsOneWidget); + expect(find.byIcon(Icons.settings_outlined), findsNothing); + }); + + testWidgets('shows Configuración in QuickActions for Doctor', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + appBar: DashboardAppBar( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + isSecretary: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('RECEPCIÓN'), findsNothing); + + // Direct quick action buttons are immediately visible + expect(find.text('Nuevo Paciente'), findsOneWidget); + expect(find.text('Buscar Paciente'), findsOneWidget); + expect(find.byIcon(Icons.settings_outlined), findsOneWidget); + }); + }); + + group('PatientModalView Role-based Streamlined View Tests', () { + testWidgets('renders streamlined reception view for secretary', (tester) async { + final patient = PatientModel( + id: 101, + nombre: 'JUAN', + apellido: 'MARTINEZ', + fecProxcita: '25/09/2026', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PatientModalView( + initialPatient: patient, + isSecretary: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify title reflects reception + expect(find.text('Ficha de Recepción'), findsOneWidget); + expect(find.text('Recepción, datos personales y programación de citas'), findsOneWidget); + + // Confidential clinical documents and clinical tabs MUST NOT exist + expect(find.byType(ClinicalDocumentsDropdown), findsNothing); + expect(find.byType(PatientClinicalTabs), findsNothing); + + // Secretary appointment section MUST exist + expect(find.byType(SecretaryAppointmentSection), findsOneWidget); + expect(find.text('Gestión de Citas y Turno del Paciente'), findsOneWidget); + expect(find.text('Próxima Cita Programada: 25/09/2026'), findsOneWidget); + expect(find.text('Elegir en Calendario'), findsOneWidget); + }); + + testWidgets('renders full clinical tabs and documents dropdown for Doctor', (tester) async { + final patient = PatientModel( + id: 102, + nombre: 'PEDRO', + apellido: 'RAMIREZ', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PatientModalView( + initialPatient: patient, + isSecretary: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify title reflects doctor editing + expect(find.text('Editar Paciente'), findsOneWidget); + + // Doctor gets clinical documents dropdown and clinical tabs + expect(find.byType(ClinicalDocumentsDropdown), findsOneWidget); + expect(find.byType(PatientClinicalTabs), findsOneWidget); + expect(find.byType(SecretaryAppointmentSection), findsNothing); + }); + }); +} diff --git a/test/features/waiting_room_test.dart b/test/features/waiting_room_test.dart new file mode 100644 index 0000000..148d7db --- /dev/null +++ b/test/features/waiting_room_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/data/models/patient_summary_model.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/today_appointments_card.dart'; + +void main() { + group('Sala de Espera (Waiting Room) Logic Tests', () { + test('PatientSummaryModel correctly handles waiting room status and helpers', () { + const p1 = PatientSummaryModel( + id: 1, + nombre: 'Carlos', + apellido: 'Santana', + cedula: '001-1234567-8', + telefono: '809-555-0101', + fecReg: '21/09/2026', + fecProxCita: '21/09/2026', + seguro: 'Senasa', + motCons: 'Dolor lumbar', + estadoEspera: 'no_presente', + ); + + expect(p1.isNoPresente, isTrue); + expect(p1.isEnEspera, isFalse); + expect(p1.isEnConsulta, isFalse); + expect(p1.isAtendido, isFalse); + expect(p1.estadoEsperaLabel, 'No Presente'); + + final pEnEspera = p1.copyWith(estadoEspera: 'en_espera'); + expect(pEnEspera.isEnEspera, isTrue); + expect(pEnEspera.isNoPresente, isFalse); + expect(pEnEspera.estadoEsperaLabel, 'En Sala de Espera'); + + final pEnConsulta = p1.copyWith(estadoEspera: 'en_consulta'); + expect(pEnConsulta.isEnConsulta, isTrue); + expect(pEnConsulta.estadoEsperaLabel, 'En Consulta'); + + final pAtendido = p1.copyWith(estadoEspera: 'atendido'); + expect(pAtendido.isAtendido, isTrue); + expect(pAtendido.estadoEsperaLabel, 'Atendido'); + }); + + test('PatientSummaryModel serialization includes estado_espera', () { + final json = { + 'id': 42, + 'nombre': 'Elena', + 'apellido': 'Gomez', + 'cedula': '001-9999999-9', + 'telefono': '809-555-9999', + 'fec_reg': '21/09/2026', + 'fec_proxcita': '21/09/2026', + 'seguro': 'Humano', + 'mot_cons': 'Evaluación general', + 'estado_espera': 'en_espera', + }; + + final model = PatientSummaryModel.fromJson(json); + expect(model.id, 42); + expect(model.estadoEspera, 'en_espera'); + expect(model.isEnEspera, isTrue); + + final outJson = model.toJson(); + expect(outJson['estado_espera'], 'en_espera'); + }); + }); + + group('TodayAppointmentsCard Waiting Room UI Tests', () { + testWidgets('renders "Llegó" button for secretary when patient is not present', (tester) async { + const patient = PatientSummaryModel( + id: 10, + nombre: 'Mario', + apellido: 'Bros', + cedula: '001-0000000-1', + telefono: '809-111-2222', + fecReg: '21/09/2026', + fecProxCita: '21/09/2026', + seguro: 'Universal', + motCons: 'Revisión', + estadoEspera: 'no_presente', + ); + + String? triggeredStatus; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TodayAppointmentsCard( + appointments: const [patient], + isSecretary: true, + onStatusChange: (p, newStatus) async { + triggeredStatus = newStatus; + }, + ), + ), + ), + ); + + expect(find.text('Llegó'), findsOneWidget); + expect(find.text('Mario Bros'), findsOneWidget); + + await tester.tap(find.text('Llegó')); + await tester.pump(); + + expect(triggeredStatus, 'en_espera'); + }); + + testWidgets('renders "En Espera" badge and Doctor action "Atender"', (tester) async { + const patient = PatientSummaryModel( + id: 11, + nombre: 'Luigi', + apellido: 'Verde', + cedula: '001-0000000-2', + telefono: '809-111-3333', + fecReg: '21/09/2026', + fecProxCita: '21/09/2026', + seguro: 'Palic', + motCons: 'Control', + estadoEspera: 'en_espera', + ); + + PatientSummaryModel? selectedPatient; + String? updatedStatus; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TodayAppointmentsCard( + appointments: const [patient], + isSecretary: false, // Doctor + onPatientTap: (p) => selectedPatient = p, + onStatusChange: (p, status) async => updatedStatus = status, + ), + ), + ), + ); + + expect(find.text('En Espera'), findsOneWidget); + expect(find.text('Atender'), findsOneWidget); + + await tester.tap(find.text('Atender')); + await tester.pump(); + + expect(updatedStatus, 'en_consulta'); + expect(selectedPatient?.id, 11); + }); + + testWidgets('renders "En Consulta" badge and Doctor action "Finalizar"', (tester) async { + const patient = PatientSummaryModel( + id: 12, + nombre: 'Peach', + apellido: 'Toadstool', + cedula: '001-0000000-3', + telefono: '809-111-4444', + fecReg: '21/09/2026', + fecProxCita: '21/09/2026', + seguro: 'Universal', + motCons: 'Consulta en proceso', + estadoEspera: 'en_consulta', + ); + + String? updatedStatus; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TodayAppointmentsCard( + appointments: const [patient], + isSecretary: false, // Doctor + onStatusChange: (p, status) async => updatedStatus = status, + ), + ), + ), + ); + + expect(find.text('En Consulta'), findsOneWidget); + expect(find.text('Finalizar'), findsOneWidget); + + await tester.tap(find.text('Finalizar')); + await tester.pump(); + + expect(updatedStatus, 'atendido'); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..09ee21a --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/dashboard_app_bar.dart'; + +import 'package:hidalyx_drluiscastro/features/dashboard/data/models/dashboard_metrics_model.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/data/models/patient_summary_model.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/dashboard_metrics_cards.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/recent_patients_card.dart'; +import 'package:hidalyx_drluiscastro/features/dashboard/presentation/widgets/today_appointments_card.dart'; + +void main() { + testWidgets('DashboardAppBar renders doctor name, specialty and quick actions', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + appBar: DashboardAppBar( + doctorName: 'Dr. Luis A. Castro Sosa', + specialty: 'Cirujano Urólogo', + ), + ), + ), + ); + expect(find.text('Dr. Luis A. Castro Sosa'), findsOneWidget); + expect(find.text('Cirujano Urólogo'), findsOneWidget); + expect(find.text('Nuevo Paciente'), findsOneWidget); + expect(find.text('Buscar Paciente'), findsOneWidget); + }); + + testWidgets('Dashboard cards and lists render properly inside IntrinsicHeight without layout crash', (WidgetTester tester) async { + const mockPatients = [ + PatientSummaryModel( + id: 1, + nombre: 'CARLOS JUAN', + apellido: 'ARIAS GOMEZ', + cedula: 'UNIVERSAL', + telefono: '829-552-4105', + fecReg: '17/07/2026', + fecProxCita: '25/02/2027', + seguro: 'Sí', + motCons: 'CHEQUEO DE RUTINA', + ), + ]; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + DashboardMetricsCards( + metrics: DashboardMetricsModel( + nuevosPacientes: 0, + consultadosHoy: 0, + citasHoy: 1, + pacientesMasculinos: 11078, + pacientesFemeninos: 2634, + totalPacientes: 13712, + ), + ), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: RecentPatientsCard(patients: mockPatients)), + Expanded(child: TodayAppointmentsCard(appointments: mockPatients)), + ], + ), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Total Nuevos Pacientes'), findsOneWidget); + expect(find.text('Pacientes Nuevos'), findsOneWidget); + expect(find.text('Citas para Hoy'), findsOneWidget); + expect(find.text('CARLOS JUAN ARIAS GOMEZ'), findsNWidgets(2)); + }); +} + + + diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..64c0ee8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + Hidalyx Med - Dr. Luis Castro + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..085fc44 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "hidalyx_drluiscastro", + "short_name": "hidalyx_drluiscastro", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..08d0d88 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(hidalyx_drluiscastro LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "hidalyx_drluiscastro") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..3dea03b --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..e685eaf --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + printing +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..d62c038 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.hidalyx" "\0" + VALUE "FileDescription", "hidalyx_drluiscastro" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "hidalyx_drluiscastro" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.hidalyx. All rights reserved." "\0" + VALUE "OriginalFilename", "hidalyx_drluiscastro.exe" "\0" + VALUE "ProductName", "hidalyx_drluiscastro" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..eded065 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"hidalyx_drluiscastro", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_