From 1c7b0775eff595daeb596fbe03dde6b8b5cc3b0f Mon Sep 17 00:00:00 2001 From: Savecoders Date: Tue, 15 Jul 2025 17:11:07 -0500 Subject: [PATCH 01/20] fix: remove SonarQube job from CI workflow --- .github/workflows/flutter_ci.yml | 54 ++------------------------------ 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml index 5605fba..93eb1a7 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.19.6' + flutter-version: "3.19.6" - name: Install dependencies run: flutter pub get @@ -77,56 +77,6 @@ jobs: name: web-build path: build/web - sonarqube: - needs: build-and-test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Important for SonarQube to analyze all branches - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.19.6' - - - name: Install dependencies - run: flutter pub get - - - name: Download coverage report - uses: actions/download-artifact@v4 - with: - name: coverage-report - path: coverage/ - - - name: Download test report - uses: actions/download-artifact@v4 - with: - name: test-report - path: ./ - - - name: Download sonar-flutter plugin - run: | - curl -L -o sonar-flutter-plugin.jar \ - https://github.com/insideapp-oss/sonar-flutter/releases/latest/download/sonar-flutter-plugin.jar - - - name: SonarQube Scan - uses: sonarqube-quality-gate-action@master - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - with: - scannerHomePath: /opt/sonar-scanner - args: > - -Dsonar.dart.analyzer.mode=DETECT - -Dsonar.dart.analyzer.report.mode=DETECT - -Dsonar.flutter.coverage.reportPath=coverage/lcov.info - -Dsonar.flutter.tests.reportPath=tests.output - -Dsonar.exclusions="**/*.g.dart,**/*.freezed.dart,**/generated_**,**/build/**,**/.dart_tool/**,**/ios/**,**/android/**,**/web/**,**/windows/**,**/linux/**,**/macos/**" - -Dsonar.test.exclusions="**/*_test.dart,**/*_test.dart" - -Dsonar.coverage.exclusions="**/*_test.dart,**/*_test.dart,**/*.g.dart,**/*.freezed.dart,**/generated_**,**/test/**" - -Dsonar.dart.analyzer.options.override=true - release: needs: build-and-test runs-on: ubuntu-latest @@ -137,7 +87,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.19.6' + flutter-version: "3.19.6" - name: Install dependencies run: flutter pub get From 277a78a91278a5793de6d9dc4960a931da3ebc2d Mon Sep 17 00:00:00 2001 From: Savecoders Date: Tue, 15 Jul 2025 17:15:49 -0500 Subject: [PATCH 02/20] fix: Archive web build as tar.gz for release uploads --- .github/workflows/flutter_ci.yml | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml index 93eb1a7..2ef2f9b 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yml @@ -1,5 +1,4 @@ name: Flutter CI - on: push: branches: [main] @@ -8,101 +7,82 @@ on: workflow_dispatch: release: types: [created] - jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Flutter uses: subosito/flutter-action@v2 with: flutter-version: "3.19.6" - - name: Install dependencies run: flutter pub get - - name: Run analyzer run: flutter analyze - - name: Run CRUD tests run: flutter test test/crud/ - - name: Run ViewModel tests run: flutter test test/viewmodels/ - - name: Run Utils tests run: flutter test test/utils/ - - name: Run Services tests run: flutter test test/services/ - - name: Run Repositories tests run: flutter test test/repositories/ - - name: Run all tests with coverage run: flutter test --coverage - - name: Generate test report for SonarQube run: flutter test --machine --coverage > tests.output - - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/ - - name: Upload test report uses: actions/upload-artifact@v4 with: name: test-report path: tests.output - - name: Build APK (release) run: flutter build apk --release - - name: Build Web (release) run: flutter build web - - name: Upload APK uses: actions/upload-artifact@v4 with: name: app-release-apk path: build/app/outputs/flutter-apk/app-release.apk - - name: Upload Web Build uses: actions/upload-artifact@v4 with: name: web-build path: build/web - release: needs: build-and-test runs-on: ubuntu-latest if: github.event_name == 'release' steps: - uses: actions/checkout@v4 - - name: Set up Flutter uses: subosito/flutter-action@v2 with: flutter-version: "3.19.6" - - name: Install dependencies run: flutter pub get - - name: Build APK (release) run: flutter build apk --release - - name: Build Web (release) run: flutter build web - + - name: Create web archive + run: | + cd build/web + tar -czf ../web-release.tar.gz . - name: Upload Release Assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v1 with: files: | build/app/outputs/flutter-apk/app-release.apk - build/web/** + build/web-release.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 87e1ef7dc0ab08b4b333f425136367051e462193 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Tue, 15 Jul 2025 17:21:59 -0500 Subject: [PATCH 03/20] fix: rename workflow file to .yaml and update Flutter version --- .github/workflows/{flutter_ci.yml => flutter_ci.yaml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{flutter_ci.yml => flutter_ci.yaml} (97%) diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yaml similarity index 97% rename from .github/workflows/flutter_ci.yml rename to .github/workflows/flutter_ci.yaml index 2ef2f9b..e74e188 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yaml @@ -15,7 +15,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.19.6" + flutter-version: "3.24.3" - name: Install dependencies run: flutter pub get - name: Run analyzer @@ -67,7 +67,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.19.6" + flutter-version: "3.24.3" - name: Install dependencies run: flutter pub get - name: Build APK (release) From a7414f4040d4d57df9c7b8c1013c2a7a544bd85c Mon Sep 17 00:00:00 2001 From: Savecoders Date: Tue, 15 Jul 2025 17:28:27 -0500 Subject: [PATCH 04/20] fix: workflow to use stable Flutter version --- .github/workflows/flutter_ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index e74e188..33469fd 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -1,4 +1,4 @@ -name: Flutter CI +name: Flutter Development CI on: push: branches: [main] @@ -15,7 +15,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.24.3" + flutter-version: "stable" - name: Install dependencies run: flutter pub get - name: Run analyzer @@ -67,7 +67,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.24.3" + flutter-version: "stable" - name: Install dependencies run: flutter pub get - name: Build APK (release) From f7251034a36586d7368fcbcefe339e9b60cdfbc0 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Tue, 15 Jul 2025 17:30:03 -0500 Subject: [PATCH 05/20] fix: stable Flutter CI workflow configuration --- .github/workflows/flutter_ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 33469fd..22a197d 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -1,4 +1,4 @@ -name: Flutter Development CI +name: Flutter CI on: push: branches: [main] @@ -15,7 +15,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "stable" + channel: "stable" - name: Install dependencies run: flutter pub get - name: Run analyzer @@ -67,7 +67,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "stable" + channel: "stable" - name: Install dependencies run: flutter pub get - name: Build APK (release) From 024b616d6fde5c2a13754d0601083d9d9970bf1e Mon Sep 17 00:00:00 2001 From: Savecoders Date: Thu, 17 Jul 2025 15:05:56 -0500 Subject: [PATCH 06/20] fix: update Gradle distribution URL and configuration settings --- android/gradle/wrapper/gradle-wrapper.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6..5c6f89d 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip From d89c729c63897251ae9e915a2cff2e8b96b99e2d Mon Sep 17 00:00:00 2001 From: Savecoders Date: Thu, 17 Jul 2025 16:13:31 -0500 Subject: [PATCH 07/20] fix: update Gradle and Flutter dependencies, adjust proguard rules, and modify login screen styles --- android/app/proguard-rules.pro | 0 android/build.gradle | 1 + android/gradle.properties | 2 +- android/gradle/wrapper/gradle-wrapper.properties | 2 +- lib/view/auth/login_selection_screen.dart | 6 +++--- pubspec.lock | 8 ++++---- 6 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 android/app/proguard-rules.pro diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/android/build.gradle b/android/build.gradle index 9bdf846..971c14a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,6 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.android.tools.build:gradle:8.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.4.0' } diff --git a/android/gradle.properties b/android/gradle.properties index 2597170..84044a9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 5c6f89d..3fa8f86 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/lib/view/auth/login_selection_screen.dart b/lib/view/auth/login_selection_screen.dart index 5d3b12f..aa7d835 100644 --- a/lib/view/auth/login_selection_screen.dart +++ b/lib/view/auth/login_selection_screen.dart @@ -31,7 +31,7 @@ class LoginSelectionScreen extends StatelessWidget { Column( children: [ Text( - 'LIGHT VITAE', + 'Light Vitae', style: AppFonts.titleBold.copyWith( fontSize: 48, color: AppColors.primaryColor, @@ -64,8 +64,8 @@ class LoginSelectionScreen extends StatelessWidget { child: Text( 'Selecciona tu tipo de acceso', style: AppFonts.subtitleBold.copyWith( - color: AppColors.primaryColor, - fontSize: 20, + color: AppColors.btnColor, + fontSize: 24, letterSpacing: 1.2, ), textAlign: TextAlign.center, diff --git a/pubspec.lock b/pubspec.lock index fc692bd..c9c61f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "754aed101003afceca3e6637fe88150dbe9739068e0135788a62645a131867bb" + sha256: b1ade5707ab7a90dfd519eaac78a7184341d19adb6096c68d499b59c7c6cf880 url: "https://pub.dev" source: hosted - version: "7.5.9" + version: "7.7.0" archive: dependency: transitive description: @@ -577,10 +577,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" + sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" url: "https://pub.dev" source: hosted - version: "0.8.12+23" + version: "0.8.12+24" image_picker_for_web: dependency: transitive description: From dd1c919eeed5794bea73118c59fb91cfc97512f9 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:55:07 -0500 Subject: [PATCH 08/20] docs: add SonarQube setup instructions with Docker to README --- README-Docker.md | 196 ----------------------------------------------- README.md | 195 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 196 deletions(-) delete mode 100644 README-Docker.md diff --git a/README-Docker.md b/README-Docker.md deleted file mode 100644 index dbc05f1..0000000 --- a/README-Docker.md +++ /dev/null @@ -1,196 +0,0 @@ -# SonarQube con Docker para Client Service - -Este README explica cómo usar SonarQube con Docker para el análisis de calidad de código del proyecto Client Service. - -## 🐳 Configuración Rápida con Docker - -### Prerrequisitos - -- Docker instalado -- Docker Compose instalado -- Flutter SDK instalado - -### Pasos de Configuración - -1. **Clonar el repositorio** (si no lo has hecho ya): - - ```bash - git clone - cd client_service - ``` - -2. **Hacer ejecutables los scripts**: - - ```bash - chmod +x download-sonar-flutter-plugin.sh - chmod +x run_sonar_analysis_docker.sh - ``` - -3. **Ejecutar el análisis completo**: - ```bash - ./run_sonar_analysis_docker.sh - ``` - -¡Eso es todo! El script se encarga de todo automáticamente. - -## 📋 Qué hace el script automáticamente - -1. ✅ Descarga el plugin sonar-flutter -2. ✅ Inicia SonarQube con Docker -3. ✅ Espera a que SonarQube esté listo -4. ✅ Instala dependencias de Flutter -5. ✅ Ejecuta tests con cobertura -6. ✅ Genera reportes de tests -7. ✅ Ejecuta el análisis de SonarQube -8. ✅ Muestra los resultados - -## 🌐 Acceder a SonarQube - -Una vez que el análisis esté completo, puedes acceder a SonarQube en: - -**URL**: http://localhost:9000 - -**Credenciales por defecto**: - -- Usuario: `admin` -- Contraseña: `admin` - -**Proyecto**: Client Service - -## 🛠️ Comandos Útiles - -### Iniciar solo SonarQube (sin análisis) - -```bash -docker-compose up -d sonarqube -``` - -### Detener SonarQube - -```bash -docker-compose down -``` - -### Ver logs de SonarQube - -```bash -docker-compose logs -f sonarqube -``` - -### Ejecutar análisis manual con Docker - -```bash -docker-compose run --rm sonar-scanner \ - -Dsonar.projectKey=client_service \ - -Dsonar.projectName="Client Service" \ - -Dsonar.sources=lib,pubspec.yaml \ - -Dsonar.tests=test \ - -Dsonar.flutter.coverage.reportPath=coverage/lcov.info \ - -Dsonar.flutter.tests.reportPath=tests.output -``` - -## 📊 Interpretación de Resultados - -### Quality Gate - -SonarQube verificará automáticamente: - -- **Coverage**: Porcentaje de cobertura de código -- **Duplications**: Código duplicado -- **Issues**: Problemas de calidad (bugs, vulnerabilidades, code smells) -- **Maintainability**: Mantenibilidad del código -- **Reliability**: Confiabilidad del código -- **Security**: Seguridad del código - -### Métricas Importantes - -- **Code Coverage**: Debe estar por encima del 80% -- **Technical Debt**: Debe ser mínimo -- **Code Smells**: Deben ser pocos o nulos -- **Bugs**: Deben ser cero -- **Vulnerabilities**: Deben ser cero - -## 🔧 Configuración Avanzada - -### Modificar configuración de SonarQube - -Edita el archivo `docker-compose.yml` para cambiar: - -- Puerto de SonarQube -- Configuración de memoria -- Volúmenes de datos - -### Personalizar análisis - -Edita el archivo `sonar-project.properties` para: - -- Cambiar exclusiones de archivos -- Modificar configuración del analizador -- Ajustar rutas de reportes - -## 🚨 Troubleshooting - -### Error: "Docker no está instalado" - -```bash -# Instalar Docker en Ubuntu/Debian -sudo apt update -sudo apt install docker.io docker-compose - -# Agregar usuario al grupo docker -sudo usermod -aG docker $USER -# Reiniciar sesión después -``` - -### Error: "SonarQube no está listo" - -```bash -# Verificar estado -docker-compose ps - -# Ver logs -docker-compose logs sonarqube - -# Reiniciar -docker-compose restart sonarqube -``` - -### Error: "Plugin no encontrado" - -```bash -# Descargar plugin manualmente -./download-sonar-flutter-plugin.sh - -# Reiniciar SonarQube -docker-compose restart sonarqube -``` - -### Error: "No coverage data found" - -```bash -# Verificar que los tests se ejecuten -flutter test --coverage - -# Verificar archivo de cobertura -ls -la coverage/lcov.info -``` - -## 📚 Recursos Adicionales - -- [Documentación oficial de sonar-flutter](https://github.com/insideapp-oss/sonar-flutter) -- [SonarQube Docker Hub](https://hub.docker.com/_/sonarqube) -- [Flutter Testing](https://docs.flutter.dev/testing) -- [Docker Compose](https://docs.docker.com/compose/) - -## 🤝 Contribuir - -Si encuentras problemas o quieres mejorar la configuración: - -1. Abre un issue en el repositorio -2. Describe el problema o mejora -3. Incluye logs si es necesario -4. Proporciona pasos para reproducir el problema - ---- - -**¡Disfruta analizando tu código con SonarQube! 🎉** diff --git a/README.md b/README.md index b1a45e7..1faea9d 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,198 @@ The project uses GitHub Actions for continuous integration: - [SonarQube Setup Guide](docs/sonarqube-setup.md) - Complete setup instructions - [Test Documentation](test/) - Test organization and examples + +# SonarQube con Docker para Client Service + +Este README explica cómo usar SonarQube con Docker para el análisis de calidad de código del proyecto Client Service. + +## 🐳 Configuración Rápida con Docker + +### Prerrequisitos + +- Docker instalado +- Docker Compose instalado +- Flutter SDK instalado + +### Pasos de Configuración + +1. **Clonar el repositorio** (si no lo has hecho ya): + + ```bash + git clone + cd client_service + ``` + +2. **Hacer ejecutables los scripts**: + + ```bash + chmod +x download-sonar-flutter-plugin.sh + chmod +x run_sonar_analysis_docker.sh + ``` + +3. **Ejecutar el análisis completo**: + ```bash + ./run_sonar_analysis_docker.sh + ``` + +¡Eso es todo! El script se encarga de todo automáticamente. + +## 📋 Qué hace el script automáticamente + +1. ✅ Descarga el plugin sonar-flutter +2. ✅ Inicia SonarQube con Docker +3. ✅ Espera a que SonarQube esté listo +4. ✅ Instala dependencias de Flutter +5. ✅ Ejecuta tests con cobertura +6. ✅ Genera reportes de tests +7. ✅ Ejecuta el análisis de SonarQube +8. ✅ Muestra los resultados + +## 🌐 Acceder a SonarQube + +Una vez que el análisis esté completo, puedes acceder a SonarQube en: + +**URL**: http://localhost:9000 + +**Credenciales por defecto**: + +- Usuario: `admin` +- Contraseña: `admin` + +**Proyecto**: Client Service + +## 🛠️ Comandos Útiles + +### Iniciar solo SonarQube (sin análisis) + +```bash +docker-compose up -d sonarqube +``` + +### Detener SonarQube + +```bash +docker-compose down +``` + +### Ver logs de SonarQube + +```bash +docker-compose logs -f sonarqube +``` + +### Ejecutar análisis manual con Docker + +```bash +docker-compose run --rm sonar-scanner \ + -Dsonar.projectKey=client_service \ + -Dsonar.projectName="Client Service" \ + -Dsonar.sources=lib,pubspec.yaml \ + -Dsonar.tests=test \ + -Dsonar.flutter.coverage.reportPath=coverage/lcov.info \ + -Dsonar.flutter.tests.reportPath=tests.output +``` + +## 📊 Interpretación de Resultados + +### Quality Gate + +SonarQube verificará automáticamente: + +- **Coverage**: Porcentaje de cobertura de código +- **Duplications**: Código duplicado +- **Issues**: Problemas de calidad (bugs, vulnerabilidades, code smells) +- **Maintainability**: Mantenibilidad del código +- **Reliability**: Confiabilidad del código +- **Security**: Seguridad del código + +### Métricas Importantes + +- **Code Coverage**: Debe estar por encima del 80% +- **Technical Debt**: Debe ser mínimo +- **Code Smells**: Deben ser pocos o nulos +- **Bugs**: Deben ser cero +- **Vulnerabilities**: Deben ser cero + +## 🔧 Configuración Avanzada + +### Modificar configuración de SonarQube + +Edita el archivo `docker-compose.yml` para cambiar: + +- Puerto de SonarQube +- Configuración de memoria +- Volúmenes de datos + +### Personalizar análisis + +Edita el archivo `sonar-project.properties` para: + +- Cambiar exclusiones de archivos +- Modificar configuración del analizador +- Ajustar rutas de reportes + +## 🚨 Troubleshooting + +### Error: "Docker no está instalado" + +```bash +# Instalar Docker en Ubuntu/Debian +sudo apt update +sudo apt install docker.io docker-compose + +# Agregar usuario al grupo docker +sudo usermod -aG docker $USER +# Reiniciar sesión después +``` + +### Error: "SonarQube no está listo" + +```bash +# Verificar estado +docker-compose ps + +# Ver logs +docker-compose logs sonarqube + +# Reiniciar +docker-compose restart sonarqube +``` + +### Error: "Plugin no encontrado" + +```bash +# Descargar plugin manualmente +./download-sonar-flutter-plugin.sh + +# Reiniciar SonarQube +docker-compose restart sonarqube +``` + +### Error: "No coverage data found" + +```bash +# Verificar que los tests se ejecuten +flutter test --coverage + +# Verificar archivo de cobertura +ls -la coverage/lcov.info +``` + +## 📚 Recursos Adicionales + +- [Documentación oficial de sonar-flutter](https://github.com/insideapp-oss/sonar-flutter) +- [SonarQube Docker Hub](https://hub.docker.com/_/sonarqube) +- [Flutter Testing](https://docs.flutter.dev/testing) +- [Docker Compose](https://docs.docker.com/compose/) + +## 🤝 Contribuir + +Si encuentras problemas o quieres mejorar la configuración: + +1. Abre un issue en el repositorio +2. Describe el problema o mejora +3. Incluye logs si es necesario +4. Proporciona pasos para reproducir el problema + +--- From e18c5d7cef0f992d5681e3b3b5598686299f34cf Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:57:39 -0500 Subject: [PATCH 09/20] refactor: remove unused authentication and notification services, and vehicle editing screen --- lib/services/auth_service.dart | 47 -- lib/utils/initial_user_setup.dart | 81 --- .../notificaciones_screen_new.dart | 505 ------------------ .../vehicle_rental/edit_vehicle_new.dart | 372 ------------- 4 files changed, 1005 deletions(-) delete mode 100644 lib/services/auth_service.dart delete mode 100644 lib/utils/initial_user_setup.dart delete mode 100644 lib/view/notifications/notificaciones_screen_new.dart delete mode 100644 lib/view/service/vehicle_rental/edit_vehicle_new.dart diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart deleted file mode 100644 index 364818e..0000000 --- a/lib/services/auth_service.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; - -class AuthService { - static final FirebaseFirestore _firestore = FirebaseFirestore.instance; - static final FirebaseAuth _auth = FirebaseAuth.instance; - static const String _collection = 'usuarios'; - - // Iniciar sesión solo para administradora - static Future> iniciarSesion({ - required String email, - required String password, - }) async { - try { - final userCredential = await _auth.signInWithEmailAndPassword( - email: email.trim(), - password: password, - ); - return { - 'success': true, - 'message': 'Inicio de sesión exitoso', - 'user': userCredential.user, - }; - } on FirebaseAuthException catch (e) { - return { - 'success': false, - 'message': e.message ?? 'Error desconocido', - }; - } catch (e) { - return { - 'success': false, - 'message': 'Error al iniciar sesión: $e', - }; - } - } - - // Cerrar sesión - static Future cerrarSesion() async { - await _auth.signOut(); - } - - // Verificar si hay una sesión activa - static bool get tieneUsuarioActivo => _auth.currentUser != null; - - // Obtener usuario actual - static User? get usuarioActual => _auth.currentUser; -} diff --git a/lib/utils/initial_user_setup.dart b/lib/utils/initial_user_setup.dart deleted file mode 100644 index c829945..0000000 --- a/lib/utils/initial_user_setup.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:client_service/models/empleado.dart'; - -class InitialUserSetup { - static final FirebaseFirestore _firestore = FirebaseFirestore.instance; - - static Future crearEmpleadosIniciales() async { - try { - // Verificar si ya existen empleados - final existing = await _firestore.collection('empleados').get(); - if (existing.docs.isNotEmpty) { - print('Ya existen empleados en el sistema'); - return; - } - - // Crear empleado administrador - final admin = Empleado( - nombre: 'Administrador', - apellido: 'Sistema', - cedula: 'admin', - direccion: 'Oficina', - telefono: '0000000000', - correo: 'admin@lightviate.com', - cargo: CargoEmpleado.administrador, - fechaContratacion: DateTime.now(), - fotoUrl: '', - ); - await _firestore.collection('empleados').add(admin.toMap()); - print('Empleado administrador creado: admin@lightviate.com'); - - // Crear empleados de ejemplo - final empleados = [ - Empleado( - nombre: 'Juan', - apellido: 'Pérez', - cedula: '123', - direccion: 'Calle 1', - telefono: '1111111111', - correo: 'juan@lightviate.com', - cargo: CargoEmpleado.tecnico, - fechaContratacion: DateTime.now(), - ), - Empleado( - nombre: 'María', - apellido: 'González', - cedula: '456', - direccion: 'Calle 2', - telefono: '2222222222', - correo: 'maria@lightviate.com', - cargo: CargoEmpleado.ayudante, - fechaContratacion: DateTime.now(), - ), - ]; - for (final emp in empleados) { - await _firestore.collection('empleados').add(emp.toMap()); - print('Empleado creado: ${emp.correo}'); - } - print('Empleados iniciales creados exitosamente'); - } catch (e) { - print('Error al crear empleados iniciales: $e'); - } - } - - // Método para mostrar todos los empleados (solo para debug) - static Future mostrarEmpleados() async { - try { - final empleados = await _firestore.collection('empleados').get(); - print('\n=== EMPLEADOS EN EL SISTEMA ==='); - for (final doc in empleados.docs) { - final empleado = Empleado.fromMap(doc.data(), doc.id); - print('ID: ${empleado.id}'); - print('Nombre: ${empleado.nombreCompleto}'); - print('Cargo: ${empleado.cargoDisplayName}'); - print('Correo: ${empleado.correo}'); - print('---'); - } - } catch (e) { - print('Error al obtener empleados: $e'); - } - } -} diff --git a/lib/view/notifications/notificaciones_screen_new.dart b/lib/view/notifications/notificaciones_screen_new.dart deleted file mode 100644 index 88104be..0000000 --- a/lib/view/notifications/notificaciones_screen_new.dart +++ /dev/null @@ -1,505 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:client_service/utils/colors.dart'; -import 'package:client_service/utils/constants/notificacion_sistema.dart'; -import 'package:client_service/services/notificacion_service.dart'; -import 'package:client_service/services/service_locator.dart'; - -class NotificacionesScreen extends StatefulWidget { - const NotificacionesScreen({super.key}); - - @override - State createState() => _NotificacionesScreenState(); -} - -class _NotificacionesScreenState extends State { - late final NotificacionService _notificacionService; - - @override - void initState() { - super.initState(); - _notificacionService = sl(); - _notificacionService.addListener(_onNotificacionesChanged); - // Agregar algunas notificaciones de prueba si no hay ninguna - _agregarNotificacionesPrueba(); - } - - @override - void dispose() { - _notificacionService.removeListener(_onNotificacionesChanged); - super.dispose(); - } - - void _onNotificacionesChanged() { - if (mounted) { - setState(() {}); - } - } - - Future _agregarNotificacionesPrueba() async { - // Solo agregar si no hay notificaciones - if (_notificacionService.notificaciones.isEmpty) { - await _notificacionService.agregarNotificacion( - titulo: 'Bienvenido al sistema', - mensaje: - 'Sistema de gestión de servicios técnicos iniciado correctamente.', - tipo: TipoNotificacion.sistema, - prioridad: PrioridadNotificacion.media, - mostrarFlash: false, - ); - - await _notificacionService.agregarNotificacion( - titulo: 'Mantenimiento programado', - mensaje: - 'Recordatorio: mantenimiento de cámaras programado para mañana a las 9:00 AM.', - tipo: TipoNotificacion.recordatorio, - prioridad: PrioridadNotificacion.alta, - mostrarFlash: false, - ); - - await _notificacionService.agregarNotificacion( - titulo: 'Nuevo servicio registrado', - mensaje: - 'Se ha registrado un nuevo servicio de instalación de postes para el cliente ACME Corp.', - tipo: TipoNotificacion.servicio, - prioridad: PrioridadNotificacion.media, - mostrarFlash: false, - ); - - await _notificacionService.agregarNotificacion( - titulo: 'Factura generada', - mensaje: - 'Nueva factura #001-2025 por \$2,500.00 ha sido creada exitosamente.', - tipo: TipoNotificacion.facturacion, - prioridad: PrioridadNotificacion.media, - mostrarFlash: false, - ); - } - } - - @override - Widget build(BuildContext context) { - final notificaciones = _notificacionService.notificaciones; - - return Scaffold( - backgroundColor: AppColors.backgroundColor, - appBar: AppBar( - title: const Text( - 'Notificaciones', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - backgroundColor: AppColors.primaryColor, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - IconButton( - icon: const Icon(Icons.mark_email_read, color: Colors.white), - onPressed: _marcarTodasComoLeidas, - ), - IconButton( - icon: const Icon(Icons.delete_sweep, color: Colors.white), - onPressed: _mostrarDialogoLimpiar, - ), - ], - ), - body: notificaciones.isEmpty - ? _buildEmptyState() - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: notificaciones.length, - itemBuilder: (context, index) { - return _buildNotificationCard(notificaciones[index]); - }, - ), - ); - } - - Widget _buildEmptyState() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.notifications_off, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'No hay notificaciones', - style: TextStyle( - fontSize: 18, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - Text( - 'Las notificaciones aparecerán aquí', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - ], - ), - ); - } - - Widget _buildNotificationCard(NotificacionSistema notificacion) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: notificacion.leida ? Colors.grey[50] : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: notificacion.leida - ? Colors.grey[300]! - : AppColors.primaryColor.withOpacity(0.2), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => _marcarComoLeida(notificacion), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAvatar(notificacion), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - notificacion.titulo, - style: TextStyle( - fontSize: 16, - fontWeight: notificacion.leida - ? FontWeight.w500 - : FontWeight.bold, - color: notificacion.leida - ? Colors.grey[700] - : Colors.black87, - ), - ), - ), - Row( - children: [ - _buildPriorityIcon(notificacion.prioridad), - const SizedBox(width: 8), - Text( - _formatearTiempo(notificacion.fechaCreacion), - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - ], - ), - ], - ), - const SizedBox(height: 4), - Text( - notificacion.tipo.displayName, - style: TextStyle( - fontSize: 12, - color: _getTipoColor(notificacion.tipo), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - Text( - notificacion.mensaje, - style: TextStyle( - fontSize: 14, - color: notificacion.leida - ? Colors.grey[600] - : Colors.grey[800], - height: 1.4, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - if (!notificacion.leida) ...[ - const SizedBox(height: 8), - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.primaryColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - const Text( - 'Nueva', - style: TextStyle( - fontSize: 12, - color: AppColors.primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ], - ), - ), - PopupMenuButton( - onSelected: (value) { - switch (value) { - case 'marcar': - _marcarComoLeida(notificacion); - break; - case 'eliminar': - _eliminarNotificacion(notificacion); - break; - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'marcar', - child: Row( - children: [ - Icon( - notificacion.leida - ? Icons.mark_email_unread - : Icons.mark_email_read, - size: 18, - ), - const SizedBox(width: 8), - Text(notificacion.leida - ? 'Marcar como no leída' - : 'Marcar como leída'), - ], - ), - ), - const PopupMenuItem( - value: 'eliminar', - child: Row( - children: [ - Icon(Icons.delete, size: 18, color: Colors.red), - SizedBox(width: 8), - Text('Eliminar', style: TextStyle(color: Colors.red)), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildAvatar(NotificacionSistema notificacion) { - Color backgroundColor; - IconData icon; - - switch (notificacion.tipo) { - case TipoNotificacion.servicio: - backgroundColor = AppColors.primaryColor; - icon = Icons.engineering; - break; - case TipoNotificacion.sistema: - backgroundColor = Colors.orange; - icon = Icons.settings; - break; - case TipoNotificacion.mantenimiento: - backgroundColor = Colors.blue; - icon = Icons.build; - break; - case TipoNotificacion.facturacion: - backgroundColor = Colors.green; - icon = Icons.receipt; - break; - case TipoNotificacion.recordatorio: - backgroundColor = Colors.purple; - icon = Icons.access_time; - break; - case TipoNotificacion.error: - backgroundColor = Colors.red; - icon = Icons.error; - break; - case TipoNotificacion.exito: - backgroundColor = Colors.teal; - icon = Icons.check_circle; - break; - } - - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.1), - shape: BoxShape.circle, - border: Border.all( - color: backgroundColor.withOpacity(0.3), - width: 1, - ), - ), - child: Icon( - icon, - color: backgroundColor, - size: 24, - ), - ); - } - - Widget _buildPriorityIcon(PrioridadNotificacion prioridad) { - Color color; - IconData icon; - - switch (prioridad) { - case PrioridadNotificacion.baja: - color = Colors.green; - icon = Icons.keyboard_arrow_down; - break; - case PrioridadNotificacion.media: - color = Colors.orange; - icon = Icons.remove; - break; - case PrioridadNotificacion.alta: - color = Colors.red; - icon = Icons.keyboard_arrow_up; - break; - case PrioridadNotificacion.critica: - color = Colors.purple; - icon = Icons.priority_high; - break; - } - - return Icon( - icon, - size: 16, - color: color, - ); - } - - Color _getTipoColor(TipoNotificacion tipo) { - switch (tipo) { - case TipoNotificacion.servicio: - return AppColors.primaryColor; - case TipoNotificacion.sistema: - return Colors.orange; - case TipoNotificacion.mantenimiento: - return Colors.blue; - case TipoNotificacion.facturacion: - return Colors.green; - case TipoNotificacion.recordatorio: - return Colors.purple; - case TipoNotificacion.error: - return Colors.red; - case TipoNotificacion.exito: - return Colors.teal; - } - } - - String _formatearTiempo(DateTime fecha) { - final ahora = DateTime.now(); - final diferencia = ahora.difference(fecha); - - if (diferencia.inMinutes < 60) { - return '${diferencia.inMinutes}m'; - } else if (diferencia.inHours < 24) { - return '${diferencia.inHours}h'; - } else if (diferencia.inDays < 7) { - return '${diferencia.inDays}d'; - } else { - return '${fecha.day}/${fecha.month}'; - } - } - - void _marcarComoLeida(NotificacionSistema notificacion) { - _notificacionService.marcarComoLeida(notificacion.id); - } - - void _eliminarNotificacion(NotificacionSistema notificacion) { - _notificacionService.eliminarNotificacion(notificacion.id); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Notificación eliminada'), - ), - ); - } - - void _marcarTodasComoLeidas() { - _notificacionService.marcarTodasComoLeidas(); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Todas las notificaciones marcadas como leídas'), - ), - ); - } - - void _mostrarDialogoLimpiar() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Limpiar notificaciones'), - content: const Text( - '¿Estás seguro de que quieres eliminar todas las notificaciones leídas?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - _limpiarNotificacionesLeidas(); - }, - child: const Text( - 'Eliminar', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ); - } - - void _limpiarNotificacionesLeidas() { - final notificacionesLeidas = - _notificacionService.notificaciones.where((n) => n.leida).length; - - _notificacionService.limpiarNotificacionesLeidas(); - - if (notificacionesLeidas > 0) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$notificacionesLeidas notificaciones eliminadas'), - ), - ); - } - } -} diff --git a/lib/view/service/vehicle_rental/edit_vehicle_new.dart b/lib/view/service/vehicle_rental/edit_vehicle_new.dart deleted file mode 100644 index 85b3bdd..0000000 --- a/lib/view/service/vehicle_rental/edit_vehicle_new.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'package:client_service/models/vehiculo.dart'; -import 'package:client_service/models/empleado.dart'; -import 'package:client_service/utils/colors.dart'; -import 'package:client_service/utils/font.dart'; -import 'package:client_service/view/widgets/shared/button.dart'; -import 'package:client_service/viewmodel/vehiculo_viewmodel.dart'; -import 'package:client_service/viewmodel/empleado_viewmodel.dart'; -import 'package:client_service/services/service_locator.dart'; -import 'package:client_service/view/widgets/flash_messages.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -class EditVehicle extends StatefulWidget { - final Alquiler vehiculo; - - const EditVehicle({super.key, required this.vehiculo}); - - @override - State createState() => _EditVehicleState(); -} - -class _EditVehicleState extends State { - final TextEditingController _nombreComercial = TextEditingController(); - final TextEditingController _direccion = TextEditingController(); - final TextEditingController _telefono = TextEditingController(); - final TextEditingController _correo = TextEditingController(); - final TextEditingController _montoAlquiler = TextEditingController(); - final TextEditingController _fechaReserva = TextEditingController(); - final TextEditingController _fechaTrabajo = TextEditingController(); - - String? selectTipoVehiculo; - List tiposVehiculo = [ - 'Camioneta', - 'Camión', - 'Auto', - 'Van', - ]; - - String? selectPersonalAsistio; - List empleados = []; - - final AlquilerViewModel _alquilerViewModel = sl(); - final EmpleadoViewmodel _empleadoViewModel = sl(); - - @override - void initState() { - super.initState(); - _loadVehicleData(); - _loadEmpleados(); - } - - void _loadEmpleados() async { - empleados = await _empleadoViewModel.obtenerEmpleados(); - setState(() {}); - } - - void _loadVehicleData() { - _nombreComercial.text = widget.vehiculo.nombreComercial; - _direccion.text = widget.vehiculo.direccion; - _telefono.text = widget.vehiculo.telefono; - _correo.text = widget.vehiculo.correo; - _montoAlquiler.text = widget.vehiculo.montoAlquiler.toString(); - _fechaReserva.text = - DateFormat('dd/MM/yyyy').format(widget.vehiculo.fechaReserva); - _fechaTrabajo.text = - DateFormat('dd/MM/yyyy').format(widget.vehiculo.fechaTrabajo); - selectTipoVehiculo = widget.vehiculo.tipoVehiculo; - selectPersonalAsistio = widget.vehiculo.personalAsistio; - } - - Future _selectDate(BuildContext context, - TextEditingController controller, DateTime initialDate) async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: initialDate, - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - if (picked != null) { - final formattedDate = DateFormat('dd/MM/yyyy').format(picked); - setState(() { - controller.text = formattedDate; - }); - } - } - - void _updateVehicle() async { - if (_nombreComercial.text.isEmpty || - _direccion.text.isEmpty || - _telefono.text.isEmpty || - _correo.text.isEmpty || - _montoAlquiler.text.isEmpty || - selectPersonalAsistio == null || - _fechaReserva.text.isEmpty || - _fechaTrabajo.text.isEmpty || - selectTipoVehiculo == null) { - FlashMessages.showWarning( - context: context, - message: 'Por favor complete todos los campos', - ); - return; - } - - try { - final updatedVehicle = Alquiler( - id: widget.vehiculo.id, - nombreComercial: _nombreComercial.text.trim(), - direccion: _direccion.text.trim(), - telefono: _telefono.text.trim(), - correo: _correo.text.trim(), - tipoVehiculo: selectTipoVehiculo!, - fechaReserva: DateFormat('dd/MM/yyyy').parse(_fechaReserva.text), - fechaTrabajo: DateFormat('dd/MM/yyyy').parse(_fechaTrabajo.text), - montoAlquiler: double.tryParse(_montoAlquiler.text.trim()) ?? 0, - personalAsistio: selectPersonalAsistio!, - ); - - await _alquilerViewModel.actualizarAlquiler(updatedVehicle); - - FlashMessages.showSuccess( - context: context, - message: 'Vehículo actualizado exitosamente', - ); - - Navigator.pop(context); - } catch (e) { - FlashMessages.showError( - context: context, - message: 'Error al actualizar el vehículo: $e', - ); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.backgroundColor, - appBar: AppBar( - title: const Text('Editar Vehículo'), - backgroundColor: AppColors.primaryColor, - foregroundColor: AppColors.whiteColor, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.3), - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Column( - children: [ - Text( - 'Editar Información de Vehículo', - style: AppFonts.titleBold.copyWith( - color: AppColors.primaryColor, - ), - ), - const SizedBox(height: 30), - _buildForm(), - const SizedBox(height: 30), - BtnElevated( - text: 'Actualizar Vehículo', - onPressed: _updateVehicle, - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildForm() { - return Column( - children: [ - _buildTextField('Nombre Comercial', _nombreComercial, - 'Ingrese el nombre comercial'), - const SizedBox(height: 15), - _buildTextField('Dirección', _direccion, 'Ingrese la dirección'), - const SizedBox(height: 15), - _buildTextField('Teléfono', _telefono, 'Ingrese el teléfono'), - const SizedBox(height: 15), - _buildTextField('Correo', _correo, 'Ingrese el correo electrónico'), - const SizedBox(height: 15), - _buildDropdown('Tipo de Vehículo', selectTipoVehiculo, tiposVehiculo, - (value) { - setState(() { - selectTipoVehiculo = value; - }); - }), - const SizedBox(height: 15), - GestureDetector( - onTap: () => - _selectDate(context, _fechaReserva, widget.vehiculo.fechaReserva), - child: AbsorbPointer( - child: _buildTextField('Fecha de Reserva', _fechaReserva, - 'Seleccione la fecha de reserva'), - ), - ), - const SizedBox(height: 15), - GestureDetector( - onTap: () => - _selectDate(context, _fechaTrabajo, widget.vehiculo.fechaTrabajo), - child: AbsorbPointer( - child: _buildTextField('Fecha de Trabajo', _fechaTrabajo, - 'Seleccione la fecha de trabajo'), - ), - ), - const SizedBox(height: 15), - _buildTextField( - 'Monto de Alquiler', _montoAlquiler, 'Ingrese el monto'), - const SizedBox(height: 15), - _buildEmployeeDropdown( - 'Personal que Asistió', selectPersonalAsistio, empleados, (value) { - setState(() { - selectPersonalAsistio = value; - }); - }), - ], - ); - } - - Widget _buildTextField( - String label, TextEditingController controller, String hint) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: AppFonts.bodyNormal.copyWith( - color: AppColors.textColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: controller, - decoration: InputDecoration( - hintText: hint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: AppColors.greyColor.withOpacity(0.3)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: AppColors.greyColor.withOpacity(0.3)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppColors.primaryColor), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 15, vertical: 12), - ), - ), - ], - ); - } - - Widget _buildDropdown(String label, String? value, List items, - ValueChanged onChanged) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: AppFonts.bodyNormal.copyWith( - color: AppColors.textColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.greyColor.withOpacity(0.3)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: 15), - hint: Text( - 'Seleccione $label', - style: AppFonts.text.copyWith( - color: AppColors.greyColor, - ), - ), - items: items.map((String item) { - return DropdownMenuItem( - value: item, - child: Text( - item, - style: AppFonts.text.copyWith( - color: AppColors.textColor, - ), - ), - ); - }).toList(), - onChanged: onChanged, - ), - ), - ), - ], - ); - } - - Widget _buildEmployeeDropdown(String label, String? value, - List employees, ValueChanged onChanged) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: AppFonts.bodyNormal.copyWith( - color: AppColors.textColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.greyColor.withOpacity(0.3)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: 15), - hint: Text( - 'Seleccione $label', - style: AppFonts.text.copyWith( - color: AppColors.greyColor, - ), - ), - items: employees.map((Empleado empleado) { - return DropdownMenuItem( - value: empleado.nombreCompleto, - child: Text( - empleado.nombreCompletoConCargo, - style: AppFonts.text.copyWith( - color: AppColors.textColor, - ), - ), - ); - }).toList(), - onChanged: onChanged, - ), - ), - ), - ], - ); - } -} From e9a7a4d9cbda1fa9228cb63e5191fe5f8108cae5 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:58:39 -0500 Subject: [PATCH 10/20] feat: add password field to Empleado model and update CambiarPasswordScreen to use AuthViewModel for password change --- lib/models/empleado.dart | 4 + lib/view/auth/cambiar_password_screen.dart | 148 +++++++++++---------- test/README.md | 147 -------------------- 3 files changed, 85 insertions(+), 214 deletions(-) delete mode 100644 test/README.md diff --git a/lib/models/empleado.dart b/lib/models/empleado.dart index bc978d3..90f7281 100644 --- a/lib/models/empleado.dart +++ b/lib/models/empleado.dart @@ -47,6 +47,7 @@ class Empleado { final CargoEmpleado cargo; final DateTime fechaContratacion; final String fotoUrl; + final String password; Empleado({ this.id, @@ -59,6 +60,7 @@ class Empleado { required this.cargo, required this.fechaContratacion, this.fotoUrl = '', + required this.password, }); Map toMap() { @@ -72,6 +74,7 @@ class Empleado { 'cargo': cargo.displayName, 'fechaContratacion': fechaContratacion, 'fotoUrl': fotoUrl, + 'password': password, }; } @@ -87,6 +90,7 @@ class Empleado { cargo: CargoEmpleado.fromString(map['cargo'] ?? ''), fechaContratacion: (map['fechaContratacion'] as Timestamp).toDate(), fotoUrl: map['fotoUrl'] ?? '', + password: map['password'] ?? '', ); } diff --git a/lib/view/auth/cambiar_password_screen.dart b/lib/view/auth/cambiar_password_screen.dart index 232023a..c1b8bfd 100644 --- a/lib/view/auth/cambiar_password_screen.dart +++ b/lib/view/auth/cambiar_password_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:firebase_auth/firebase_auth.dart'; +import 'package:provider/provider.dart'; import 'package:client_service/view/auth/login_empleado_screen.dart'; +import 'package:client_service/viewmodel/auth_viewmodel.dart'; class CambiarPasswordScreen extends StatefulWidget { final String cedula; @@ -29,21 +30,30 @@ class _CambiarPasswordScreenState extends State { if (!_formKey.currentState!.validate()) return; setState(() => _isLoading = true); try { - final user = FirebaseAuth.instance.currentUser; - if (user == null) throw Exception('No autenticado'); - await user.updatePassword(_passwordController.text); - await FirebaseAuth.instance.signOut(); - if (mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const LoginEmpleadoScreen()), - (route) => false, - ); + final viewModel = Provider.of(context, listen: false); + final resultado = await viewModel.cambiarPassword( + cedula: widget.cedula, + nuevaPassword: _passwordController.text, + ); + if (resultado['success']) { + if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const LoginEmpleadoScreen()), + (route) => false, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Contraseña actualizada. Inicie sesión con su nueva contraseña.'), + backgroundColor: Colors.green), + ); + } + } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Contraseña actualizada. Inicie sesión con su nueva contraseña.'), - backgroundColor: Colors.green), + SnackBar( + content: Text(resultado['message'] ?? 'Error'), + backgroundColor: Colors.red), ); } } catch (e) { @@ -57,62 +67,66 @@ class _CambiarPasswordScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Cambiar Contraseña')), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Nueva Contraseña', - style: - TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), - const SizedBox(height: 32), - TextFormField( - controller: _passwordController, - decoration: InputDecoration( - labelText: 'Nueva contraseña', - suffixIcon: IconButton( - icon: Icon(_obscurePassword - ? Icons.visibility - : Icons.visibility_off), - onPressed: () => - setState(() => _obscurePassword = !_obscurePassword), + return ChangeNotifierProvider( + create: (_) => AuthViewModel(), + child: Scaffold( + appBar: AppBar(title: const Text('Cambiar Contraseña')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Nueva Contraseña', + style: + TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), + const SizedBox(height: 32), + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Nueva contraseña', + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), ), + obscureText: _obscurePassword, + validator: (v) => v == null || v.length < 6 + ? 'Mínimo 6 caracteres' + : null, ), - obscureText: _obscurePassword, - validator: (v) => - v == null || v.length < 6 ? 'Mínimo 6 caracteres' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmController, - decoration: InputDecoration( - labelText: 'Confirmar contraseña', - suffixIcon: IconButton( - icon: Icon(_obscureConfirm - ? Icons.visibility - : Icons.visibility_off), - onPressed: () => - setState(() => _obscureConfirm = !_obscureConfirm), + const SizedBox(height: 16), + TextFormField( + controller: _confirmController, + decoration: InputDecoration( + labelText: 'Confirmar contraseña', + suffixIcon: IconButton( + icon: Icon(_obscureConfirm + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => + setState(() => _obscureConfirm = !_obscureConfirm), + ), ), + obscureText: _obscureConfirm, + validator: (v) => v != _passwordController.text + ? 'Las contraseñas no coinciden' + : null, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _isLoading ? null : _cambiarPassword, + child: _isLoading + ? const CircularProgressIndicator() + : const Text('Confirmar'), ), - obscureText: _obscureConfirm, - validator: (v) => v != _passwordController.text - ? 'Las contraseñas no coinciden' - : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _isLoading ? null : _cambiarPassword, - child: _isLoading - ? const CircularProgressIndicator() - : const Text('Confirmar'), - ), - ], + ], + ), ), ), ), diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 1dc9aef..0000000 --- a/test/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# Tests de LightViate - -Esta carpeta contiene todos los tests del proyecto LightViate, organizados por categorías para facilitar el mantenimiento y la ejecución. - -## Estructura de Carpetas - -``` -test/ -├── crud/ # Tests de casos de uso CRUD -│ ├── cliente_crud_test.dart -│ ├── empleado_crud_test.dart -│ ├── factura_crud_test.dart -│ ├── camara_crud_test.dart -│ ├── vehiculo_crud_test.dart -│ └── instalacion_crud_test.dart -├── views/ # Tests de pantallas/views -│ ├── login_admin_screen_test.dart -│ ├── login_empleado_screen_test.dart -│ ├── register_client_screen_test.dart -│ ├── edit_client_screen_test.dart -│ ├── register_employet_screen_test.dart -│ ├── edit_employet_screen_test.dart -│ ├── register_camara_screen_test.dart -│ ├── edit_camara_screen_test.dart -│ ├── register_installation_screen_test.dart -│ ├── edit_installation_screen_test.dart -│ ├── register_vehicle_screen_test.dart -│ ├── edit_vehicle_screen_test.dart -│ ├── create_factura_screen_test.dart -│ ├── edit_factura_screen_test.dart -│ └── anular_factura_screen_test.dart -├── viewmodels/ # Tests de ViewModels -│ ├── camara_viewmodel_test.dart -│ └── calendario_viewmodel_test.dart -├── services/ # Tests de servicios -│ ├── auth_service_test.dart -│ └── notificacion_service_test.dart -├── repositories/ # Tests de repositorios -│ ├── camara_repository_test.dart -│ └── instalacion_repository_test.dart -├── utils/ # Tests de utilidades -│ └── excel_export_utility_test.dart -├── mocks.dart # Configuración de mocks -├── mocks.mocks.dart # Mocks generados automáticamente -├── test_setup.dart # Configuración de tests -└── widget_test.dart # Test principal de widget -``` - -## Ejecución de Tests - -### Ejecutar todos los tests - -```bash -flutter test -``` - -### Ejecutar tests por categoría - -```bash -# Tests de CRUD (casos de uso) -flutter test test/crud/ - -# Tests de Views (pantallas) -flutter test test/views/ - -# Tests de ViewModels -flutter test test/viewmodels/ - -# Tests de Servicios -flutter test test/services/ - -# Tests de Repositorios -flutter test test/repositories/ - -# Tests de Utilidades -flutter test test/utils/ -``` - -### Ejecutar tests específicos - -```bash -# Test específico de cliente -flutter test test/crud/cliente_crud_test.dart - -# Test específico de login -flutter test test/views/login_admin_screen_test.dart -``` - -## Configuración de Mocks - -Los tests utilizan **mockito** con `@GenerateMocks` para crear mocks automáticamente: - -1. **Archivo de configuración**: `test/mocks.dart` -2. **Mocks generados**: `test/mocks.mocks.dart` - -### Regenerar mocks - -Si agregas nuevos repositorios o servicios, regenera los mocks: - -```bash -flutter pub run build_runner build -``` - -## Casos de Prueba Implementados - -### CRUD Tests (29 tests) - -- ✅ **Cliente**: Registrar, editar, eliminar, listar -- ✅ **Empleado**: Registrar, editar, eliminar, listar, verificar administrador -- ✅ **Factura**: Registrar, cancelar, listar, eliminar, calcular totales -- ✅ **Cámara**: Registrar mantenimiento, editar, cancelar, listar, eliminar -- ✅ **Vehículo**: Registrar alquiler, actualizar, cancelar, listar, eliminar -- ✅ **Instalación**: Registrar poste, editar, cancelar, listar, eliminar - -### ViewModel Tests (2 tests) - -- ✅ **CamaraViewModel**: Inicialización -- ✅ **CalendarioViewModel**: Filtrado de eventos - -### Service Tests (2 tests) - -- ✅ **AuthService**: Autenticación -- ✅ **NotificacionService**: Notificaciones - -### Repository Tests (2 tests) - -- ✅ **CamaraRepository**: Operaciones básicas -- ✅ **InstalacionRepository**: Operaciones básicas - -### Utils Tests (1 test) - -- ⚠️ **ExcelExportUtility**: Exportación (falla en entorno de test por descarga de archivos) - -## Notas Importantes - -1. **Mocks**: Todos los tests usan mocks generados automáticamente para evitar problemas de null safety -2. **Dependencias**: Los tests están configurados para usar `GetIt` para inyección de dependencias -3. **Excel Tests**: Los tests de exportación a Excel pueden fallar en CI/CD porque requieren descarga de archivos -4. **Organización**: Los tests están organizados por funcionalidad para facilitar el mantenimiento - -## Agregar Nuevos Tests - -1. **Identifica la categoría** del test (crud, views, viewmodels, services, repositories, utils) -2. **Crea el archivo** en la carpeta correspondiente -3. **Usa los mocks generados** importando `../mocks.mocks.dart` -4. **Sigue el patrón** de los tests existentes -5. **Ejecuta los tests** para verificar que funcionan From abdbc275891f13b907519f7ecc0792c7d864bb09 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:59:06 -0500 Subject: [PATCH 11/20] feat: refactor login screens to use AuthViewModel for authentication and improve error handling --- lib/view/auth/login_admin_screen.dart | 217 +++++++++-------------- lib/view/auth/login_empleado_screen.dart | 145 ++++----------- lib/view/auth/logout_widget.dart | 168 +----------------- lib/viewmodel/auth_viewmodel.dart | 83 +++++++++ 4 files changed, 213 insertions(+), 400 deletions(-) create mode 100644 lib/viewmodel/auth_viewmodel.dart diff --git a/lib/view/auth/login_admin_screen.dart b/lib/view/auth/login_admin_screen.dart index 1055175..6bc8c86 100644 --- a/lib/view/auth/login_admin_screen.dart +++ b/lib/view/auth/login_admin_screen.dart @@ -1,78 +1,103 @@ import 'package:flutter/material.dart'; -import 'package:client_service/services/auth_service.dart'; +import 'package:client_service/viewmodel/auth_viewmodel.dart'; import 'package:client_service/view/home/view.dart'; import 'package:client_service/view/widgets/auth/login_card.dart'; +import 'package:client_service/models/empleado.dart'; +import 'package:client_service/view/auth/cambiar_password_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:client_service/providers/empleado_provider.dart'; -class LoginAdminScreen extends StatefulWidget { +class LoginAdminScreen extends StatelessWidget { const LoginAdminScreen({super.key}); @override - State createState() => _LoginAdminScreenState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => AuthViewModel(), + child: const _LoginAdminScreenBody(), + ); + } +} + +class _LoginAdminScreenBody extends StatefulWidget { + const _LoginAdminScreenBody(); + + @override + State<_LoginAdminScreenBody> createState() => _LoginAdminScreenBodyState(); } -class _LoginAdminScreenState extends State { +class _LoginAdminScreenBodyState extends State<_LoginAdminScreenBody> { bool _isLoading = false; + void _mostrarError(String mensaje) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(mensaje), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + Future _iniciarSesion(String email, String password) async { setState(() { _isLoading = true; }); - - try { - final resultado = await AuthService.iniciarSesion( - email: email, - password: password, - ); - - if (resultado['success']) { - final user = resultado['user']; - if (user != null) { - // Si el email es de empleado (termina en @empleado.com) - if (user.email != null && user.email!.endsWith('@empleado.com')) { - // Si es primer login (password == cédula), redirigir a cambio de contraseña - // (Esto requiere lógica adicional en AuthService para detectar primer login) - // Por ahora, redirigir a la pantalla de cambio de contraseña si es necesario - // Si no, redirigir a la pantalla principal de empleado - // Ejemplo: - // Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => CambiarPasswordScreen())); - // Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => EmpleadoHomeScreen())); - } else { - // Administradora - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const HomePage()), - (route) => false, - ); - } + final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+\u0000*'); + if (!emailRegex.hasMatch(email)) { + _mostrarError('Ingrese un correo válido.'); + setState(() => _isLoading = false); + return; + } + final viewModel = Provider.of(context, listen: false); + final resultado = + await viewModel.loginEmpleado(correo: email, password: password); + if (resultado['success']) { + final empleado = resultado['empleado']; + final primerLogin = resultado['primerLogin'] ?? false; + if (empleado != null) { + if (empleado.cargo != CargoEmpleado.administrador) { + _mostrarError( + 'Solo usuarios con cargo de Administrador pueden acceder aquí.'); + setState(() => _isLoading = false); + return; } + Provider.of(context, listen: false) + .setEmpleado(empleado); + } + if (empleado != null && primerLogin) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => CambiarPasswordScreen(cedula: empleado.cedula), + ), + ); } else { - _mostrarError(resultado['message']); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const HomePage()), + (route) => false, + ); } - } catch (e) { - _mostrarError('Error inesperado: $e'); - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); + } else { + final msg = resultado['message']?.toString().toLowerCase() ?? ''; + if (msg.contains('no encontrado') || msg.contains('not found')) { + _mostrarError('No se encontró una cuenta con ese correo.'); + } else if (msg.contains('contraseña') || msg.contains('password')) { + _mostrarError('Credenciales incorrectas.'); + } else { + _mostrarError('Error al iniciar sesión.'); } } - } - - void _mostrarError(String mensaje) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(mensaje), - backgroundColor: Colors.red, - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.all(16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ); + setState(() { + _isLoading = false; + }); } @override @@ -92,7 +117,6 @@ class _LoginAdminScreenState extends State { padding: const EdgeInsets.all(20), child: Column( children: [ - // Botón de regreso Row( children: [ IconButton( @@ -105,79 +129,12 @@ class _LoginAdminScreenState extends State { ), ], ), - const SizedBox(height: 40), - - // Header con logo y título - const Column( - children: [ - // Título LIGHT VITAE más grande - Text( - 'LIGHT VITAE', - style: TextStyle( - fontSize: 48, - color: Color(0xFF8962F8), - letterSpacing: 3, - fontWeight: FontWeight.w900, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(2, 2), - blurRadius: 4, - ), - ], - ), - ), - - SizedBox(height: 10), - - // Subtítulo SERVICE - Text( - 'SERVICE', - style: TextStyle( - fontSize: 24, - color: Color(0xFF8962F8), - letterSpacing: 2, - fontWeight: FontWeight.w700, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(1, 1), - blurRadius: 2, - ), - ], - ), - ), - - SizedBox(height: 20), - - // Tipo de usuario - Text( - 'Administrador', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 20, - shadows: [ - Shadow( - color: Colors.black54, - offset: Offset(1, 1), - blurRadius: 3, - ), - ], - ), - ), - ], - ), - - const SizedBox(height: 80), - - // Formulario flotante Container( width: double.infinity, padding: const EdgeInsets.all(35), decoration: BoxDecoration( - color: Colors.white, + color: Colors.white.withOpacity(0.95), borderRadius: BorderRadius.circular(25), boxShadow: [ BoxShadow( @@ -194,10 +151,7 @@ class _LoginAdminScreenState extends State { showFormOnly: true, ), ), - const SizedBox(height: 40), - - // Enlaces adicionales TextButton( onPressed: () { showDialog( @@ -231,16 +185,13 @@ class _LoginAdminScreenState extends State { ), ), ), - const SizedBox(height: 20), - - // Footer - Text( + const Text( '© 2025 Light Vitae', style: TextStyle( fontSize: 12, - color: Colors.white.withOpacity(0.8), - shadows: const [ + color: Colors.white70, + shadows: [ Shadow( color: Colors.black54, offset: Offset(1, 1), diff --git a/lib/view/auth/login_empleado_screen.dart b/lib/view/auth/login_empleado_screen.dart index 952d45d..dca6a1c 100644 --- a/lib/view/auth/login_empleado_screen.dart +++ b/lib/view/auth/login_empleado_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:client_service/services/auth_service.dart'; -import 'package:client_service/view/calendar/calendario_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:client_service/viewmodel/auth_viewmodel.dart'; +import 'package:client_service/providers/empleado_provider.dart'; import 'package:client_service/view/auth/cambiar_password_screen.dart'; -import 'package:client_service/view/asistencia/asistencia_screen.dart'; +import 'package:client_service/view/panel_empleado/panel_empleado_screen.dart'; +import 'package:client_service/view/auth/login_selection_screen.dart'; +import 'package:client_service/view/widgets/auth/login_card.dart'; class LoginEmpleadoScreen extends StatefulWidget { const LoginEmpleadoScreen({super.key}); @@ -12,134 +15,64 @@ class LoginEmpleadoScreen extends StatefulWidget { } class _LoginEmpleadoScreenState extends State { - final _formKey = GlobalKey(); - final _cedulaController = TextEditingController(); - final _passwordController = TextEditingController(); bool _isLoading = false; - bool _obscurePassword = true; - @override - void dispose() { - _cedulaController.dispose(); - _passwordController.dispose(); - super.dispose(); + void _mostrarError(String mensaje) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mensaje), backgroundColor: Colors.red), + ); } - Future _iniciarSesion() async { - if (!_formKey.currentState!.validate()) return; + Future _iniciarSesion(String correo, String cedula) async { setState(() => _isLoading = true); - final cedula = _cedulaController.text.trim(); - final password = _passwordController.text; - final email = '$cedula@empleado.com'; + final viewModel = Provider.of(context, listen: false); final resultado = - await AuthService.iniciarSesion(email: email, password: password); - setState(() => _isLoading = false); + await viewModel.loginEmpleado(correo: correo, password: cedula); if (resultado['success']) { - // Si es primer login (password == cedula), forzar cambio de contraseña - if (password == cedula) { + final empleado = resultado['empleado']; + if (empleado != null) { + Provider.of(context, listen: false) + .setEmpleado(empleado); + } + if (resultado['primerLogin'] == true) { Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => CambiarPasswordScreen(cedula: cedula)), ); } else { - // Ir a la pantalla principal de empleado (calendario + asistencia) + // Navegar al panel de empleado Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const EmpleadoHomeScreen()), - ); - } + context, + MaterialPageRoute( + builder: (_) => PanelEmpleadoScreen(empleado: empleado)), + ); + } + } else { + final msg = resultado['message']?.toString().toLowerCase() ?? ''; + if (msg.contains('no encontrado') || msg.contains('not found')) { + _mostrarError('No se encontró una cuenta con ese correo.'); + } else if (msg.contains('contraseña') || msg.contains('password')) { + _mostrarError('Credenciales incorrectas.'); } else { - _mostrarError(resultado['message']); + _mostrarError(resultado['message'] ?? 'Error al iniciar sesión.'); + } } - } - - void _mostrarError(String mensaje) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(mensaje), backgroundColor: Colors.red), - ); + setState(() => _isLoading = false); } @override Widget build(BuildContext context) { return Scaffold( body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Login Empleado', - style: - TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 32), - TextFormField( - controller: _cedulaController, - decoration: const InputDecoration(labelText: 'Cédula'), - keyboardType: TextInputType.number, - validator: (v) => - v == null || v.isEmpty ? 'Ingrese su cédula' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: InputDecoration( - labelText: 'Contraseña', - suffixIcon: IconButton( - icon: Icon(_obscurePassword - ? Icons.visibility - : Icons.visibility_off), - onPressed: () => - setState(() => _obscurePassword = !_obscurePassword), - ), - ), - obscureText: _obscurePassword, - validator: (v) => - v == null || v.isEmpty ? 'Ingrese su contraseña' : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _isLoading ? null : _iniciarSesion, - child: _isLoading - ? const CircularProgressIndicator() - : const Text('Ingresar'), - ), - ], - ), + child: SingleChildScrollView( + child: LoginCard( + userType: 'Empleado', + isLoading: _isLoading, + onLogin: (correo, cedula) => _iniciarSesion(correo, cedula), ), ), ), ); } } - -// Pantalla principal de empleado: calendario + asistencia -class EmpleadoHomeScreen extends StatelessWidget { - const EmpleadoHomeScreen({super.key}); - - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: const Text('Panel Empleado'), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.calendar_today), text: 'Servicios'), - Tab(icon: Icon(Icons.access_time), text: 'Asistencia'), - ], - ), - ), - body: const TabBarView( - children: [ - CalendarioScreen(), - AsistenciaScreen(), - ], - ), - ), - ); - } -} diff --git a/lib/view/auth/logout_widget.dart b/lib/view/auth/logout_widget.dart index 599697a..cb17ebc 100644 --- a/lib/view/auth/logout_widget.dart +++ b/lib/view/auth/logout_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:client_service/services/auth_service.dart'; import 'package:client_service/view/auth/login_selection_screen.dart'; class LogoutWidget extends StatelessWidget { @@ -67,9 +66,6 @@ class LogoutWidget extends StatelessWidget { } void _cerrarSesion(BuildContext context) { - // Cerrar sesión - AuthService.cerrarSesion(); - // Navegar a la pantalla de login Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( @@ -96,163 +92,13 @@ class LogoutWidget extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: child, - floatingActionButton: AuthService.tieneUsuarioActivo - ? FloatingActionButton( - onPressed: () => _mostrarDialogoLogout(context), - backgroundColor: Colors.red[600], - child: const Icon( - Icons.logout, - color: Colors.white, - ), - ) - : null, - ); - } -} - -// Widget para mostrar información del usuario logueado -class UserInfoWidget extends StatelessWidget { - const UserInfoWidget({super.key}); - - @override - Widget build(BuildContext context) { - final user = AuthService.usuarioActual; - - if (user == null) return const SizedBox.shrink(); - - // Determine role based on email (simple heuristic) - final isAdmin = - user.email != null && !user.email!.endsWith('@empleado.com'); - final displayName = - user.displayName ?? (isAdmin ? 'Administradora' : 'Empleado'); - final email = user.email ?? 'Sin correo'; - final uid = user.uid; - final role = isAdmin ? 'Administradora' : 'Empleado'; - - return Container( - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 5), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isAdmin - ? Colors.purple.withOpacity(0.1) - : Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - isAdmin ? Icons.admin_panel_settings : Icons.person, - color: isAdmin ? Colors.purple : Colors.blue, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayName, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - role, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 2), - Text( - email, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 2), - Text( - 'UID: $uid', - style: TextStyle( - fontSize: 10, - color: Colors.grey[400], - ), - ), - ], - ), - ), - IconButton( - onPressed: () { - // Mostrar más información del usuario - showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - title: const Text('Información de la Cuenta'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow('Nombre:', displayName), - _buildInfoRow('Correo:', email), - _buildInfoRow('Rol:', role), - _buildInfoRow('UID:', uid), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cerrar'), - ), - ], - ), - ); - }, - icon: const Icon(Icons.info_outline), - ), - ], - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Text(value), - ), - ], + floatingActionButton: FloatingActionButton( + onPressed: () => _mostrarDialogoLogout(context), + backgroundColor: Colors.red[600], + child: const Icon( + Icons.logout, + color: Colors.white, + ), ), ); } diff --git a/lib/viewmodel/auth_viewmodel.dart b/lib/viewmodel/auth_viewmodel.dart new file mode 100644 index 0000000..48dc024 --- /dev/null +++ b/lib/viewmodel/auth_viewmodel.dart @@ -0,0 +1,83 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:client_service/models/empleado.dart'; +import 'package:flutter/material.dart'; + +class AuthViewModel extends ChangeNotifier { + bool _isLoading = false; + String? _errorMessage; + Map? _empleadoData; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + Map? get empleadoData => _empleadoData; + + Future> loginEmpleado({ + required String correo, + required String password, + }) async { + _setLoading(true); + // Buscar empleado en Firestore por correo + final query = await FirebaseFirestore.instance + .collection('empleados') + .where('correo', isEqualTo: correo.trim().toLowerCase()) + .limit(1) + .get(); + _setLoading(false); + if (query.docs.isEmpty) { + _errorMessage = 'Empleado no encontrado'; + _empleadoData = null; + notifyListeners(); + return {'success': false, 'message': 'Empleado no encontrado'}; + } + final empleado = + Empleado.fromMap(query.docs.first.data(), query.docs.first.id); + if (empleado.password == password) { + final primerLogin = password == empleado.cedula; + _errorMessage = null; + _empleadoData = {'empleado': empleado, 'primerLogin': primerLogin}; + notifyListeners(); + return { + 'success': true, + 'empleado': empleado, + 'primerLogin': primerLogin + }; + } else { + _errorMessage = 'Contraseña incorrecta'; + _empleadoData = null; + notifyListeners(); + return {'success': false, 'message': 'Contraseña incorrecta'}; + } + } + + Future> cambiarPassword({ + required String cedula, + required String nuevaPassword, + }) async { + try { + final query = await FirebaseFirestore.instance + .collection('empleados') + .where('cedula', isEqualTo: cedula.trim()) + .limit(1) + .get(); + if (query.docs.isEmpty) { + return {'success': false, 'message': 'Empleado no encontrado'}; + } + await query.docs.first.reference.update({'password': nuevaPassword}); + return { + 'success': true, + 'message': 'Contraseña actualizada correctamente' + }; + } catch (e) { + return {'success': false, 'message': 'Error al cambiar contraseña: $e'}; + } + } + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } +} From b954fd160a913ee5096befdee19c82ebfc2426df Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:59:21 -0500 Subject: [PATCH 12/20] feat: update Apptitle and Toolbar widgets for improved layout and navigation handling --- lib/view/widgets/shared/apptitle.dart | 61 ++++++++++++++------------- lib/view/widgets/shared/toolbar.dart | 36 ++++++++++++---- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/lib/view/widgets/shared/apptitle.dart b/lib/view/widgets/shared/apptitle.dart index 5493b10..e5c5004 100644 --- a/lib/view/widgets/shared/apptitle.dart +++ b/lib/view/widgets/shared/apptitle.dart @@ -11,37 +11,38 @@ class Apptitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - children: [ - Container( - margin: - const EdgeInsets.only(top: 10, left: 20, right: 10, bottom: 10), - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: AppColors.backgroundColor, - ), - child: BtnIcon( - onPressed: () { - Navigator.pop(context); - }, - icon: Icons.arrow_back_ios_new_rounded), - ), - const SizedBox(width: 10), - Text(title, style: AppFonts.subtitleBold), - const Spacer(), - Visibility( - visible: isVisible, - child: Container( - margin: const EdgeInsets.only(right: 20), - child: BtnIcon( - onPressed: () {}, - icon: Icons.delete_rounded, + return SafeArea( + bottom: false, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + BtnIcon( + onPressed: () => Navigator.pop(context), + icon: Icons.arrow_back_ios_new_rounded, + bg: AppColors.backgroundColor, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: AppFonts.subtitleBold, + overflow: TextOverflow.ellipsis, + ), ), - ), - ) - ], + if (isVisible) + Container( + margin: const EdgeInsets.only(left: 8), + child: BtnIcon( + onPressed: () {}, + icon: Icons.delete_rounded, + ), + ), + ], + ), + ), ); } } diff --git a/lib/view/widgets/shared/toolbar.dart b/lib/view/widgets/shared/toolbar.dart index e09922e..f15da8c 100644 --- a/lib/view/widgets/shared/toolbar.dart +++ b/lib/view/widgets/shared/toolbar.dart @@ -1,5 +1,8 @@ import 'package:client_service/utils/colors.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:client_service/providers/empleado_provider.dart'; +import 'package:client_service/view/calendar/calendario_screen.dart'; class Toolbar extends StatefulWidget { const Toolbar({super.key}); @@ -12,13 +15,13 @@ class _ToolbarState extends State { @override Widget build(BuildContext context) { return Container( - height: 70, + height: 64, decoration: const BoxDecoration( borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), topRight: Radius.circular(20)), + topLeft: Radius.circular(24), topRight: Radius.circular(24)), color: AppColors.primaryColor, ), - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 40), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -28,15 +31,30 @@ class _ToolbarState extends State { color: AppColors.whiteColor, ), IconButton( - onPressed: () => Navigator.pushNamed(context, 'calendario'), + onPressed: () { + final empleado = + Provider.of(context, listen: false) + .empleado; + if (empleado != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CalendarioScreen(empleado: empleado), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No hay usuario autenticado para mostrar el calendario.'), + backgroundColor: Colors.red, + ), + ); + } + }, icon: const Icon(Icons.calendar_month, size: 30), color: AppColors.secondaryColor, ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.search, size: 30), - color: AppColors.secondaryColor, - ), IconButton( onPressed: () {}, icon: const Icon(Icons.person, size: 30), From 10cc1a15e3ea3faa332b918c435738806af8ed38 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 21:59:40 -0500 Subject: [PATCH 13/20] feat: update login form to use email instead of ID and add email validation; set initial password to ID when adding new employee --- lib/view/widgets/auth/login_card.dart | 19 ++++++++++--------- lib/viewmodel/empleado_viewmodel.dart | 3 ++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/view/widgets/auth/login_card.dart b/lib/view/widgets/auth/login_card.dart index 5d12283..55c8b1a 100644 --- a/lib/view/widgets/auth/login_card.dart +++ b/lib/view/widgets/auth/login_card.dart @@ -169,16 +169,14 @@ class _LoginCardState extends State { ), ), - const SizedBox(height: 45), // Aumenté el espaciado + const SizedBox(height: 45), - // Campo de cédula TextFormField( controller: _emailController, - keyboardType: TextInputType.text, - style: const TextStyle( - fontSize: 16), // Aumenté el tamaño de la fuente + keyboardType: TextInputType.emailAddress, + style: const TextStyle(fontSize: 16), decoration: InputDecoration( - labelText: 'Cédula', + labelText: 'Correo', labelStyle: AppFonts.inputtext.copyWith( color: Colors.grey[600], fontSize: 16, @@ -196,12 +194,15 @@ class _LoginCardState extends State { width: 2, ), ), - contentPadding: const EdgeInsets.symmetric( - vertical: 15), // Más altura en los campos + contentPadding: const EdgeInsets.symmetric(vertical: 15), ), validator: (value) { if (value == null || value.isEmpty) { - return 'Por favor ingresa tu cédula'; + return 'Por favor ingresa tu correo'; + } + final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailRegex.hasMatch(value)) { + return 'Correo inválido'; } return null; }, diff --git a/lib/viewmodel/empleado_viewmodel.dart b/lib/viewmodel/empleado_viewmodel.dart index 484e013..4f66e78 100644 --- a/lib/viewmodel/empleado_viewmodel.dart +++ b/lib/viewmodel/empleado_viewmodel.dart @@ -72,7 +72,7 @@ class EmpleadoViewmodel extends BaseViewModel { final result = await handleAsyncOperation(() async { final id = await _repository.createWithImage(empleado, imageFile); - // Actualizar lista local con el nuevo empleado + // El password inicial siempre será la cédula final newEmpleado = Empleado( id: id, nombre: empleado.nombre, @@ -84,6 +84,7 @@ class EmpleadoViewmodel extends BaseViewModel { cargo: empleado.cargo, fechaContratacion: empleado.fechaContratacion, fotoUrl: empleado.fotoUrl, + password: empleado.cedula, ); _empleados.add(newEmpleado); From 49e15dae5c60f634597ea0d944adbc808e3cfb54 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:00:25 -0500 Subject: [PATCH 14/20] feat: update employee registration and editing to include password handling; enhance calendar view model with employee identification --- .../registers/employet/edit_employet.dart | 1 + .../registers/employet/register_employet.dart | 1 + .../service/vehicle_rental/edit_vehicle.dart | 357 ++++++++++++------ lib/viewmodel/calendario_viewmodel.dart | 35 +- 4 files changed, 259 insertions(+), 135 deletions(-) diff --git a/lib/view/registers/employet/edit_employet.dart b/lib/view/registers/employet/edit_employet.dart index 1c04c27..d44c619 100644 --- a/lib/view/registers/employet/edit_employet.dart +++ b/lib/view/registers/employet/edit_employet.dart @@ -128,6 +128,7 @@ class _EditEmpleadoPageState extends State { cargo: CargoEmpleado.fromString(selectValue!), fechaContratacion: fecha, fotoUrl: widget.empleado.fotoUrl, + password: widget.empleado.password, // Mantener el password actual ); final success = await _empleadoVM.actualizarEmpleado( diff --git a/lib/view/registers/employet/register_employet.dart b/lib/view/registers/employet/register_employet.dart index 9e7f6c0..89dc48b 100644 --- a/lib/view/registers/employet/register_employet.dart +++ b/lib/view/registers/employet/register_employet.dart @@ -343,6 +343,7 @@ class _RegistroEmpleadoPageState extends State { cargo: CargoEmpleado.fromString(selectValue!), fechaContratacion: fecha, fotoUrl: '', + password: _cedula.text.trim(), ); final viewModel = sl(); diff --git a/lib/view/service/vehicle_rental/edit_vehicle.dart b/lib/view/service/vehicle_rental/edit_vehicle.dart index 85b3bdd..fdc9f82 100644 --- a/lib/view/service/vehicle_rental/edit_vehicle.dart +++ b/lib/view/service/vehicle_rental/edit_vehicle.dart @@ -20,6 +20,7 @@ class EditVehicle extends StatefulWidget { } class _EditVehicleState extends State { + final _formKey = GlobalKey(); final TextEditingController _nombreComercial = TextEditingController(); final TextEditingController _direccion = TextEditingController(); final TextEditingController _telefono = TextEditingController(); @@ -142,42 +143,49 @@ class _EditVehicleState extends State { ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), - child: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.3), - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Column( - children: [ - Text( - 'Editar Información de Vehículo', - style: AppFonts.titleBold.copyWith( - color: AppColors.primaryColor, + child: Form( + key: _formKey, + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + children: [ + Text( + 'Editar Información de Vehículo', + style: AppFonts.titleBold.copyWith( + color: AppColors.primaryColor, + ), ), - ), - const SizedBox(height: 30), - _buildForm(), - const SizedBox(height: 30), - BtnElevated( - text: 'Actualizar Vehículo', - onPressed: _updateVehicle, - ), - ], + const SizedBox(height: 30), + _buildForm(), + const SizedBox(height: 30), + BtnElevated( + text: 'Actualizar Vehículo', + onPressed: () { + if (_formKey.currentState!.validate()) { + _updateVehicle(); + } + }, + ), + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -186,28 +194,97 @@ class _EditVehicleState extends State { Widget _buildForm() { return Column( children: [ - _buildTextField('Nombre Comercial', _nombreComercial, - 'Ingrese el nombre comercial'), + _buildTextField( + 'Nombre Comercial', + _nombreComercial, + 'Ingrese el nombre comercial', + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'El nombre comercial es obligatorio'; + } + return null; + }, + ), const SizedBox(height: 15), - _buildTextField('Dirección', _direccion, 'Ingrese la dirección'), + _buildTextField( + 'Dirección', + _direccion, + 'Ingrese la dirección', + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'La dirección es obligatoria'; + } + return null; + }, + ), const SizedBox(height: 15), - _buildTextField('Teléfono', _telefono, 'Ingrese el teléfono'), + _buildTextField( + 'Teléfono', + _telefono, + 'Ingrese el teléfono', + keyboardType: TextInputType.phone, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'El teléfono es obligatorio'; + } + final phoneReg = RegExp(r'^[0-9+\-]{7,15}$'); + if (!phoneReg.hasMatch(value.trim())) { + return 'Teléfono inválido'; + } + return null; + }, + ), const SizedBox(height: 15), - _buildTextField('Correo', _correo, 'Ingrese el correo electrónico'), + _buildTextField( + 'Correo', + _correo, + 'Ingrese el correo electrónico', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'El correo es obligatorio'; + } + final emailReg = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailReg.hasMatch(value.trim())) { + return 'Correo inválido'; + } + return null; + }, + ), const SizedBox(height: 15), - _buildDropdown('Tipo de Vehículo', selectTipoVehiculo, tiposVehiculo, - (value) { - setState(() { - selectTipoVehiculo = value; - }); - }), + _buildDropdown( + 'Tipo de Vehículo', + selectTipoVehiculo, + tiposVehiculo, + (value) { + setState(() { + selectTipoVehiculo = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Seleccione un tipo de vehículo'; + } + return null; + }, + ), const SizedBox(height: 15), GestureDetector( onTap: () => _selectDate(context, _fechaReserva, widget.vehiculo.fechaReserva), child: AbsorbPointer( - child: _buildTextField('Fecha de Reserva', _fechaReserva, - 'Seleccione la fecha de reserva'), + child: _buildTextField( + 'Fecha de Reserva', + _fechaReserva, + 'Seleccione la fecha de reserva', + readOnly: true, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'La fecha de reserva es obligatoria'; + } + return null; + }, + ), ), ), const SizedBox(height: 15), @@ -215,26 +292,65 @@ class _EditVehicleState extends State { onTap: () => _selectDate(context, _fechaTrabajo, widget.vehiculo.fechaTrabajo), child: AbsorbPointer( - child: _buildTextField('Fecha de Trabajo', _fechaTrabajo, - 'Seleccione la fecha de trabajo'), + child: _buildTextField( + 'Fecha de Trabajo', + _fechaTrabajo, + 'Seleccione la fecha de trabajo', + readOnly: true, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'La fecha de trabajo es obligatoria'; + } + return null; + }, + ), ), ), const SizedBox(height: 15), _buildTextField( - 'Monto de Alquiler', _montoAlquiler, 'Ingrese el monto'), + 'Monto de Alquiler', + _montoAlquiler, + 'Ingrese el monto', + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'El monto es obligatorio'; + } + if (double.tryParse(value.trim()) == null) { + return 'Monto inválido'; + } + return null; + }, + ), const SizedBox(height: 15), _buildEmployeeDropdown( - 'Personal que Asistió', selectPersonalAsistio, empleados, (value) { - setState(() { - selectPersonalAsistio = value; - }); - }), + 'Personal que Asistió', + selectPersonalAsistio, + empleados, + (value) { + setState(() { + selectPersonalAsistio = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Seleccione el personal que asistió'; + } + return null; + }, + ), ], ); } Widget _buildTextField( - String label, TextEditingController controller, String hint) { + String label, + TextEditingController controller, + String hint, { + TextInputType? keyboardType, + bool readOnly = false, + String? Function(String?)? validator, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -248,6 +364,8 @@ class _EditVehicleState extends State { const SizedBox(height: 8), TextFormField( controller: controller, + keyboardType: keyboardType, + readOnly: readOnly, decoration: InputDecoration( hintText: hint, border: OutlineInputBorder( @@ -267,13 +385,19 @@ class _EditVehicleState extends State { contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), + validator: validator, ), ], ); } - Widget _buildDropdown(String label, String? value, List items, - ValueChanged onChanged) { + Widget _buildDropdown( + String label, + String? value, + List items, + ValueChanged onChanged, { + String? Function(String?)? validator, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -285,44 +409,49 @@ class _EditVehicleState extends State { ), ), const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.greyColor.withOpacity(0.3)), + DropdownButtonFormField( + value: value, + isExpanded: true, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + BorderSide(color: AppColors.greyColor.withOpacity(0.3)), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 15, vertical: 12), + ), + hint: Text( + 'Seleccione $label', + style: AppFonts.text.copyWith( + color: AppColors.greyColor, + ), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: 15), - hint: Text( - 'Seleccione $label', + items: items.map((String item) { + return DropdownMenuItem( + value: item, + child: Text( + item, style: AppFonts.text.copyWith( - color: AppColors.greyColor, + color: AppColors.textColor, ), ), - items: items.map((String item) { - return DropdownMenuItem( - value: item, - child: Text( - item, - style: AppFonts.text.copyWith( - color: AppColors.textColor, - ), - ), - ); - }).toList(), - onChanged: onChanged, - ), - ), + ); + }).toList(), + onChanged: onChanged, + validator: validator, ), ], ); } - Widget _buildEmployeeDropdown(String label, String? value, - List employees, ValueChanged onChanged) { + Widget _buildEmployeeDropdown( + String label, + String? value, + List employees, + ValueChanged onChanged, { + String? Function(String?)? validator, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -334,37 +463,37 @@ class _EditVehicleState extends State { ), ), const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.greyColor.withOpacity(0.3)), + DropdownButtonFormField( + value: value, + isExpanded: true, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + BorderSide(color: AppColors.greyColor.withOpacity(0.3)), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: 15), - hint: Text( - 'Seleccione $label', + hint: Text( + 'Seleccione $label', + style: AppFonts.text.copyWith( + color: AppColors.greyColor, + ), + ), + items: employees.map((Empleado empleado) { + return DropdownMenuItem( + value: empleado.nombreCompleto, + child: Text( + empleado.nombreCompletoConCargo, style: AppFonts.text.copyWith( - color: AppColors.greyColor, + color: AppColors.textColor, ), ), - items: employees.map((Empleado empleado) { - return DropdownMenuItem( - value: empleado.nombreCompleto, - child: Text( - empleado.nombreCompletoConCargo, - style: AppFonts.text.copyWith( - color: AppColors.textColor, - ), - ), - ); - }).toList(), - onChanged: onChanged, - ), - ), + ); + }).toList(), + onChanged: onChanged, + validator: validator, ), ], ); diff --git a/lib/viewmodel/calendario_viewmodel.dart b/lib/viewmodel/calendario_viewmodel.dart index bccf795..260b068 100644 --- a/lib/viewmodel/calendario_viewmodel.dart +++ b/lib/viewmodel/calendario_viewmodel.dart @@ -3,18 +3,23 @@ import 'package:client_service/repositories/camara_repository.dart'; import 'package:client_service/repositories/instalacion_repository.dart'; import 'package:client_service/repositories/vehiculo_repository.dart'; import 'package:client_service/viewmodel/base_viewmodel.dart'; -import 'package:firebase_auth/firebase_auth.dart'; + +import 'package:client_service/models/empleado.dart'; class CalendarioViewModel extends BaseViewModel { final CamaraRepository _camaraRepository; final InstalacionRepository _instalacionRepository; final VehiculoRepository _vehiculoRepository; + final String cedulaEmpleado; + final CargoEmpleado cargoEmpleado; CalendarioViewModel( this._camaraRepository, this._instalacionRepository, - this._vehiculoRepository, - ); + this._vehiculoRepository, { + required this.cedulaEmpleado, + required this.cargoEmpleado, + }); List _eventos = []; List get eventos => _eventos; @@ -52,19 +57,13 @@ class CalendarioViewModel extends BaseViewModel { final alquileres = await _vehiculoRepository.getAll(); todosLosEventos.addAll(alquileres.map(EventoCalendario.fromAlquiler)); - // Filtrar por empleado autenticado si no es admin - final user = FirebaseAuth.instance.currentUser; + // Filtrar por empleado autenticado si no es administrador List eventosFiltrados = todosLosEventos; - if (user != null && - user.email != null && - user.email!.endsWith('@empleado.com')) { - final cedula = user.email!.split('@').first; + if (cargoEmpleado != CargoEmpleado.administrador) { eventosFiltrados = todosLosEventos.where((evento) { - // Filtrar por técnico asignado (puedes expandir para cuadrilla si lo implementas) - return evento.tecnico == cedula || evento.tecnico == user.email; + return evento.tecnico == cedulaEmpleado; }).toList(); } - _eventos = eventosFiltrados; _organizarEventosPorFecha(); notifyListeners(); @@ -107,19 +106,13 @@ class CalendarioViewModel extends BaseViewModel { ); eventosRango.addAll(alquileres.map(EventoCalendario.fromAlquiler)); - // Filtrar por empleado autenticado si no es admin - final user = FirebaseAuth.instance.currentUser; + // Filtrar por empleado autenticado si no es administrador List eventosFiltradosRango = eventosRango; - if (user != null && - user.email != null && - user.email!.endsWith('@empleado.com')) { - final cedula = user.email!.split('@').first; + if (cargoEmpleado != CargoEmpleado.administrador) { eventosFiltradosRango = eventosRango.where((evento) { - // Filtrar por técnico asignado (puedes expandir para cuadrilla si lo implementas) - return evento.tecnico == cedula || evento.tecnico == user.email; + return evento.tecnico == cedulaEmpleado; }).toList(); } - _eventos = eventosFiltradosRango; _organizarEventosPorFecha(); notifyListeners(); From 3ab7e484d39811a73f9062223f323247933f6727 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:00:43 -0500 Subject: [PATCH 15/20] feat: implement EmpleadoProvider for state management and add method to retrieve non-admin empleados in EmpleadoRepository --- lib/providers/empleado_provider.dart | 18 ++++++++++++++++++ lib/repositories/empleado_repository.dart | 9 +++++++++ lib/services/service_locator.dart | 16 +++++++++++----- 3 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 lib/providers/empleado_provider.dart diff --git a/lib/providers/empleado_provider.dart b/lib/providers/empleado_provider.dart new file mode 100644 index 0000000..def996a --- /dev/null +++ b/lib/providers/empleado_provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:client_service/models/empleado.dart'; + +class EmpleadoProvider extends ChangeNotifier { + Empleado? _empleado; + + Empleado? get empleado => _empleado; + + void setEmpleado(Empleado empleado) { + _empleado = empleado; + notifyListeners(); + } + + void clearEmpleado() { + _empleado = null; + notifyListeners(); + } +} diff --git a/lib/repositories/empleado_repository.dart b/lib/repositories/empleado_repository.dart index ce86ee9..4e9b46d 100644 --- a/lib/repositories/empleado_repository.dart +++ b/lib/repositories/empleado_repository.dart @@ -1,3 +1,5 @@ +// ...existing code... + import 'dart:io'; import 'package:cloud_firestore/cloud_firestore.dart'; import '../models/empleado.dart'; @@ -5,6 +7,13 @@ import '../services/cloudinary_service.dart'; import 'base_repository.dart'; class EmpleadoRepository implements BaseRepository { + Future> getAllEmpleadosNoAdmin() async { + final empleados = await getAll(); + return empleados + .where((e) => e.cargo != CargoEmpleado.administrador) + .toList(); + } + final FirebaseFirestore _firestore = FirebaseFirestore.instance; final CloudinaryService _cloudinaryService = CloudinaryService(); final String _collection = 'empleados'; diff --git a/lib/services/service_locator.dart b/lib/services/service_locator.dart index 271b72e..7fbb2a9 100644 --- a/lib/services/service_locator.dart +++ b/lib/services/service_locator.dart @@ -42,11 +42,17 @@ Future setupServiceLocator() async { () => AlquilerViewModel(sl())); sl.registerFactory( () => FacturaViewModel(sl())); - sl.registerFactory(() => CalendarioViewModel( - sl(), - sl(), - sl(), - )); + // Para usar CalendarioViewModel ahora debes pasar la cédula y el cargo del empleado autenticado: + // Ejemplo: + // sl.registerFactory(() => CalendarioViewModel( + // sl(), + // sl(), + // sl(), + // cedulaEmpleado: '1234567890', + // cargoEmpleado: CargoEmpleado.tecnico, + // )); + // + // Por lo general, deberás crear la instancia manualmente donde tengas la info del usuario autenticado. // Inicializar servicios que lo requieran await sl().inicializar(); From ace6ecde6ad60950f9ee6dfd36f4468bc5d12fcf Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:01:01 -0500 Subject: [PATCH 16/20] feat: add Asistencia model and implement AsistenciaScreen for attendance tracking with Firebase integration --- lib/models/asistencia.dart | 44 ++++ .../auth => utils/events}/splash_screen.dart | 0 lib/view/asistencia/asistencia_screen.dart | 214 ++++++++++++++---- 3 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 lib/models/asistencia.dart rename lib/{view/auth => utils/events}/splash_screen.dart (100%) diff --git a/lib/models/asistencia.dart b/lib/models/asistencia.dart new file mode 100644 index 0000000..286850c --- /dev/null +++ b/lib/models/asistencia.dart @@ -0,0 +1,44 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Asistencia { + final String id; + final String cedula; + final String? email; + final String fecha; // Formato: yyyy-MM-dd + final String horaEntrada; // Formato: HH:mm + final String? horaSalida; // Formato: HH:mm o null si no ha salido + final Timestamp timestamp; + + Asistencia({ + required this.id, + required this.cedula, + this.email, + required this.fecha, + required this.horaEntrada, + this.horaSalida, + required this.timestamp, + }); + + factory Asistencia.fromMap(Map map, String id) { + return Asistencia( + id: id, + cedula: map['cedula'] ?? '', + email: map['email'], + fecha: map['fecha'] ?? '', + horaEntrada: map['horaEntrada'] ?? '', + horaSalida: map['horaSalida'], + timestamp: map['timestamp'] ?? Timestamp.now(), + ); + } + + Map toMap() { + return { + 'cedula': cedula, + 'email': email, + 'fecha': fecha, + 'horaEntrada': horaEntrada, + 'horaSalida': horaSalida, + 'timestamp': timestamp, + }; + } +} diff --git a/lib/view/auth/splash_screen.dart b/lib/utils/events/splash_screen.dart similarity index 100% rename from lib/view/auth/splash_screen.dart rename to lib/utils/events/splash_screen.dart diff --git a/lib/view/asistencia/asistencia_screen.dart b/lib/view/asistencia/asistencia_screen.dart index 9ece30c..78a9c78 100644 --- a/lib/view/asistencia/asistencia_screen.dart +++ b/lib/view/asistencia/asistencia_screen.dart @@ -1,10 +1,13 @@ +import 'package:client_service/view/widgets/shared/button.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; + +import 'package:client_service/models/empleado.dart'; class AsistenciaScreen extends StatefulWidget { - const AsistenciaScreen({super.key}); + final Empleado empleado; + const AsistenciaScreen({super.key, required this.empleado}); @override State createState() => _AsistenciaScreenState(); @@ -13,47 +16,69 @@ class AsistenciaScreen extends StatefulWidget { class _AsistenciaScreenState extends State { String? _entrada; String? _salida; + bool _loading = true; + + @override + void initState() { + super.initState(); + _fetchAsistenciaHoy(); + } - void _marcarEntrada() async { - final user = FirebaseAuth.instance.currentUser; - if (user == null) return; - final cedula = user.email?.split('@').first ?? ''; + Future _fetchAsistenciaHoy() async { + final today = DateFormat('yyyy-MM-dd').format(DateTime.now()); + final query = await FirebaseFirestore.instance + .collection('asistencias') + .where('cedula', isEqualTo: widget.empleado.cedula) + .where('fecha', isEqualTo: today) + .get(); + String? entrada; + String? salida; + for (final doc in query.docs) { + final data = doc.data(); + if (data['horaEntrada'] != null) entrada = data['horaEntrada']; + if (data['horaSalida'] != null) salida = data['horaSalida']; + } + setState(() { + _entrada = entrada; + _salida = salida; + _loading = false; + }); + } + + Future _marcarEntrada() async { final now = DateTime.now(); final fecha = DateFormat('yyyy-MM-dd').format(now); final hora = DateFormat('HH:mm:ss').format(now); - setState(() { - _entrada = hora; - }); await FirebaseFirestore.instance.collection('asistencias').add({ - 'cedula': cedula, - 'email': user.email, + 'cedula': widget.empleado.cedula, + 'email': widget.empleado.correo, 'fecha': fecha, 'horaEntrada': hora, 'timestamp': now, }); + setState(() { + _entrada = hora; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Entrada registrada'), backgroundColor: Colors.green), ); } - void _marcarSalida() async { - final user = FirebaseAuth.instance.currentUser; - if (user == null) return; - final cedula = user.email?.split('@').first ?? ''; + Future _marcarSalida() async { final now = DateTime.now(); final fecha = DateFormat('yyyy-MM-dd').format(now); final hora = DateFormat('HH:mm:ss').format(now); - setState(() { - _salida = hora; - }); await FirebaseFirestore.instance.collection('asistencias').add({ - 'cedula': cedula, - 'email': user.email, + 'cedula': widget.empleado.cedula, + 'email': widget.empleado.correo, 'fecha': fecha, 'horaSalida': hora, 'timestamp': now, }); + setState(() { + _salida = hora; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Salida registrada'), backgroundColor: Colors.green), @@ -63,38 +88,129 @@ class _AsistenciaScreenState extends State { @override Widget build(BuildContext context) { final fecha = DateFormat('dd/MM/yyyy').format(DateTime.now()); - final hora = DateFormat('HH:mm:ss').format(DateTime.now()); + final hora = DateFormat('hh:mm').format(DateTime.now()); return Scaffold( - appBar: AppBar(title: const Text('Asistencia')), + backgroundColor: Colors.white, body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Fecha: $fecha', style: const TextStyle(fontSize: 18)), - const SizedBox(height: 8), - Text('Hora actual: $hora', style: const TextStyle(fontSize: 18)), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _marcarEntrada, - child: const Text('Marcar Entrada'), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _marcarSalida, - child: const Text('Marcar Salida'), + child: _loading + ? const CircularProgressIndicator() + : Container( + margin: + const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 8), + const Text( + 'Asistencia', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + hora, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.black87, + letterSpacing: 2, + ), + ), + const Text( + 'AM', + style: TextStyle( + fontSize: 18, + color: Colors.black54, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today, + size: 18, color: Colors.grey), + const SizedBox(width: 8), + Text( + fecha.replaceAll('/', ' de '), + style: const TextStyle( + fontSize: 16, color: Colors.black87), + ), + ], + ), + ), + const SizedBox(height: 32), + if (_entrada == null && _salida == null) + BtnElevated( + text: 'Marcar Entrada', + onPressed: () async { + await _marcarEntrada(); + await _fetchAsistenciaHoy(); + }, + ) + else if (_entrada != null && _salida == null) + BtnElevated( + text: 'Marcar Salida', + onPressed: () async { + await _marcarSalida(); + await _fetchAsistenciaHoy(); + }, + ) + else if (_entrada != null && _salida != null) + Column( + children: const [ + Icon(Icons.check_circle, + color: Colors.green, size: 48), + SizedBox(height: 12), + Text( + '¡Asistencia completada!\nYa registraste tu entrada y salida de hoy.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.green, + fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + if (_entrada != null) + Text('Entrada marcada a las: $_entrada', + style: const TextStyle(color: Colors.green)), + if (_salida != null) + Text('Salida marcada a las: $_salida', + style: const TextStyle(color: Colors.red)), + ], + ), ), - const SizedBox(height: 32), - if (_entrada != null) - Text('Entrada marcada a las: $_entrada', - style: const TextStyle(color: Colors.green)), - if (_salida != null) - Text('Salida marcada a las: $_salida', - style: const TextStyle(color: Colors.red)), - ], - ), - ), ), ); } From 268efcba71d812d5e49913d11368e8fb569cf5b9 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:01:18 -0500 Subject: [PATCH 17/20] feat: enhance CalendarioScreen to accept Empleado data and restrict event state changes for admin users --- lib/view/calendar/calendario_screen.dart | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/view/calendar/calendario_screen.dart b/lib/view/calendar/calendario_screen.dart index 19217a4..1ca7e3e 100644 --- a/lib/view/calendar/calendario_screen.dart +++ b/lib/view/calendar/calendario_screen.dart @@ -4,20 +4,24 @@ import 'package:client_service/utils/colors.dart'; import 'package:client_service/utils/events/evento_calendario.dart'; import 'package:client_service/viewmodel/calendario_viewmodel.dart'; import 'package:client_service/services/service_locator.dart'; +import 'package:client_service/repositories/camara_repository.dart'; +import 'package:client_service/repositories/instalacion_repository.dart'; +import 'package:client_service/repositories/vehiculo_repository.dart'; import 'package:intl/intl.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; +import 'package:client_service/models/empleado.dart'; class CalendarioScreen extends StatefulWidget { - const CalendarioScreen({super.key}); + final Empleado empleado; + const CalendarioScreen({super.key, required this.empleado}); @override State createState() => _CalendarioScreenState(); } class _CalendarioScreenState extends State { - late final CalendarioViewModel _viewModel; - late final ValueNotifier> _selectedEvents; + late CalendarioViewModel _viewModel; + late ValueNotifier> _selectedEvents; CalendarFormat _calendarFormat = CalendarFormat.month; DateTime _focusedDay = DateTime.now(); DateTime? _selectedDay; @@ -25,7 +29,13 @@ class _CalendarioScreenState extends State { @override void initState() { super.initState(); - _viewModel = sl(); + _viewModel = CalendarioViewModel( + sl(), + sl(), + sl(), + cedulaEmpleado: widget.empleado.cedula, + cargoEmpleado: widget.empleado.cargo, + ); _selectedDay = DateTime.now(); _selectedEvents = ValueNotifier(_getEventsForDay(_selectedDay!)); _cargarEventos(); @@ -553,13 +563,9 @@ class _CalendarioScreenState extends State { } void _onEventTap(EventoCalendario evento) async { - final user = FirebaseAuth.instance.currentUser; - final isEmpleado = user != null && - user.email != null && - user.email!.endsWith('@empleado.com'); - if (!isEmpleado) return; + // Solo permitir cambiar estado si no es admin + if (widget.empleado.cargo == CargoEmpleado.administrador) return; final now = TimeOfDay.now(); - final hoy = DateTime.now(); final horaInicio = _parseTimeOfDay(evento.horaInicio); final horaFin = evento.horaFin != null ? _parseTimeOfDay(evento.horaFin!) : null; From 0cf331f7b53e972a5b66bc3346adc96f42bf63ba Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:01:27 -0500 Subject: [PATCH 18/20] feat: add PanelEmpleadoScreen for employee management with navigation to calendar and attendance screens --- .../panel_empleado/panel_empleado_screen.dart | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 lib/view/panel_empleado/panel_empleado_screen.dart diff --git a/lib/view/panel_empleado/panel_empleado_screen.dart b/lib/view/panel_empleado/panel_empleado_screen.dart new file mode 100644 index 0000000..549f8c9 --- /dev/null +++ b/lib/view/panel_empleado/panel_empleado_screen.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:client_service/models/empleado.dart'; +import 'package:client_service/view/calendar/calendario_screen.dart'; +import 'package:client_service/view/asistencia/asistencia_screen.dart'; + +class PanelEmpleadoScreen extends StatefulWidget { + final Empleado empleado; + const PanelEmpleadoScreen({super.key, required this.empleado}); + + @override + State createState() => _PanelEmpleadoScreenState(); +} + +class _PanelEmpleadoScreenState extends State + with SingleTickerProviderStateMixin { + int _selectedIndex = 0; + + List get _screens => [ + CalendarioScreen(empleado: widget.empleado), + AsistenciaScreen(empleado: widget.empleado), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _screens[_selectedIndex], + bottomNavigationBar: BottomNavigationBar( + currentIndex: _selectedIndex, + onTap: _onItemTapped, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.calendar_month), + label: 'Servicios', + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_time), + label: 'Asistencia', + ), + ], + selectedItemColor: Colors.deepPurple, + unselectedItemColor: Colors.grey, + backgroundColor: Colors.white, + type: BottomNavigationBarType.fixed, + ), + ); + } +} From a3670a1e74f48756e6a98d9433662afff9cbcfa0 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:01:53 -0500 Subject: [PATCH 19/20] feat: enhance to manage employee attendance with filtering and export functionality --- lib/view/reports/empleado.dart | 398 +++++++++++++++++++++++++++------ 1 file changed, 331 insertions(+), 67 deletions(-) diff --git a/lib/view/reports/empleado.dart b/lib/view/reports/empleado.dart index c5b1f1b..a30a586 100644 --- a/lib/view/reports/empleado.dart +++ b/lib/view/reports/empleado.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:client_service/models/empleado.dart'; +import 'package:client_service/repositories/empleado_repository.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:intl/intl.dart'; import 'package:client_service/utils/excel_export_utility.dart'; +import 'package:client_service/utils/colors.dart'; +import 'package:client_service/view/widgets/shared/apptitle.dart'; +import 'package:client_service/view/widgets/shared/button.dart'; +import 'package:client_service/view/widgets/shared/toolbar.dart'; class AsistenciasAdminScreen extends StatefulWidget { const AsistenciasAdminScreen({super.key}); @@ -14,25 +20,64 @@ class _AsistenciasAdminScreenState extends State { DateTime _selectedMonth = DateTime(DateTime.now().year, DateTime.now().month); List>> _asistencias = []; bool _loading = true; + List _empleados = []; + List _empleadosNoAdmin = []; + Empleado? _empleadoSeleccionado; @override void initState() { super.initState(); - _fetchAsistencias(); + _fetchEmpleadosYAsistencias(); + } + + Future _fetchEmpleadosYAsistencias() async { + setState(() => _loading = true); + final repo = EmpleadoRepository(); + final empleados = await repo.getAll(); + final empleadosNoAdmin = empleados + .where((e) => e.cargoDisplayName.toLowerCase() != 'administrador') + .toList(); + setState(() { + _empleados = empleados; + _empleadosNoAdmin = empleadosNoAdmin; + _empleadoSeleccionado = + empleadosNoAdmin.isNotEmpty ? empleadosNoAdmin.first : null; + }); + await _fetchAsistencias(); } Future _fetchAsistencias() async { + if (_empleadoSeleccionado == null) { + setState(() { + _asistencias = []; + _loading = false; + }); + return; + } setState(() => _loading = true); final start = DateTime(_selectedMonth.year, _selectedMonth.month, 1); final end = DateTime(_selectedMonth.year, _selectedMonth.month + 1, 1); + // Solo filtra por cedula en Firestore, el resto en memoria para evitar el índice compuesto final query = await FirebaseFirestore.instance .collection('asistencias') - .where('timestamp', isGreaterThanOrEqualTo: start) - .where('timestamp', isLessThan: end) - .orderBy('timestamp', descending: true) + .where('cedula', isEqualTo: _empleadoSeleccionado!.cedula) .get(); + final docsFiltrados = query.docs.where((doc) { + final ts = doc['timestamp']; + if (ts is Timestamp) { + final dt = ts.toDate(); + return dt.isAfter(start.subtract(const Duration(seconds: 1))) && + dt.isBefore(end); + } + return false; + }).toList(); + docsFiltrados.sort((a, b) { + final ta = a['timestamp'] as Timestamp; + final tb = b['timestamp'] as Timestamp; + return tb.compareTo(ta); // descending + }); setState(() { - _asistencias = query.docs; + _asistencias = docsFiltrados; _loading = false; }); } @@ -132,71 +177,290 @@ class _AsistenciasAdminScreenState extends State { @override Widget build(BuildContext context) { final monthLabel = DateFormat('MMMM yyyy', 'es_ES').format(_selectedMonth); - final grouped = >>{}; - for (final doc in _asistencias) { - final data = doc.data(); - final cedula = data['cedula'] ?? ''; - final fecha = data['fecha'] ?? ''; - final key = '$cedula|$fecha'; - grouped.putIfAbsent(key, () => []).add(data); - } return Scaffold( - appBar: AppBar( - title: const Text('Asistencias de Empleados'), - actions: [ - IconButton( - icon: const Icon(Icons.calendar_month), - onPressed: () => _selectMonth(context), - tooltip: 'Filtrar por mes', - ), - IconButton( - icon: const Icon(Icons.download), - onPressed: _exportarAsistencias, - tooltip: 'Exportar a Excel', - ), - ], - ), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : grouped.isEmpty - ? const Center( - child: Text('No hay asistencias registradas para este mes.')) - : ListView( - children: grouped.entries.map((entry) { - final parts = entry.key.split('|'); - final cedula = parts[0]; - final fecha = parts[1]; - final asistencias = entry.value; - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - child: ListTile( - title: Text('Empleado: $cedula'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Fecha: $fecha'), - ...asistencias.map((a) { - final entrada = a['horaEntrada'] ?? '-'; - final salida = a['horaSalida'] ?? '-'; - return Row( - children: [ - const Icon(Icons.login, - size: 16, color: Colors.green), - Text(' Entrada: $entrada'), - const SizedBox(width: 16), - const Icon(Icons.logout, - size: 16, color: Colors.red), - Text(' Salida: $salida'), + body: Container( + decoration: const BoxDecoration( + color: AppColors.backgroundColor, + ), + child: Column( + children: [ + const Apptitle(title: 'Reporte de Empleados'), + // Botón de filtro de mes y descarga + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + monthLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primaryColor, + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.calendar_month), + tooltip: 'Cambiar mes', + onPressed: () => _selectMonth(context), + color: AppColors.primaryColor, + ), + IconButton( + icon: const Icon(Icons.download), + tooltip: 'Exportar a Excel', + onPressed: _exportarAsistencias, + color: AppColors.primaryColor, + ), + ], + ), + ], + ), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _empleadosNoAdmin.isEmpty + ? const Center(child: Text('No hay empleados')) + : ListView.builder( + itemCount: _empleadosNoAdmin.length, + itemBuilder: (context, index) { + final empleado = _empleadosNoAdmin[index]; + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: const BoxDecoration( + color: AppColors.whiteColor, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ), ], - ); - }), - ], + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.primaryColor, + child: Text( + empleado.nombreCompleto.isNotEmpty + ? empleado.nombreCompleto[0] + .toUpperCase() + : '?', + style: const TextStyle( + color: AppColors.whiteColor, + fontSize: 20, + ), + ), + ), + title: Text(empleado.nombreCompleto), + subtitle: Text(empleado.cargoDisplayName), + trailing: PopupMenuButton( + color: AppColors.whiteColor, + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'asistencias') { + setState(() { + _empleadoSeleccionado = empleado; + }); + await _fetchAsistencias(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(24)), + ), + builder: (context) { + return SizedBox( + height: MediaQuery.of(context) + .size + .height * + 0.6, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all( + 16.0), + child: Text( + 'Asistencias de ${empleado.nombreCompleto}', + style: const TextStyle( + fontSize: 18, + fontWeight: + FontWeight.bold, + color: Colors.deepPurple, + ), + ), + ), + Expanded( + child: _asistencias.isEmpty + ? const Center( + child: Text( + 'No hay asistencias registradas para este mes.'), + ) + : ListView( + padding: + const EdgeInsets + .symmetric( + vertical: 8), + children: _asistencias + .map((doc) { + final data = + doc.data(); + final fecha = + data['fecha'] ?? + ''; + final entrada = + data['horaEntrada'] ?? + '-'; + final salida = data[ + 'horaSalida'] ?? + '-'; + return Card( + margin: + const EdgeInsets + .symmetric( + horizontal: + 12, + vertical: + 4), + child: ListTile( + title: Text( + 'Fecha: $fecha'), + subtitle: Row( + children: [ + const Icon( + Icons + .login, + size: + 16, + color: Colors + .green), + Text( + ' Entrada: $entrada'), + const SizedBox( + width: + 16), + const Icon( + Icons + .logout, + size: + 16, + color: Colors + .red), + Text( + ' Salida: $salida'), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + }, + ); + } else if (value == 'editar') { + // TODO: Navegar a pantalla de edición de empleado + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Función editar no implementada')), + ); + } else if (value == 'eliminar') { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: + const Text('Eliminar empleado'), + content: Text( + '¿Seguro que deseas eliminar a ${empleado.nombreCompleto}?'), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context, false), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () => + Navigator.pop(context, true), + child: const Text('Eliminar', + style: TextStyle( + color: Colors.red)), + ), + ], + ), + ); + if (confirm == true) { + final repo = EmpleadoRepository(); + await repo.delete(empleado.id!); + await _fetchEmpleadosYAsistencias(); + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: + Text('Empleado eliminado')), + ); + } + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'asistencias', + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text('Ver asistencias'), + Icon(Icons.calendar_month, size: 18), + ], + ), + ), + const PopupMenuItem( + value: 'editar', + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text('Editar'), + Icon(Icons.edit, size: 18), + ], + ), + ), + const PopupMenuItem( + value: 'eliminar', + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text('Eliminar'), + Icon(Icons.delete, size: 18), + ], + ), + ), + ], + ), + ), + ); + }, ), - ), - ); - }).toList(), - ), + ), + ], + ), + ), + floatingActionButton: BtnFloating( + onPressed: () { + _exportarAsistencias(); + }, + icon: Icons.download_rounded, + text: 'Descargar', + ), + bottomNavigationBar: const Toolbar(), ); } } From 290eeaf1379f45d81afd449cc033eb035744ce05 Mon Sep 17 00:00:00 2001 From: Savecoders Date: Fri, 18 Jul 2025 22:02:09 -0500 Subject: [PATCH 20/20] fix: refactor imports and enhance HomePage layout with background color and extended body --- lib/app.dart | 2 +- lib/main.dart | 13 ++++++++++++- lib/routes.dart | 5 ++--- lib/view/home/view.dart | 29 +++++------------------------ 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index e0c53fe..893bacf 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,4 +1,4 @@ -import 'package:client_service/view/auth/splash_screen.dart'; +import 'package:client_service/utils/events/splash_screen.dart'; import 'package:client_service/services/navigation_service.dart'; import 'package:client_service/routes.dart'; import 'package:flutter/material.dart'; diff --git a/lib/main.dart b/lib/main.dart index d014611..f2668c6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,9 @@ import 'package:client_service/firebase_options.dart'; import 'package:client_service/services/service_locator.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:client_service/providers/empleado_provider.dart'; +import 'package:client_service/viewmodel/auth_viewmodel.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; Future main(List args) async { @@ -18,5 +21,13 @@ Future main(List args) async { await setupServiceLocator(); - runApp(const MyApp()); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthViewModel()), + ChangeNotifierProvider(create: (_) => EmpleadoProvider()), + ], + child: const MyApp(), + ), + ); } diff --git a/lib/routes.dart b/lib/routes.dart index 52b954b..4f019d8 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -12,10 +12,9 @@ import 'package:client_service/view/billing/create_factura.dart'; import 'package:client_service/view/billing/facturas_list_avanzada.dart'; import 'package:client_service/view/billing/anular_facturas.dart'; import 'package:client_service/view/billing/dashboard_facturacion.dart'; -import 'package:client_service/view/calendar/calendario_screen.dart'; import 'package:client_service/view/notifications/notificaciones_screen.dart'; import 'package:client_service/view/settings/configuracion_screen.dart'; -import 'package:client_service/view/auth/splash_screen.dart'; +import 'package:client_service/utils/events/splash_screen.dart'; import 'package:client_service/view/auth/login_selection_screen.dart'; import 'package:client_service/view/auth/login_empleado_screen.dart'; import 'package:client_service/view/auth/login_admin_screen.dart'; @@ -50,7 +49,7 @@ final Map routes = { 'Reporte vehículos': (context) => const ReportVehiculo(), // Nuevas funcionalidades - 'calendario': (context) => const CalendarioScreen(), + // 'calendario': (context) => const CalendarioScreen(), // REMOVED: CalendarioScreen now requires Empleado 'notificaciones': (context) => const NotificacionesScreen(), 'configuracion': (context) => const ConfiguracionScreen(), }; diff --git a/lib/view/home/view.dart b/lib/view/home/view.dart index ed3d831..22d33b1 100644 --- a/lib/view/home/view.dart +++ b/lib/view/home/view.dart @@ -1,10 +1,8 @@ import 'package:client_service/utils/colors.dart'; import 'package:client_service/view/home/widgets/category.dart'; -import 'package:client_service/view/widgets/shared/search.dart'; import 'package:client_service/view/home/widgets/header.dart'; import 'package:client_service/view/home/widgets/section.dart'; import 'package:client_service/view/widgets/shared/toolbar.dart'; -import 'package:client_service/utils/helpers/notificacion_helper.dart'; import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { @@ -18,6 +16,8 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( + extendBody: true, + backgroundColor: AppColors.primaryColor, appBar: AppBar( toolbarHeight: 80, title: const Header(), @@ -26,28 +26,9 @@ class _HomePageState extends State { ), body: Container( color: AppColors.backgroundColor, - child: const Column( - children: [ - ContentPage(), - Toolbar(), - ], - ), - ), - // Botón flotante temporal para probar notificaciones - floatingActionButton: FloatingActionButton.extended( - onPressed: () async { - print('DEBUG: Botón de prueba presionado'); - await NotificacionUtils.notificarServicioCreado( - 'prueba manual', - 'Cliente de prueba', - DateTime.now(), - ); - print('DEBUG: Notificación de prueba creada'); - }, - icon: const Icon(Icons.notification_add), - label: const Text('Probar'), - backgroundColor: AppColors.primaryColor, + child: const ContentPage(), ), + bottomNavigationBar: const Toolbar(), ); } } @@ -73,7 +54,7 @@ class _ContentPageState extends State { return Expanded( child: Column( children: [ - const SearchBarPage(), + // SearchBarPage eliminado CategoryPage(onCategorySelected: updateCategory), SectionPage(selectedCategory: selectedCategory), ],