first commit

This commit is contained in:
=
2026-09-21 16:33:51 -04:00
commit a899479dc9
204 changed files with 21132 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.0"
}
+45
View File
@@ -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/
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "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'
+17
View File
@@ -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.
+57
View File
@@ -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/<feature_name>/
> ├── 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.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -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
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+45
View File
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="hidalyx_drluiscastro"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.hidalyx.hidalyx_drluiscastro
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="2048.000000pt" height="2048.000000pt" viewBox="0 0 2048.000000 2048.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,2048.000000) scale(0.100000,-0.100000)"
fill="#2251FF" stroke="none">
<path d="M4727 15916 c-222 -79 -367 -282 -367 -516 0 -207 114 -397 296 -491
l69 -36 3 -1336 2 -1337 -68 -34 c-335 -169 -405 -615 -138 -882 48 -47 93
-82 139 -104 l67 -33 -2 -2781 -3 -2781 -68 -34 c-128 -63 -235 -196 -272
-336 -19 -76 -19 -224 0 -300 41 -155 169 -298 322 -360 130 -52 310 -45 443
17 91 43 204 159 245 250 l30 67 1442 1 1442 0 38 -72 c48 -93 147 -194 231
-237 176 -89 394 -79 551 26 112 74 192 185 226 313 20 74 19 232 -1 301 -43
144 -141 265 -266 329 l-68 35 0 1341 0 1340 38 18 c114 54 215 153 271 264
l31 62 895 0 895 0 24 -48 c58 -114 152 -212 260 -268 l56 -29 0 -1336 c0
-1260 -1 -1337 -17 -1350 -10 -8 -40 -25 -67 -39 -67 -34 -165 -133 -203 -205
-48 -91 -67 -168 -67 -270 -1 -410 427 -670 797 -484 87 44 186 147 229 238
l33 71 1435 -3 1435 -2 36 -67 c52 -95 100 -150 177 -202 88 -60 166 -86 277
-93 166 -10 298 40 416 158 170 169 208 425 96 648 -43 87 -135 179 -221 221
-37 18 -70 38 -73 44 -4 6 -9 608 -11 1337 l-3 1327 68 36 c135 71 228 178
275 314 33 97 33 269 0 365 -47 134 -141 242 -271 311 l-71 37 0 2777 0 2778
63 31 c274 136 382 457 247 734 -60 124 -178 229 -308 275 -81 29 -234 33
-325 9 -148 -38 -266 -134 -333 -269 l-37 -76 -1441 1 -1441 0 -34 71 c-62
129 -192 236 -334 274 -69 18 -219 18 -292 -2 -270 -70 -451 -384 -380 -657
37 -143 155 -291 283 -356 l62 -31 0 -1343 0 -1343 -62 -30 c-86 -40 -192
-146 -242 -241 l-37 -73 -894 0 -894 0 -38 73 c-49 93 -146 191 -235 237 l-68
35 0 1340 0 1340 68 35 c90 46 187 144 230 232 155 316 -4 683 -337 782 -113
33 -282 16 -401 -41 -80 -39 -178 -141 -220 -230 l-34 -73 -1452 0 -1451 0
-33 68 c-40 80 -149 193 -226 234 -119 64 -310 78 -437 34z m3611 -735 c10
-29 5 -35 -524 -570 -344 -347 -540 -553 -549 -575 -13 -31 -15 -240 -15
-1546 0 -1131 -3 -1517 -12 -1533 -6 -12 -133 -155 -281 -317 -149 -162 -359
-392 -466 -510 -108 -118 -257 -282 -331 -363 -74 -81 -140 -160 -147 -175 -9
-21 -12 -377 -13 -1517 l0 -1491 -58 -65 c-32 -36 -207 -237 -391 -447 -418
-481 -437 -502 -451 -482 -6 10 -10 941 -10 2787 l0 2771 68 38 c138 77 244
221 277 379 18 88 18 132 0 220 -35 165 -148 318 -282 381 l-62 29 0 1343 c-1
1118 1 1342 12 1342 13 0 73 -73 465 -559 l141 -174 -21 -40 c-73 -143 -82
-325 -23 -469 53 -129 178 -260 293 -304 l42 -17 0 -1244 0 -1243 183 2 182 3
3 1242 2 1242 68 36 c217 114 333 355 289 602 -36 200 -188 375 -375 433 -77
24 -221 27 -302 6 -44 -12 -56 -12 -65 -1 -5 7 -118 147 -251 311 -133 165
-266 330 -297 368 l-55 69 16 34 16 33 1457 0 1457 0 10 -29z m6751 -11 c10
-35 10 -43 -6 -63 -10 -13 -214 -252 -453 -531 -239 -279 -447 -525 -462 -547
-17 -26 -28 -58 -32 -97 -3 -31 -4 -712 -3 -1514 3 -1031 0 -1462 -7 -1476
-10 -19 -107 -126 -616 -682 -424 -463 -595 -652 -605 -669 -6 -10 -11 -278
-12 -741 -1 -399 -2 -945 -3 -1215 l-1 -490 -62 -29 c-90 -42 -205 -160 -251
-256 -19 -40 -40 -98 -47 -129 -17 -83 -7 -243 21 -314 l22 -58 -24 -29 c-12
-15 -92 -106 -177 -202 -85 -95 -229 -257 -320 -361 -91 -103 -170 -186 -176
-184 -5 2 -13 24 -17 48 -3 24 -7 627 -7 1339 l-1 1295 67 35 c40 21 96 63
139 105 111 109 154 217 154 386 0 194 -81 347 -241 453 -158 104 -382 114
-553 23 -79 -41 -182 -145 -224 -224 l-33 -63 -904 0 -904 0 -33 63 c-43 81
-149 187 -225 226 -221 111 -465 72 -639 -103 -109 -109 -154 -219 -154 -376
0 -158 50 -280 160 -390 40 -41 91 -79 132 -100 l68 -35 0 -1323 c0 -885 -3
-1330 -10 -1342 -6 -11 -36 -32 -68 -47 -77 -37 -189 -149 -226 -228 -16 -33
-34 -63 -40 -67 -6 -4 -655 -8 -1442 -8 -1129 0 -1434 3 -1447 13 -9 6 -19 26
-22 42 -5 26 2 39 52 96 75 84 605 693 757 869 65 74 125 149 134 165 16 28
17 144 19 1523 l2 1493 108 117 c60 64 171 187 248 272 77 85 187 207 245 270
318 345 618 678 628 697 9 16 12 412 12 1548 l0 1526 497 505 c274 277 502
504 508 504 6 0 19 -6 28 -12 16 -12 17 -102 15 -1347 l-3 -1335 -60 -30 c-86
-45 -204 -164 -243 -246 -41 -87 -51 -137 -52 -246 0 -240 149 -447 375 -517
101 -31 243 -29 341 5 133 47 247 146 309 269 l25 49 905 0 904 0 31 -58 c140
-263 462 -364 730 -230 125 63 239 205 275 343 19 75 19 204 0 278 -38 145
-155 291 -283 351 l-62 30 0 1340 0 1341 63 30 c85 40 202 158 242 243 l29 62
1447 0 1446 0 12 -40z m309 -3077 l2 -2782 -62 -33 c-81 -42 -194 -155 -230
-230 -79 -162 -78 -360 3 -520 41 -82 145 -186 227 -227 l62 -31 0 -1340 c0
-1017 -3 -1341 -12 -1346 -15 -9 -51 30 -383 416 -148 172 -323 374 -387 448
-84 96 -118 142 -118 161 -1 14 -2 87 -4 161 -1 74 -3 746 -4 1493 l-2 1357
-180 0 -180 0 0 -1572 c1 -1205 4 -1579 13 -1600 11 -25 89 -117 703 -828 126
-146 235 -273 242 -282 14 -17 1 -66 -21 -80 -7 -4 -654 -8 -1440 -8 l-1427 0
-20 33 c-11 17 -20 34 -20 38 0 6 110 135 295 344 78 88 174 198 215 245 40
47 95 108 123 136 l49 52 56 -20 c70 -25 208 -34 287 -19 132 25 270 114 350
226 73 102 99 186 99 325 1 95 -3 127 -22 182 -48 137 -147 252 -281 322 l-71
37 0 1144 0 1144 118 128 c128 139 717 787 959 1055 104 116 152 177 157 199
3 17 6 710 6 1539 l0 1507 253 294 c138 162 333 390 432 507 107 126 186 211
195 209 13 -2 15 -321 18 -2784z m-9128 1927 c113 -58 131 -224 35 -309 -30
-26 -89 -51 -120 -51 -60 0 -148 60 -171 117 -17 39 -18 111 -3 150 37 98 161
143 259 93z m6883 -7234 c71 -29 127 -129 111 -199 -12 -56 -43 -106 -83 -133
-171 -113 -371 95 -257 266 47 71 148 100 229 66z"/>
<path d="M12921 14394 c-86 -23 -150 -63 -232 -144 -180 -178 -221 -437 -107
-664 43 -84 154 -193 241 -236 l67 -33 0 -1019 0 -1019 -133 -137 c-136 -141
-560 -589 -632 -669 -28 -31 -52 -47 -80 -53 -23 -6 -807 -10 -1863 -10 -1625
0 -1828 -2 -1857 -15 -19 -10 -197 -196 -446 -468 -603 -658 -605 -660 -617
-684 -9 -16 -12 -294 -12 -1057 l0 -1036 -69 -35 c-130 -65 -240 -190 -289
-329 -34 -97 -35 -250 -3 -346 68 -197 240 -348 434 -380 197 -34 391 29 517
167 58 63 126 195 140 272 45 246 -72 489 -294 611 l-76 42 0 968 0 969 168
183 c92 101 215 235 272 299 58 64 179 197 270 297 l165 182 1853 0 c1534 0
1858 2 1885 14 19 7 98 83 187 177 232 245 486 514 661 700 86 91 164 180 173
197 14 28 16 135 16 1106 l0 1075 68 35 c91 49 152 103 205 184 77 116 91 167
91 317 0 153 -15 208 -91 318 -75 110 -186 190 -310 222 -72 19 -230 18 -302
-1z m201 -354 c125 -35 181 -199 101 -297 -43 -54 -88 -77 -148 -77 -155 0
-245 194 -143 310 34 39 58 54 103 65 43 10 46 10 87 -1z m-5625 -7251 c64
-24 123 -109 123 -179 0 -78 -62 -158 -139 -181 -121 -36 -241 59 -241 192 0
91 89 179 181 179 26 0 60 -5 76 -11z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+620
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -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)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hidalyx Drluiscastro</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hidalyx_drluiscastro</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
typedef TokenRefreshCallback = Future<String?> 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');
}
}
+22
View File
@@ -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(),
),
],
);
}
+34
View File
@@ -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);
}
+109
View File
@@ -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,
),
);
}
}
+179
View File
@@ -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<T?> showMedicalModal<T>({
required BuildContext context,
required WidgetBuilder builder,
bool useRootNavigator = true,
}) {
return showDialog<T>(
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<Widget>? 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<MedicalModalDialog> createState() => _MedicalModalDialogState();
}
class _MedicalModalDialogState extends State<MedicalModalDialog> {
@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!,
],
),
),
),
);
}
}
View File
@@ -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<String, dynamic> loginData,
required Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
return {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires': expires,
'user_id': userId,
'first_name': firstName,
'last_name': lastName,
'email': email,
'role_name': roleName,
};
}
}
@@ -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<AuthSessionModel?> 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<String, dynamic>;
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<String, dynamic> 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<String, dynamic>;
}
} 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<String, dynamic>;
}
} 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<AuthSessionModel?> 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<String, dynamic>,
);
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<String, dynamic>;
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<AuthSessionModel?> loadSavedSession() async {
try {
final prefs = await SharedPreferences.getInstance();
final sessionJson = prefs.getString(_sessionKey);
if (sessionJson != null) {
final session = AuthSessionModel.fromJson(
jsonDecode(sessionJson) as Map<String, dynamic>,
);
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<void> 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<String, dynamic>,
);
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();
}
}
}
@@ -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<bool> 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<void> checkInitialSession() async {
isLoading = true;
try {
final session = await authService.loadSavedSession();
currentSession = session;
} catch (_) {
currentSession = null;
} finally {
isLoading = false;
}
}
@action
Future<bool> 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<void> logout() async {
isLoading = true;
errorMessage = null;
try {
await authService.logout();
currentSession = null;
} finally {
isLoading = false;
}
}
}
@@ -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<bool>? _$isAuthenticatedComputed;
@override
bool get isAuthenticated => (_$isAuthenticatedComputed ??= Computed<bool>(
() => 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<bool> 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<void> checkInitialSession() {
return _$checkInitialSessionAsyncAction.run(
() => super.checkInitialSession(),
);
}
late final _$refreshTokenAsyncAction = AsyncAction(
'_AuthStoreBase.refreshToken',
context: context,
);
@override
Future<bool> refreshToken() {
return _$refreshTokenAsyncAction.run(() => super.refreshToken());
}
late final _$logoutAsyncAction = AsyncAction(
'_AuthStoreBase.logout',
context: context,
);
@override
Future<void> 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}
''';
}
}
@@ -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<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
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,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -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<LoginFormWidget> createState() => _LoginFormWidgetState();
}
class _LoginFormWidgetState extends State<LoginFormWidget> {
final _formKey = GlobalKey<FormBuilderState>();
Future<void> _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: '[email protected]',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
hintText: '[email protected]',
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<Color>(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,
),
),
],
),
),
],
),
);
},
);
}
}
@@ -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,
),
),
],
);
}
}
@@ -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<DoctorInfoModel> 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<String, dynamic>) {
return DoctorInfoModel.fromJson(data);
} else if (data is List && data.isNotEmpty && data.first is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data.first as Map<String, dynamic>);
}
}
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<DoctorInfoModel> 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<String, dynamic>) {
return DoctorInfoModel.fromJson(data);
} else if (data is List && data.isNotEmpty && data.first is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data.first as Map<String, dynamic>);
}
}
return model;
} catch (e) {
throw Exception('Error al guardar la configuración: $e');
}
}
}
@@ -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<void> loadConfig() async {
isLoading = true;
errorMessage = null;
try {
final config = await service.getConfig();
initFromModel(config);
} catch (e) {
errorMessage = e.toString();
} finally {
isLoading = false;
}
}
@action
Future<DoctorInfoModel?> 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;
}
}
}
@@ -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<bool>? _$isValidComputed;
@override
bool get isValid => (_$isValidComputed ??= Computed<bool>(
() => 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<void> loadConfig() {
return _$loadConfigAsyncAction.run(() => super.loadConfig());
}
late final _$saveConfigAsyncAction = AsyncAction(
'_ConfigStoreBase.saveConfig',
context: context,
);
@override
Future<DoctorInfoModel?> 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}
''';
}
}
@@ -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<DoctorInfoModel>? onSaved;
final ConfigStore? store;
const ConfigModalView({
super.key,
this.onSaved,
this.store,
});
@override
State<ConfigModalView> createState() => _ConfigModalViewState();
}
class _ConfigModalViewState extends State<ConfigModalView> {
late final ConfigStore _store;
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
_store = widget.store ?? ConfigStore();
if (widget.store == null) {
_store.loadConfig();
}
}
Future<void> _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<Color>(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,
),
),
],
),
),
],
);
}
}
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'nuevos_pacientes': nuevosPacientes,
'consultados_hoy': consultadosHoy,
'citas_hoy': citasHoy,
'pacientes_masculinos': pacientesMasculinos,
'pacientes_femeninos': pacientesFemeninos,
'total_pacientes': totalPacientes,
};
}
}
@@ -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<String, dynamic> 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<String, dynamic> toJson({bool includeId = false}) {
final map = <String, dynamic>{
'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,
);
}
}
@@ -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<String, dynamic> 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<String, dynamic> 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,
};
}
}
@@ -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<DoctorInfoModel> 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<String, dynamic>;
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<DashboardMetricsModel> 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<dynamic>? ?? [];
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<dynamic>? ?? [];
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<dynamic>? ?? [];
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<dynamic>? ?? [];
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<dynamic>? ?? [];
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<List<PatientSummaryModel>> 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<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> 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<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> getTodayAppointments({int limit = 50}) async {
return getAppointmentsByDate(DateTime.now(), limit: limit);
}
Future<bool> 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;
}
}
}
@@ -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<PatientSummaryModel> recentPatients = ObservableList<PatientSummaryModel>();
@observable
ObservableList<PatientSummaryModel> todayAppointments = ObservableList<PatientSummaryModel>();
@observable
String? errorMessage;
int get enEsperaCount =>
todayAppointments.where((a) => a.isEnEspera).length;
@action
Future<void> 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<bool> 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;
}
}
@@ -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<PatientSummaryModel> get recentPatients {
_$recentPatientsAtom.reportRead();
return super.recentPatients;
}
@override
set recentPatients(ObservableList<PatientSummaryModel> value) {
_$recentPatientsAtom.reportWrite(value, super.recentPatients, () {
super.recentPatients = value;
});
}
late final _$todayAppointmentsAtom = Atom(
name: '_DashboardStoreBase.todayAppointments',
context: context,
);
@override
ObservableList<PatientSummaryModel> get todayAppointments {
_$todayAppointmentsAtom.reportRead();
return super.todayAppointments;
}
@override
set todayAppointments(ObservableList<PatientSummaryModel> 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<void> loadDashboardData() {
return _$loadDashboardDataAsyncAction.run(() => super.loadDashboardData());
}
late final _$changeWaitingStatusAsyncAction = AsyncAction(
'_DashboardStoreBase.changeWaitingStatus',
context: context,
);
@override
Future<bool> 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}
''';
}
}
@@ -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<AppointmentsModalView> createState() => _AppointmentsModalViewState();
}
class _AppointmentsModalViewState extends State<AppointmentsModalView> {
late final DashboardService _service;
late DateTime _selectedDate;
bool _isLoading = false;
String? _errorMessage;
List<PatientSummaryModel> _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<void> _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<PatientSummaryModel> 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<void> _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<void> _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),
),
),
],
),
);
}
}
@@ -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<DashboardView> createState() => _DashboardViewState();
}
class _DashboardViewState extends State<DashboardView> {
late final DashboardStore _store;
bool _isSecretary = false;
@override
void initState() {
super.initState();
_store = widget.store ?? DashboardStore();
_store.loadDashboardData();
_loadUserRole();
}
Future<void> _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,
],
);
},
),
],
),
),
),
),
);
},
);
}
}
@@ -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<QuickActionType>? 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,
),
),
),
),
),
],
),
),
),
),
],
),
),
);
}
}
@@ -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),
);
}
}
@@ -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<QuickActionType>? 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,
),
),
),
),
),
],
],
);
}
}
@@ -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<PatientSummaryModel> 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,
),
),
],
),
),
],
),
],
],
),
),
),
),
),
],
),
);
}
}
@@ -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<PatientSummaryModel> appointments;
final VoidCallback? onSchedulePressed;
final ValueChanged<PatientSummaryModel>? onPatientTap;
final bool isSecretary;
final Future<void> 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,
),
),
],
),
),
],
);
}
}

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