diff --git a/flutter_soma_app/.gitignore b/flutter_soma_app/.gitignore new file mode 100644 index 0000000..4dddf66 --- /dev/null +++ b/flutter_soma_app/.gitignore @@ -0,0 +1,63 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Regenerados por `flutter pub get`/build a partir de los plugins en +# pubspec.yaml; versionarlos solo genera diffs falsos entre corridas. +windows/flutter/generated_plugin_registrant.cc +windows/flutter/generated_plugin_registrant.h +windows/flutter/generated_plugins.cmake +linux/flutter/generated_plugin_registrant.cc +linux/flutter/generated_plugin_registrant.h +linux/flutter/generated_plugins.cmake +macos/Flutter/GeneratedPluginRegistrant.swift +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Environment variables +.env + +# Claude Code files +CLAUDE.md +.claude +.claude/ diff --git a/flutter_soma_app/.metadata b/flutter_soma_app/.metadata new file mode 100644 index 0000000..fac5aec --- /dev/null +++ b/flutter_soma_app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: android + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: ios + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: linux + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: macos + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: web + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + - platform: windows + create_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + base_revision: 68415ad1d920f6fe5ec284f5c2febf7c4dd5b0b3 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_soma_app/.vscode/launch.json b/flutter_soma_app/.vscode/launch.json new file mode 100644 index 0000000..656a4cb --- /dev/null +++ b/flutter_soma_app/.vscode/launch.json @@ -0,0 +1,46 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + // + // El entorno se elige al compilar con --dart-define-from-file. Cada + // configuración apunta a config/dev.json o config/prod.json. Si falta el + // archivo, la app corta el arranque (AppEnv.assertConfigured). + "version": "0.2.0", + "configurations": [ + { + "name": "SOMA (dev)", + "request": "launch", + "type": "dart", + "args": [ + "--dart-define-from-file=config/dev.json" + ] + }, + { + "name": "SOMA (prod)", + "request": "launch", + "type": "dart", + "args": [ + "--dart-define-from-file=config/prod.json" + ] + }, + { + "name": "SOMA (dev · profile)", + "request": "launch", + "type": "dart", + "flutterMode": "profile", + "args": [ + "--dart-define-from-file=config/dev.json" + ] + }, + { + "name": "SOMA (dev · release)", + "request": "launch", + "type": "dart", + "flutterMode": "release", + "args": [ + "--dart-define-from-file=config/dev.json" + ] + } + ] +} diff --git a/flutter_soma_app/README.md b/flutter_soma_app/README.md new file mode 100644 index 0000000..2abc833 --- /dev/null +++ b/flutter_soma_app/README.md @@ -0,0 +1,52 @@ +# SOMA - Gym Manager + +App de gestión de gimnasio con Flutter y Supabase. + +## Setup + +### 1. Configurar Supabase + +Editá el archivo `.env` con tus credenciales de Supabase: + +```env +SUPABASE_URL=https://TU-PROYECTO.supabase.co +SUPABASE_ANON_KEY=tu-anon-key-aqui +``` + +### 2. Instalar dependencias + +```bash +flutter pub get +``` + +### 3. Correr la app + +```bash +flutter run +``` + +## Roles + +- **admin/superadmin**: Acceso total +- **profesor**: Gestión de rutinas, horarios propios +- **usuario**: Ver pagos, rutina y perfil + +## Estructura + +``` +lib/ +├── core/ # Configuración, tema, widgets compartidos +├── features/ # Features por dominio (auth, payments, etc) +└── main.dart +``` + +## Stack + +- Flutter + Riverpod +- Supabase (backend) +- GoRouter (navegación) +- Clean Architecture + +## Documentación + +Ver [PROJECT.md](PROJECT.md) para detalles técnicos completos. diff --git a/flutter_soma_app/analysis_options.yaml b/flutter_soma_app/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/flutter_soma_app/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/flutter_soma_app/android/.gitignore b/flutter_soma_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/flutter_soma_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/flutter_soma_app/android/app/build.gradle.kts b/flutter_soma_app/android/app/build.gradle.kts new file mode 100644 index 0000000..b24697f --- /dev/null +++ b/flutter_soma_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.gimnasio_soma" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.gimnasio_soma" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/flutter_soma_app/android/app/src/debug/AndroidManifest.xml b/flutter_soma_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_soma_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_soma_app/android/app/src/main/AndroidManifest.xml b/flutter_soma_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b79e862 --- /dev/null +++ b/flutter_soma_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/android/app/src/main/kotlin/com/example/gimnasio_soma/MainActivity.kt b/flutter_soma_app/android/app/src/main/kotlin/com/example/gimnasio_soma/MainActivity.kt new file mode 100644 index 0000000..f0d8d64 --- /dev/null +++ b/flutter_soma_app/android/app/src/main/kotlin/com/example/gimnasio_soma/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.gimnasio_soma + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/flutter_soma_app/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_soma_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/flutter_soma_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_soma_app/android/app/src/main/res/drawable/launch_background.xml b/flutter_soma_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/flutter_soma_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_soma_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_soma_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/flutter_soma_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/flutter_soma_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_soma_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/flutter_soma_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/flutter_soma_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter_soma_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/flutter_soma_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/flutter_soma_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_soma_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/flutter_soma_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/flutter_soma_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_soma_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/flutter_soma_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/flutter_soma_app/android/app/src/main/res/values-night/styles.xml b/flutter_soma_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/flutter_soma_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_soma_app/android/app/src/main/res/values/styles.xml b/flutter_soma_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/flutter_soma_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_soma_app/android/app/src/profile/AndroidManifest.xml b/flutter_soma_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_soma_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_soma_app/android/build.gradle.kts b/flutter_soma_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/flutter_soma_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/flutter_soma_app/android/gradle.properties b/flutter_soma_app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/flutter_soma_app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/flutter_soma_app/android/gradle/wrapper/gradle-wrapper.properties b/flutter_soma_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/flutter_soma_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/flutter_soma_app/android/settings.gradle.kts b/flutter_soma_app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/flutter_soma_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/flutter_soma_app/assets/images/soma_logo_black.png b/flutter_soma_app/assets/images/soma_logo_black.png new file mode 100644 index 0000000..9098094 Binary files /dev/null and b/flutter_soma_app/assets/images/soma_logo_black.png differ diff --git a/flutter_soma_app/assets/images/soma_logo_white.png b/flutter_soma_app/assets/images/soma_logo_white.png new file mode 100644 index 0000000..04b1338 Binary files /dev/null and b/flutter_soma_app/assets/images/soma_logo_white.png differ diff --git a/flutter_soma_app/assets/logo.png b/flutter_soma_app/assets/logo.png new file mode 100644 index 0000000..628d316 Binary files /dev/null and b/flutter_soma_app/assets/logo.png differ diff --git a/flutter_soma_app/config/example.json b/flutter_soma_app/config/example.json new file mode 100644 index 0000000..b6b2bd3 --- /dev/null +++ b/flutter_soma_app/config/example.json @@ -0,0 +1,5 @@ +{ + "ENV": "dev", + "SUPABASE_URL": "https://TU-PROYECTO.supabase.co", + "SUPABASE_ANON_KEY": "tu-anon-key-aqui" +} diff --git a/flutter_soma_app/devtools_options.yaml b/flutter_soma_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/flutter_soma_app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter_soma_app/ios/.gitignore b/flutter_soma_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/flutter_soma_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/flutter_soma_app/ios/Flutter/AppFrameworkInfo.plist b/flutter_soma_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/flutter_soma_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/flutter_soma_app/ios/Flutter/Debug.xcconfig b/flutter_soma_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/flutter_soma_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/flutter_soma_app/ios/Flutter/Release.xcconfig b/flutter_soma_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/flutter_soma_app/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/flutter_soma_app/ios/Podfile b/flutter_soma_app/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/flutter_soma_app/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/flutter_soma_app/ios/Runner.xcodeproj/project.pbxproj b/flutter_soma_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6bbef56 --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter_soma_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_soma_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_soma_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter_soma_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter_soma_app/ios/Runner/AppDelegate.swift b/flutter_soma_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/flutter_soma_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/flutter_soma_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter_soma_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter_soma_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/flutter_soma_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/ios/Runner/Base.lproj/Main.storyboard b/flutter_soma_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/flutter_soma_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/ios/Runner/Info.plist b/flutter_soma_app/ios/Runner/Info.plist new file mode 100644 index 0000000..dabaf95 --- /dev/null +++ b/flutter_soma_app/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gimnasio Soma + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + gimnasio_soma + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/flutter_soma_app/ios/Runner/Runner-Bridging-Header.h b/flutter_soma_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/flutter_soma_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/flutter_soma_app/ios/RunnerTests/RunnerTests.swift b/flutter_soma_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/flutter_soma_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter_soma_app/lib/core/config/app_config.dart b/flutter_soma_app/lib/core/config/app_config.dart new file mode 100644 index 0000000..9e05990 --- /dev/null +++ b/flutter_soma_app/lib/core/config/app_config.dart @@ -0,0 +1,17 @@ +import 'package:gimnasio_soma/core/config/app_constants.dart'; + +class AppConfig { + final int pagosVentanaEdicionMinutos; + + const AppConfig({required this.pagosVentanaEdicionMinutos}); + + factory AppConfig.fromMap(Map map) => AppConfig( + pagosVentanaEdicionMinutos: + (map['pagos.ventana_edicion_minutos'] as num?)?.toInt() ?? + AppConstants.pagosVentanaEdicionMinutosDefault, + ); + + static const AppConfig defaults = AppConfig( + pagosVentanaEdicionMinutos: AppConstants.pagosVentanaEdicionMinutosDefault, + ); +} diff --git a/flutter_soma_app/lib/core/config/app_config_provider.dart b/flutter_soma_app/lib/core/config/app_config_provider.dart new file mode 100644 index 0000000..1ca172f --- /dev/null +++ b/flutter_soma_app/lib/core/config/app_config_provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_config.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; + +final appConfigProvider = FutureProvider((ref) async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + final client = SupabaseConfig.client; + final params = {}; + if (token != null) params['p_token'] = token; + final response = await client.rpc(AppConstants.rpcGetConfig, params: params); + if (response is Map) { + return AppConfig.fromMap(Map.from(response)); + } + return AppConfig.defaults; +}); diff --git a/flutter_soma_app/lib/core/config/app_constants.dart b/flutter_soma_app/lib/core/config/app_constants.dart new file mode 100644 index 0000000..ded46b1 --- /dev/null +++ b/flutter_soma_app/lib/core/config/app_constants.dart @@ -0,0 +1,86 @@ +class AppConstants { + AppConstants._(); + + static const String appName = 'SOMA PRO'; + + // Layout breakpoints + static const double kDesktopBreakpoint = 800.0; + static const double kWideBreakpoint = 1200.0; + + // Avatar radii + static const double kAvatarRadiusSm = 16.0; + static const double kAvatarRadiusMd = 20.0; + static const double kAvatarRadiusLg = 28.0; + + // SharedPreferences keys + static const String tokenKey = 'session_token'; + static const String usuariosViewModeKey = 'usuarios_view_mode'; + static const String pagosViewModeKey = 'pagos_view_mode'; + + // Supabase RPC names - Auth + static const String rpcLogin = 'fc_ingresar'; + static const String rpcIniciarSesionPorToken = 'fc_iniciar_sesion_por_token'; + static const String rpcGetUserByToken = 'fc_obtener_usuario_por_token'; + static const String rpcDestroySession = 'fc_eliminar_sesion'; + + // Supabase RPC names - Usuarios + static const String rpcGetUsuarios = 'fc_obtener_usuarios'; + static const String rpcInsertUsuario = 'fc_insertar_usuario'; + static const String rpcUpdateUsuario = 'fc_modificar_usuario'; + static const String rpcToggleUsuarioStatus = 'fc_modificar_estado_usuario'; + static const String rpcDeleteUsuario = 'fc_eliminar_usuario'; + static const String rpcCambiarPropiaContrasena = 'fc_cambiar_propia_contrasena'; + static const String rpcResetearContrasenaUsuario = 'fc_resetear_contrasena_usuario'; + + // Supabase RPC names - Config + static const String rpcGetConfig = 'fc_obtener_config'; + + // Supabase RPC names - Pagos + static const String rpcGetPagos = 'fc_obtener_pagos'; + static const String rpcGetMisPagos = 'fc_obtener_mis_pagos'; + static const String rpcInsertPago = 'fc_insertar_pago'; + static const String rpcEditarPago = 'fc_editar_pago'; + static const String rpcAnularPago = 'fc_anular_pago'; + static const String rpcGetMetodosPago = 'fc_obtener_metodos_pago'; + static const String rpcUpdateMetodoPago = 'fc_modificar_metodos_pago'; + + // Fallback local cuando appConfigProvider aún no resolvió o falló. + static const int pagosVentanaEdicionMinutosDefault = 30; + + // Supabase RPC names - Actividades + static const String rpcGetActividadesTipoCuota = 'fc_obtener_actividades_tipo_cuota'; + + static const String rpcGetActividades = 'fc_obtener_actividades'; + static const String rpcInsertActividad = 'fc_insertar_actividad'; + static const String rpcUpdateActividad = 'fc_modificar_actividad'; + static const String rpcDeleteActividad = 'fc_eliminar_actividad'; + + // Supabase RPC names - Horarios + static const String rpcObtenerHorarios = 'fc_obtener_horarios'; + static const String rpcObtenerPlanificacionesFuturas = 'fc_obtener_planificaciones_futuras'; + static const String rpcInsertHorarioConActividades = 'fc_insertar_horario_con_actividades'; + static const String rpcEliminarDiaEspecial = 'fc_eliminar_dia_especial'; + + // Supabase RPC names - Huerfanas + static const String rpcObtenerReservasHuerfanas = 'fc_obtener_reservas_huerfanas'; + static const String rpcResolverHuerfana = 'fc_resolver_huerfana'; + static const String rpcMoverReservaHuerfana = 'fc_mover_reserva_huerfana'; + + // Supabase RPC names - Turnos + static const String rpcObtenerTurnos = 'fc_obtener_turnos'; + static const String rpcUpsertTurno = 'fc_upsert_turno'; + static const String rpcLimpiarTurnosAntiguos = 'fc_limpiar_turnos_antiguos'; + static const String rpcObtenerReservasTurno = 'fc_obtener_reservas_turno'; + static const String rpcReservarTurnoAdmin = 'fc_reservar_turno_admin'; + static const String rpcCancelarReservaAdmin = 'fc_cancelar_reserva_admin'; + static const String rpcObtenerEstadoCupo = 'fc_obtener_estado_cupo'; + + // Supabase RPC names - Usuarios Cuota + static const String rpcGetUsuarioTipoCuota = 'fc_obtener_usuario_tipo_cuota'; + + // Supabase RPC names - Tipos de Cuota + static const String rpcGetTiposCuota = 'fc_obtener_tipo_cuota'; + static const String rpcInsertTipoCuota = 'fc_insertar_tipo_cuota'; + static const String rpcUpdateTipoCuota = 'fc_modificar_tipo_cuota'; + static const String rpcDeleteTipoCuota = 'fc_eliminar_tipo_cuota'; +} diff --git a/flutter_soma_app/lib/core/config/app_env.dart b/flutter_soma_app/lib/core/config/app_env.dart new file mode 100644 index 0000000..df6fea7 --- /dev/null +++ b/flutter_soma_app/lib/core/config/app_env.dart @@ -0,0 +1,52 @@ +/// Configuración de entorno horneada en tiempo de compilación. +/// +/// Los valores entran al binario por `--dart-define-from-file=config/.json` +/// (ver `config/dev.json` y `config/prod.json` en la raíz del proyecto). Son +/// constantes de compilación: se leen con `String.fromEnvironment` y quedan +/// fijas en el binario. NO hay archivo `.env` ni carga en runtime. +/// +/// Si la app se compila/corre sin pasar el archivo de config, estas constantes +/// quedan vacías y [assertConfigured] corta el arranque con un mensaje claro, +/// en vez de dejar que la app intente conectarse a una URL vacía. +class AppEnv { + AppEnv._(); + + /// Identidad del entorno: `'dev'` o `'prod'`. Vacío si no se pasó el archivo. + static const String env = String.fromEnvironment('ENV'); + + /// URL del proyecto Supabase del entorno. + static const String supabaseUrl = String.fromEnvironment('SUPABASE_URL'); + + /// Clave pública (anon) del entorno. Es pública por diseño (protegida por RLS), + /// por eso puede vivir en `config/*.json` versionado. El secreto real + /// (service_role / DATABASE_URL) nunca entra acá. + static const String supabaseAnonKey = + String.fromEnvironment('SUPABASE_ANON_KEY'); + + static bool get isDev => env == 'dev'; + static bool get isProd => env == 'prod'; + + /// Falla temprano y con un mensaje accionable si la app se compiló sin + /// `--dart-define-from-file=config/.json`, o con un `ENV` inválido. + static void assertConfigured() { + final faltantes = [ + if (env.isEmpty) 'ENV', + if (supabaseUrl.isEmpty) 'SUPABASE_URL', + if (supabaseAnonKey.isEmpty) 'SUPABASE_ANON_KEY', + ]; + if (faltantes.isNotEmpty) { + throw StateError( + 'Configuración de entorno ausente: ${faltantes.join(', ')}. ' + 'Compilá/corré pasando el archivo de entorno, por ejemplo:\n' + ' flutter run --dart-define-from-file=config/dev.json\n' + ' flutter build windows --dart-define-from-file=config/prod.json', + ); + } + if (env != 'dev' && env != 'prod') { + throw StateError( + 'ENV inválido: "$env". Debe ser "dev" o "prod" ' + '(ver config/dev.json y config/prod.json).', + ); + } + } +} diff --git a/flutter_soma_app/lib/core/config/env_banner.dart b/flutter_soma_app/lib/core/config/env_banner.dart new file mode 100644 index 0000000..c46f672 --- /dev/null +++ b/flutter_soma_app/lib/core/config/env_banner.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/config/app_env.dart'; + +/// Envuelve la app en una cinta diagonal con el nombre del entorno cuando NO es +/// prod (ej. "DEV"), para no confundir el entorno de pruebas con el real. En +/// prod devuelve el hijo sin envolver: no se ve ninguna cinta. +class EnvBanner extends StatelessWidget { + const EnvBanner({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (AppEnv.isProd) return child; + return Banner( + message: AppEnv.env.toUpperCase(), + location: BannerLocation.topStart, + color: Colors.red.shade700, + child: child, + ); + } +} diff --git a/flutter_soma_app/lib/core/config/supabase_config.dart b/flutter_soma_app/lib/core/config/supabase_config.dart new file mode 100644 index 0000000..b54a9be --- /dev/null +++ b/flutter_soma_app/lib/core/config/supabase_config.dart @@ -0,0 +1,23 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/core/config/app_env.dart'; +import 'package:gimnasio_soma/core/services/soma_logger.dart'; + +class SupabaseConfig { + static SupabaseClient get client => Supabase.instance.client; + + static Future initialize() async { + await Supabase.initialize( + url: AppEnv.supabaseUrl, + anonKey: AppEnv.supabaseAnonKey, + ); + } + + /// RPC con logging automático de request, params, duración y resultado. + static Future rpc(String fn, {Map? params}) { + return SomaLogger.instance.logRpc( + fn, + params ?? {}, + () async => await client.rpc(fn, params: params), + ); + } +} diff --git a/flutter_soma_app/lib/core/navigation/navigation_item.dart b/flutter_soma_app/lib/core/navigation/navigation_item.dart new file mode 100644 index 0000000..d937a01 --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/navigation_item.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +enum SomaRole { staff, usuario } + +class NavigationItem { + final String label; + final IconData icon; + final IconData activeIcon; + final String path; + final Set allowedRoles; + + const NavigationItem({ + required this.label, + required this.icon, + required this.activeIcon, + required this.path, + required this.allowedRoles, + }); + + bool isVisibleTo(String userRole) { + // Mapear múltiples roles de BD al rol unificado 'staff' + if (userRole == 'superadmin' || userRole == 'admin' || userRole == 'profesor') { + return allowedRoles.contains(SomaRole.staff); + } + return allowedRoles.contains(SomaRole.usuario); + } +} diff --git a/flutter_soma_app/lib/core/navigation/navigation_items.dart b/flutter_soma_app/lib/core/navigation/navigation_items.dart new file mode 100644 index 0000000..4fdcab0 --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/navigation_items.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'navigation_item.dart'; + +const List kNavigationItems = [ + NavigationItem( + label: 'Usuarios', + icon: Icons.people_outline, + activeIcon: Icons.people, + path: '/usuarios', + allowedRoles: {SomaRole.staff}, + ), + NavigationItem( + label: 'Pagos', + icon: Icons.payment_outlined, + activeIcon: Icons.payment, + path: '/pagos', + allowedRoles: {SomaRole.staff, SomaRole.usuario}, + ), + // Rutinas: módulo aún no desarrollado. Se oculta de la navegación hasta + // implementarlo. La ruta /rutinas sigue definida en app_router (el guard + // redirige al primer ítem visible si se accede directamente). + NavigationItem( + label: 'Actividades', + icon: Icons.sports_gymnastics_outlined, + activeIcon: Icons.sports_gymnastics, + path: '/actividades', + allowedRoles: {SomaRole.staff}, + ), + NavigationItem( + label: 'Horarios', + icon: Icons.schedule_outlined, + activeIcon: Icons.schedule, + path: '/horarios', + allowedRoles: {SomaRole.staff, SomaRole.usuario}, + ), + NavigationItem( + label: 'Turnos', + icon: Icons.event_available_outlined, + activeIcon: Icons.event_available, + path: '/turnos', + allowedRoles: {SomaRole.staff}, + ), + NavigationItem( + label: 'Planes', + icon: Icons.card_membership_outlined, + activeIcon: Icons.card_membership, + path: '/planes', + allowedRoles: {SomaRole.staff}, + ), + NavigationItem( + label: 'Sin turno', + icon: Icons.warning_amber_outlined, + activeIcon: Icons.warning_amber, + path: '/huerfanas', + allowedRoles: {SomaRole.staff}, + ), +]; + +List getVisibleItems(String userRole) { + return kNavigationItems.where((item) => item.isVisibleTo(userRole)).toList(); +} diff --git a/flutter_soma_app/lib/core/navigation/navigation_shell.dart b/flutter_soma_app/lib/core/navigation/navigation_shell.dart new file mode 100644 index 0000000..2f04e27 --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/navigation_shell.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_item.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_items.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_drawer.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_sidebar.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +const _kDesktopBreakpoint = 800.0; + +class NavigationShell extends ConsumerWidget { + final Widget child; + + const NavigationShell({super.key, required this.child}); + + static const _subPageTitles = { + '/pagos/metodos': 'Métodos de pago', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final width = MediaQuery.of(context).size.width; + final isDesktop = width >= _kDesktopBreakpoint; + final user = ref.watch(authStateProvider).valueOrNull; + final currentPath = GoRouterState.of(context).matchedLocation; + final effectiveRole = user?.role ?? ''; + final visibleItems = + user != null ? getVisibleItems(effectiveRole) : []; + + // Detectar si estamos en una sub-página + final isSubPage = !visibleItems.any((i) => i.path == currentPath) && + !{'/gallery', '/logs', '/perfil'}.contains(currentPath); + final parentItem = isSubPage + ? visibleItems + .where((i) => currentPath.startsWith(i.path)) + .firstOrNull + : null; + + final theme = Theme.of(context); + + if (isDesktop) { + return Scaffold( + body: Row( + children: [ + SomaSidebar( + items: visibleItems, + currentPath: currentPath, + user: user, + ), + Expanded( + child: Column( + children: [ + if (isSubPage && parentItem != null) + _BackBar( + parentLabel: parentItem.label, + onBack: () => context.go(parentItem.path), + theme: theme, + ), + Expanded(child: child), + ], + ), + ), + ], + ), + ); + } + + // Mobile + return Scaffold( + appBar: AppBar( + title: Text(_titleForPath(currentPath)), + leading: isSubPage && parentItem != null + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go(parentItem.path), + ) + : Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), + ), + ), + drawer: isSubPage + ? null + : SomaDrawer( + items: visibleItems, + currentPath: currentPath, + user: user, + ), + body: child, + ); + } + + String _titleForPath(String path) { + // Primero buscar título específico de sub-página + final subTitle = _subPageTitles[path]; + if (subTitle != null) return subTitle; + + final item = + kNavigationItems.where((i) => path.startsWith(i.path)).firstOrNull; + return item?.label ?? 'SOMA PRO'; + } +} + +class _BackBar extends StatelessWidget { + final String parentLabel; + final VoidCallback onBack; + final ThemeData theme; + + const _BackBar({ + required this.parentLabel, + required this.onBack, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withAlpha(80), + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + ), + child: InkWell( + onTap: onBack, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + Icons.arrow_back, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(178), + ), + const SizedBox(width: 8), + Text( + 'Volver a $parentLabel', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/navigation/widgets/soma_drawer.dart b/flutter_soma_app/lib/core/navigation/widgets/soma_drawer.dart new file mode 100644 index 0000000..9332c0e --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/widgets/soma_drawer.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_item.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_nav_tile.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_user_card.dart'; +import 'package:gimnasio_soma/core/widgets/soma_logo.dart'; +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +class SomaDrawer extends ConsumerWidget { + final List items; + final String currentPath; + final UserSession? user; + + const SomaDrawer({ + super.key, + required this.items, + required this.currentPath, + required this.user, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Drawer( + backgroundColor: Theme.of(context).colorScheme.surface, + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 20), + + // Logo + const SomaLogo(width: 100), + + const SizedBox(height: 24), + + Divider( + height: 1, + thickness: 0.5, + indent: 16, + endIndent: 16, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + + const SizedBox(height: 8), + + // Nav items + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = items[index]; + final isActive = currentPath.startsWith(item.path); + return SomaNavTile( + item: item, + isActive: isActive, + onTap: () { + Navigator.of(context).pop(); + context.go(item.path); + }, + ); + }, + ), + ), + + // Footer + Divider( + height: 1, + thickness: 0.5, + indent: 16, + endIndent: 16, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + + SomaUserCard( + user: user, + onTap: () { + Navigator.of(context).pop(); + context.go('/perfil'); + }, + ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.logout, size: 20), + tooltip: 'Cerrar sesión', + onPressed: () { + Navigator.of(context).pop(); + ref.read(authStateProvider.notifier).logout(); + }, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + ], + ), + ), + + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/navigation/widgets/soma_nav_tile.dart b/flutter_soma_app/lib/core/navigation/widgets/soma_nav_tile.dart new file mode 100644 index 0000000..71fd0a7 --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/widgets/soma_nav_tile.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_item.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; + +class SomaNavTile extends StatelessWidget { + final NavigationItem item; + final bool isActive; + final bool isCollapsed; + final VoidCallback onTap; + final int? badge; + + const SomaNavTile({ + super.key, + required this.item, + required this.isActive, + required this.onTap, + this.isCollapsed = false, + this.badge, + }); + + @override + Widget build(BuildContext context) { + final tile = InkWell( + onTap: onTap, + mouseCursor: SystemMouseCursors.click, + borderRadius: BorderRadius.circular(8), + child: Container( + height: 44, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isActive + ? SomaColors.primary.withAlpha(18) + : Colors.transparent, + ), + child: Row( + children: [ + // Franja izquierda de acento: reemplaza Border(left:...) no uniforme + Container( + width: 3, + color: isActive ? SomaColors.primary : Colors.transparent, + ), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(isCollapsed ? 0 : 9, 0, isCollapsed ? 0 : 12, 0), + child: Row( + mainAxisAlignment: isCollapsed + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon( + isActive ? item.activeIcon : item.icon, + size: 20, + color: isActive + ? SomaColors.primary + : Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + if (badge != null && badge! > 0) + Positioned( + top: -4, + right: -5, + child: Container( + padding: const EdgeInsets.all(2), + constraints: const BoxConstraints( + minWidth: 14, + minHeight: 14, + ), + decoration: BoxDecoration( + color: SomaColors.error, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + badge! > 99 ? '99+' : '$badge', + style: const TextStyle( + fontSize: 8, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + if (!isCollapsed) ...[ + const SizedBox(width: 12), + Expanded( + child: Text( + item.label, + style: TextStyle( + fontSize: 14, + fontWeight: + isActive ? FontWeight.w600 : FontWeight.w500, + color: isActive + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context) + .colorScheme + .onSurface + .withAlpha(178), + ), + ), + ), + if (badge != null && badge! > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.error, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + badge! > 99 ? '99+' : '$badge', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + + if (isCollapsed) { + return Tooltip( + message: item.label, + preferBelow: false, + child: tile, + ); + } + + return tile; + } +} diff --git a/flutter_soma_app/lib/core/navigation/widgets/soma_sidebar.dart b/flutter_soma_app/lib/core/navigation/widgets/soma_sidebar.dart new file mode 100644 index 0000000..e94a32b --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/widgets/soma_sidebar.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_item.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_nav_tile.dart'; +import 'package:gimnasio_soma/core/navigation/widgets/soma_user_card.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_logo.dart'; +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart'; + +final sidebarCollapsedProvider = StateProvider((ref) => false); + +class SomaSidebar extends ConsumerWidget { + final List items; + final String currentPath; + final UserSession? user; + + const SomaSidebar({ + super.key, + required this.items, + required this.currentPath, + required this.user, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCollapsed = ref.watch(sidebarCollapsedProvider); + + Future confirmLogout() async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cerrar sesión'), + content: const Text('¿Estás seguro de que querés cerrar sesión?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Cerrar sesión'), + ), + ], + ), + ); + if (confirm != true) return; + ref.read(authStateProvider.notifier).logout(); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: isCollapsed ? 64 : 240, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + right: BorderSide( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + ), + child: Column( + children: [ + // Logo + Padding( + padding: EdgeInsets.fromLTRB( + isCollapsed ? 8 : 16, + 20, + isCollapsed ? 8 : 16, + 8, + ), + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: isCollapsed + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SomaLogo(width: 90), + secondChild: const SomaLogo(width: 32), + ), + ), + + const SizedBox(height: 16), + + // Nav items + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric( + horizontal: isCollapsed ? 8 : 12, + ), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = items[index]; + final isActive = currentPath.startsWith(item.path); + int? badge; + if (item.path == '/huerfanas') { + badge = ref.watch(huerfanasPendienteCountProvider).valueOrNull; + } + return SomaNavTile( + item: item, + isActive: isActive, + isCollapsed: isCollapsed, + badge: badge, + onTap: () => context.go(item.path), + ); + }, + ), + ), + + // Footer + Divider( + height: 1, + thickness: 0.5, + indent: isCollapsed ? 8 : 16, + endIndent: isCollapsed ? 8 : 16, + ), + + SomaUserCard( + user: user, + isCollapsed: isCollapsed, + onTap: () => context.go('/perfil'), + ), + + // Actions + Padding( + padding: EdgeInsets.symmetric( + horizontal: isCollapsed ? 8 : 12, + vertical: 4, + ), + child: _SidebarActionTile( + isCollapsed: isCollapsed, + icon: Icons.logout, + label: 'Cerrar sesión', + color: SomaColors.error, + onTap: confirmLogout, + ), + ), + const SizedBox(height: 2), + + // Collapse toggle + Padding( + padding: EdgeInsets.symmetric( + horizontal: isCollapsed ? 8 : 12, + vertical: 4, + ), + child: _SidebarActionTile( + isCollapsed: isCollapsed, + icon: isCollapsed + ? Icons.keyboard_double_arrow_right + : Icons.keyboard_double_arrow_left, + label: isCollapsed ? 'Expandir panel' : 'Colapsar panel', + onTap: () => ref.read(sidebarCollapsedProvider.notifier).state = + !isCollapsed, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _SidebarActionTile extends StatefulWidget { + final bool isCollapsed; + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? color; + + const _SidebarActionTile({ + required this.isCollapsed, + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + @override + State<_SidebarActionTile> createState() => _SidebarActionTileState(); +} + +class _SidebarActionTileState extends State<_SidebarActionTile> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final baseColor = widget.color ?? Theme.of(context).colorScheme.onSurface; + + if (widget.isCollapsed) { + return Tooltip( + message: widget.label, + waitDuration: const Duration(milliseconds: 600), + child: IconButton( + icon: Icon(widget.icon, size: 20), + onPressed: widget.onTap, + color: baseColor.withAlpha(178), + splashRadius: 18, + ), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: _hovered ? baseColor.withAlpha(15) : Colors.transparent, + ), + child: Row( + children: [ + Icon(widget.icon, size: 18, color: baseColor.withAlpha(190)), + const SizedBox(width: 10), + Text( + widget.label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: baseColor.withAlpha(190), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/navigation/widgets/soma_user_card.dart b/flutter_soma_app/lib/core/navigation/widgets/soma_user_card.dart new file mode 100644 index 0000000..48f36ac --- /dev/null +++ b/flutter_soma_app/lib/core/navigation/widgets/soma_user_card.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; + +class SomaUserCard extends StatelessWidget { + final UserSession? user; + final bool isCollapsed; + final VoidCallback? onTap; + + const SomaUserCard({ + super.key, + required this.user, + this.isCollapsed = false, + this.onTap, + }); + + String _getInitials() { + if (user == null) return '?'; + final n = user!.nombre; + final a = user!.apellido; + if (n.isNotEmpty && a.isNotEmpty) { + return '${n[0]}${a[0]}'.toUpperCase(); + } + if (n.isNotEmpty) return n[0].toUpperCase(); + if (user!.dni.length >= 2) return user!.dni.substring(0, 2); + return '?'; + } + + @override + Widget build(BuildContext context) { + final avatar = CircleAvatar( + radius: 16, + backgroundColor: SomaColors.primary.withAlpha(40), + child: Text( + _getInitials(), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + + if (isCollapsed) { + return Tooltip( + message: user?.displayName ?? '', + child: InkWell( + onTap: onTap, + mouseCursor: SystemMouseCursors.click, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center(child: avatar), + ), + ), + ); + } + + return InkWell( + onTap: onTap, + mouseCursor: SystemMouseCursors.click, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + avatar, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + user?.displayName ?? '', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + user?.role ?? '', + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/router/app_router.dart b/flutter_soma_app/lib/core/router/app_router.dart new file mode 100644 index 0000000..f37b8a1 --- /dev/null +++ b/flutter_soma_app/lib/core/router/app_router.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_items.dart'; +import 'package:gimnasio_soma/core/navigation/navigation_shell.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/auth/presentation/screens/login_screen.dart'; +import 'package:gimnasio_soma/features/auth/presentation/screens/splash_screen.dart'; +import 'package:gimnasio_soma/features/dev/logs_screen.dart'; +import 'package:gimnasio_soma/features/dev/widget_gallery_screen.dart'; +import 'package:gimnasio_soma/features/actividades/presentation/screens/actividades_screen.dart'; +import 'package:gimnasio_soma/features/perfil/presentation/screens/cambiar_contrasena_screen.dart'; +import 'package:gimnasio_soma/features/perfil/presentation/screens/perfil_screen.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/screens/horarios_screen.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/screens/metodos_pago_screen.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/screens/pagos_screen.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/screens/tipos_cuota_screen.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/screens/huerfanas_screen.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/screens/turnos_screen.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/screens/usuarios_screen.dart'; + +final routerProvider = Provider((ref) { + final authChangeNotifier = ref.watch(authChangeNotifierProvider); + + return GoRouter( + initialLocation: '/splash', + refreshListenable: authChangeNotifier, + redirect: (context, state) { + final authState = ref.read(authStateProvider); + final user = authState.valueOrNull; + final isLoggedIn = user != null; + final location = state.matchedLocation; + final isAuthScreen = location == '/login' || location == '/splash'; + + // No logueado y no está en login/splash → ir a login + if (!isLoggedIn && !isAuthScreen) return '/login'; + + final effectiveRole = user?.role ?? ''; + + // Logueado y está en login o splash → ir al primer item visible + if (isLoggedIn && isAuthScreen) { + final items = getVisibleItems(effectiveRole); + return items.isNotEmpty ? items.first.path : '/usuarios'; + } + + // Guard: si el usuario navega a una ruta sin permiso + if (isLoggedIn) { + final visiblePaths = + getVisibleItems(effectiveRole).map((i) => i.path).toSet(); + final matched = state.matchedLocation; + final isStaff = user.isStaff; + + // Rutas internas/de staff que no figuran en el menú pero deben + // quedar bloqueadas para clientes. + const staffOnlyPaths = { + '/gallery', + '/logs', + '/perfil/cambiar-contrasena', + }; + if (staffOnlyPaths.contains(matched) && !isStaff) { + return visiblePaths.isNotEmpty ? visiblePaths.first : '/usuarios'; + } + + if (matched != '/gallery' && + matched != '/perfil' && + matched != '/perfil/cambiar-contrasena' && + matched != '/logs' && + !visiblePaths.any((p) => matched.startsWith(p))) { + return visiblePaths.isNotEmpty ? visiblePaths.first : '/usuarios'; + } + } + + return null; + }, + routes: [ + // Splash - validación de sesión + GoRoute( + path: '/splash', + builder: (context, state) => const SplashScreen(), + ), + + // Login - fuera del shell + GoRoute( + path: '/login', + builder: (context, state) => const LoginScreen(), + ), + + // Shell con navegación + ShellRoute( + builder: (context, state, child) => NavigationShell(child: child), + routes: [ + GoRoute( + path: '/usuarios', + pageBuilder: (c, s) => _fade(s, const UsuariosScreen()), + ), + GoRoute( + path: '/pagos', + pageBuilder: (c, s) => _fade(s, const PagosScreen()), + ), + GoRoute( + path: '/pagos/metodos', + pageBuilder: (c, s) => _fade(s, const MetodosPagoScreen()), + ), + GoRoute( + path: '/actividades', + pageBuilder: (c, s) => _fade(s, const ActividadesScreen()), + ), + GoRoute( + path: '/horarios', + pageBuilder: (c, s) => _fade(s, const HorariosScreen()), + ), + GoRoute( + path: '/turnos', + pageBuilder: (c, s) => _fade(s, const TurnosScreen()), + ), + GoRoute( + path: '/planes', + pageBuilder: (c, s) => _fade(s, const TiposCuotaScreen()), + ), + GoRoute( + path: '/huerfanas', + pageBuilder: (c, s) => _fade(s, const HuerfanasScreen()), + ), + GoRoute( + path: '/perfil', + pageBuilder: (c, s) => _fade(s, const PerfilScreen()), + ), + GoRoute( + path: '/perfil/cambiar-contrasena', + pageBuilder: (c, s) => _fade(s, const CambiarContrasenaScreen()), + ), + GoRoute( + path: '/gallery', + pageBuilder: (c, s) => _fade(s, const WidgetGalleryScreen()), + ), + GoRoute( + path: '/logs', + pageBuilder: (c, s) => _fade(s, const LogsScreen()), + ), + ], + ), + ], + ); +}); + +CustomTransitionPage _fade(GoRouterState state, Widget child) => + CustomTransitionPage( + key: state.pageKey, + child: child, + transitionDuration: const Duration(milliseconds: 180), + reverseTransitionDuration: const Duration(milliseconds: 140), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); diff --git a/flutter_soma_app/lib/core/services/soma_logger.dart b/flutter_soma_app/lib/core/services/soma_logger.dart new file mode 100644 index 0000000..0d0e668 --- /dev/null +++ b/flutter_soma_app/lib/core/services/soma_logger.dart @@ -0,0 +1,192 @@ +import 'dart:collection'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; + +enum LogLevel { debug, info, warning, error } + +class LogEntry { + final DateTime timestamp; + final LogLevel level; + final String tag; + final String message; + final String? detail; + final Duration? duration; + + LogEntry({ + required this.timestamp, + required this.level, + required this.tag, + required this.message, + this.detail, + this.duration, + }); + + String get formatted { + final ts = + '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}.' + '${timestamp.millisecond.toString().padLeft(3, '0')}'; + final lvl = level.name.toUpperCase().padRight(5); + final dur = duration != null ? ' (${duration!.inMilliseconds}ms)' : ''; + return '[$ts] $lvl [$tag] $message$dur'; + } +} + +class SomaLogger { + SomaLogger._(); + static final SomaLogger instance = SomaLogger._(); + + static const int _maxEntries = 300; + final Queue _entries = Queue(); + + /// Plataforma detectada al iniciar. + String platform = 'unknown'; + + List get entries => _entries.toList(); + int get count => _entries.length; + + void _log(LogLevel level, String tag, String message, + {String? detail, Duration? duration}) { + final entry = LogEntry( + timestamp: DateTime.now(), + level: level, + tag: tag, + message: message, + detail: detail, + duration: duration, + ); + + _entries.addLast(entry); + while (_entries.length > _maxEntries) { + _entries.removeFirst(); + } + + // Salida a consola multiplataforma + developer.log( + entry.formatted, + name: 'SOMA', + level: _levelToInt(level), + ); + if (detail != null) { + developer.log(' $detail', name: 'SOMA', level: _levelToInt(level)); + } + } + + int _levelToInt(LogLevel level) { + switch (level) { + case LogLevel.debug: + return 500; + case LogLevel.info: + return 800; + case LogLevel.warning: + return 900; + case LogLevel.error: + return 1000; + } + } + + void debug(String tag, String message, + {String? detail, Duration? duration}) => + _log(LogLevel.debug, tag, message, detail: detail, duration: duration); + + void info(String tag, String message, + {String? detail, Duration? duration}) => + _log(LogLevel.info, tag, message, detail: detail, duration: duration); + + void warning(String tag, String message, + {String? detail, Duration? duration}) => + _log(LogLevel.warning, tag, message, detail: detail, duration: duration); + + void error(String tag, String message, + {String? detail, Duration? duration}) => + _log(LogLevel.error, tag, message, detail: detail, duration: duration); + + /// Wrapper para llamadas RPC con logging automático de request y timing. + Future logRpc( + String rpcName, + Map params, + Future Function() call, + ) async { + final sanitized = _sanitizeParams(params); + debug('RPC', '-> $rpcName', detail: sanitized); + + final sw = Stopwatch()..start(); + try { + final result = await call(); + sw.stop(); + info('RPC', '<- $rpcName OK', + duration: sw.elapsed, detail: _summarizeResult(result)); + return result; + } catch (e) { + sw.stop(); + error('RPC', 'x $rpcName FAIL', + duration: sw.elapsed, detail: e.toString()); + rethrow; + } + } + + String _sanitizeParams(Map params) { + final safe = {}; + for (final entry in params.entries) { + if (entry.key.contains('token') || entry.key.contains('password')) { + safe[entry.key] = '***'; + } else { + safe[entry.key] = entry.value; + } + } + return safe.toString(); + } + + String _summarizeResult(dynamic result) { + if (result is List) return '${result.length} items'; + if (result is Map) return '${result.length} keys'; + if (result is bool) return result.toString(); + if (result == null) return 'null'; + if (result is String && result.length > 80) { + return '${result.substring(0, 80)}...'; + } + return result.toString(); + } + + /// Detecta plataforma actual. + void detectPlatform() { + if (kIsWeb) { + platform = 'Web'; + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + platform = 'Android'; + case TargetPlatform.iOS: + platform = 'iOS'; + case TargetPlatform.windows: + platform = 'Windows'; + case TargetPlatform.macOS: + platform = 'macOS'; + case TargetPlatform.linux: + platform = 'Linux'; + case TargetPlatform.fuchsia: + platform = 'Fuchsia'; + } + } + info('App', 'Plataforma: $platform'); + } + + void clear() => _entries.clear(); + + /// Exporta logs como texto plano. + String export() { + final buf = StringBuffer(); + buf.writeln('=== SOMA Logs ==='); + buf.writeln('Plataforma: $platform'); + buf.writeln('Exportado: ${DateTime.now()}'); + buf.writeln('Entradas: ${_entries.length}'); + buf.writeln(''); + for (final e in _entries) { + buf.writeln(e.formatted); + if (e.detail != null) buf.writeln(' ${e.detail}'); + } + return buf.toString(); + } +} diff --git a/flutter_soma_app/lib/core/services/whatsapp_service.dart b/flutter_soma_app/lib/core/services/whatsapp_service.dart new file mode 100644 index 0000000..707ba14 --- /dev/null +++ b/flutter_soma_app/lib/core/services/whatsapp_service.dart @@ -0,0 +1,32 @@ +import 'package:url_launcher/url_launcher.dart'; + +class WhatsAppService { + WhatsAppService._(); + + /// Normaliza un número argentino a formato internacional sin +. + /// Retorna null si el número es inválido (vacío o menos de 8 dígitos). + static String? normalizarNumeroAr(String? rawPhone) { + if (rawPhone == null || rawPhone.trim().isEmpty) return null; + final digits = rawPhone.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 8) return null; + if (digits.startsWith('0054')) return '54${digits.substring(4)}'; + if (digits.startsWith('54') && digits.length >= 10) return digits; + if (digits.startsWith('0')) return '54${digits.substring(1)}'; + return '54$digits'; + } + + /// Abre WhatsApp con un mensaje pre-armado. + /// Retorna true si se abrió correctamente, false si el número es inválido + /// o si el sistema no pudo lanzar la URL. + static Future abrirChat({ + required String? telefono, + required String mensaje, + }) async { + final number = normalizarNumeroAr(telefono); + if (number == null) return false; + final uri = Uri.parse( + 'https://wa.me/$number?text=${Uri.encodeComponent(mensaje)}', + ); + return launchUrl(uri, mode: LaunchMode.externalApplication); + } +} diff --git a/flutter_soma_app/lib/core/theme/activity_colors.dart b/flutter_soma_app/lib/core/theme/activity_colors.dart new file mode 100644 index 0000000..fbd0fa3 --- /dev/null +++ b/flutter_soma_app/lib/core/theme/activity_colors.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +abstract final class ActivityColors { + static const List _palette = [ + Color(0xFFE07272), // coral + Color(0xFFD4904A), // ámbar terroso + Color(0xFF9FC44A), // lima + Color(0xFF4AC49A), // menta + Color(0xFF4A9ED4), // cielo + Color(0xFF7A6ED4), // lavanda + Color(0xFFC46EC4), // malva + Color(0xFFD46E96), // rosa + ]; + + static Color forId(int actividadId) => _palette[actividadId % _palette.length]; +} diff --git a/flutter_soma_app/lib/core/theme/soma_colors.dart b/flutter_soma_app/lib/core/theme/soma_colors.dart new file mode 100644 index 0000000..27e0f0b --- /dev/null +++ b/flutter_soma_app/lib/core/theme/soma_colors.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class SomaColors { + SomaColors._(); + + // Primary + static const Color primary = Color(0xFFFFD600); + static const Color primaryDark = Color(0xFFFFC800); + static const Color primaryLight = Color(0xFFFFE24D); + + // Variante legible de primary para texto sobre fondo claro + static const Color primaryText = Color(0xFF8B7000); + + // Dark theme + static const Color darkBackground = Color(0xFF121212); + static const Color darkSurface = Color(0xFF1E1E1E); + static const Color darkSurfaceVariant = Color(0xFF2C2C2C); + static const Color darkOnBackground = Color(0xFFFFFFFF); + static const Color darkOnSurface = Color(0xFFE0E0E0); + static const Color darkOnSurfaceVariant = Color(0xFF9E9E9E); + + // Semantic + static const Color success = Color(0xFF4CAF50); + static const Color error = Color(0xFFEF5350); + static const Color info = Color(0xFFFFD600); + + // On primary (text on yellow) + static const Color onPrimary = Color(0xFF1A1A1A); +} diff --git a/flutter_soma_app/lib/core/theme/soma_theme.dart b/flutter_soma_app/lib/core/theme/soma_theme.dart new file mode 100644 index 0000000..cebe49f --- /dev/null +++ b/flutter_soma_app/lib/core/theme/soma_theme.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'soma_colors.dart'; + +class SomaTheme { + SomaTheme._(); + + static ThemeData get dark => ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: SomaColors.darkBackground, + colorScheme: const ColorScheme.dark( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + surface: SomaColors.darkSurface, + onSurface: SomaColors.darkOnSurface, + surfaceContainerHighest: SomaColors.darkSurfaceVariant, + error: SomaColors.error, + ), + appBarTheme: const AppBarTheme( + backgroundColor: SomaColors.darkBackground, + foregroundColor: SomaColors.darkOnBackground, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: SomaColors.darkSurfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: SomaColors.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: SomaColors.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: SomaColors.error, width: 2), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + hintStyle: const TextStyle(color: SomaColors.darkOnSurfaceVariant), + labelStyle: const TextStyle(color: SomaColors.darkOnSurfaceVariant), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.primary, + foregroundColor: SomaColors.onPrimary, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: SomaColors.primary, + minimumSize: const Size(double.infinity, 52), + side: const BorderSide(color: SomaColors.primary, width: 1.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: SomaColors.primary, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + tabBarTheme: const TabBarThemeData( + labelColor: SomaColors.darkOnSurface, + unselectedLabelColor: SomaColors.darkOnSurfaceVariant, + indicatorColor: SomaColors.primary, + ), + segmentedButtonTheme: SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return SomaColors.primary; + return Colors.transparent; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return SomaColors.onPrimary; + return null; + }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return SomaColors.onPrimary; + return null; + }), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + contentTextStyle: const TextStyle(fontSize: 14), + ), + ); +} diff --git a/flutter_soma_app/lib/core/widgets/soma_header_help.dart b/flutter_soma_app/lib/core/widgets/soma_header_help.dart new file mode 100644 index 0000000..5979299 --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_header_help.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; + +class SomaHelpItem { + final IconData icon; + final String text; + + const SomaHelpItem({required this.icon, required this.text}); +} + +/// Botón "?" para poner al lado del título de una pantalla. Al tocarlo +/// muestra un globito explicando qué hace cada ícono/acción visible. +class SomaHeaderHelp extends StatelessWidget { + final List items; + + const SomaHeaderHelp({super.key, required this.items}); + + void _show(BuildContext context) { + final button = context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal( + Offset(0, button.size.height + 6), + ancestor: overlay, + ), + button.localToGlobal( + button.size.bottomRight(Offset(0, button.size.height + 6)), + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + + showMenu( + context: context, + position: position, + constraints: const BoxConstraints(maxWidth: 300), + items: [ + PopupMenuItem( + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < items.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(items[i].icon, size: 16, color: SomaColors.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + items[i].text, + style: const TextStyle(fontSize: 12.5, height: 1.3), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon( + Icons.help_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withAlpha(120), + ), + tooltip: 'Ayuda', + visualDensity: VisualDensity.compact, + onPressed: () => _show(context), + ); + } +} diff --git a/flutter_soma_app/lib/core/widgets/soma_logo.dart b/flutter_soma_app/lib/core/widgets/soma_logo.dart new file mode 100644 index 0000000..a29c789 --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_logo.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class SomaLogo extends StatelessWidget { + final double? width; + final double? height; + + const SomaLogo({ + super.key, + this.width, + this.height, + }); + + @override + Widget build(BuildContext context) { + return Image.asset( + 'assets/logo.png', + width: width, + height: height, + fit: BoxFit.contain, + ); + } +} diff --git a/flutter_soma_app/lib/core/widgets/soma_primary_button.dart b/flutter_soma_app/lib/core/widgets/soma_primary_button.dart new file mode 100644 index 0000000..4046d4a --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_primary_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class SomaPrimaryButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final IconData? icon; + + const SomaPrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: isLoading ? null : onPressed, + child: isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.black87, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20), + const SizedBox(width: 8), + ], + Text(text), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/widgets/soma_secondary_button.dart b/flutter_soma_app/lib/core/widgets/soma_secondary_button.dart new file mode 100644 index 0000000..58068fc --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_secondary_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class SomaSecondaryButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final IconData? icon; + + const SomaSecondaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: isLoading ? null : onPressed, + child: isLoading + ? SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Theme.of(context).colorScheme.primary, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20), + const SizedBox(width: 8), + ], + Text(text), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/widgets/soma_text_field.dart b/flutter_soma_app/lib/core/widgets/soma_text_field.dart new file mode 100644 index 0000000..4e6ff84 --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_text_field.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SomaTextField extends StatelessWidget { + final TextEditingController? controller; + final String? hintText; + final String? labelText; + final IconData? prefixIcon; + final Widget? suffixIcon; + final bool obscureText; + final TextInputType keyboardType; + final List? inputFormatters; + final String? Function(String?)? validator; + final void Function(String)? onChanged; + final bool enabled; + final int maxLines; + + const SomaTextField({ + super.key, + this.controller, + this.hintText, + this.labelText, + this.prefixIcon, + this.suffixIcon, + this.obscureText = false, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.validator, + this.onChanged, + this.enabled = true, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + validator: validator, + onChanged: onChanged, + enabled: enabled, + maxLines: maxLines, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), + decoration: InputDecoration( + hintText: hintText, + labelText: labelText, + prefixIcon: prefixIcon != null + ? Icon( + prefixIcon, + color: Theme.of(context).colorScheme.onSurface.withAlpha(153), + size: 22, + ) + : null, + suffixIcon: suffixIcon, + ), + ); + } +} diff --git a/flutter_soma_app/lib/core/widgets/soma_toast.dart b/flutter_soma_app/lib/core/widgets/soma_toast.dart new file mode 100644 index 0000000..8989374 --- /dev/null +++ b/flutter_soma_app/lib/core/widgets/soma_toast.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; + +enum ToastType { success, error, info } + +class SomaToast { + SomaToast._(); + + static void show( + BuildContext context, { + required String message, + ToastType type = ToastType.info, + SnackBarAction? action, + }) { + ScaffoldMessenger.of(context).clearSnackBars(); + + final (Color bg, Color text, IconData icon) = switch (type) { + ToastType.success => (SomaColors.success, Colors.white, Icons.check_circle_outline), + ToastType.error => (SomaColors.error, Colors.white, Icons.error_outline), + ToastType.info => (SomaColors.primary, SomaColors.onPrimary, Icons.info_outline), + }; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(icon, color: text, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + color: text, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + backgroundColor: bg, + duration: Duration(seconds: action != null ? 5 : 3), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + action: action, + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/actividades/data/repositories/actividades_repository_impl.dart b/flutter_soma_app/lib/features/actividades/data/repositories/actividades_repository_impl.dart new file mode 100644 index 0000000..a84ab48 --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/data/repositories/actividades_repository_impl.dart @@ -0,0 +1,65 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; +import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart'; + +class ActividadesRepositoryImpl implements ActividadesRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + @override + Future> getActividades({bool? soloActivas}) async { + final token = await _getToken(); + + final params = {'p_token': token}; + if (soloActivas != null) params['p_solo_activas'] = soloActivas; + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetActividades, + params: params, + ); + + if (response is List) { + return response + .map((e) => Actividad.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future insertActividad(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcInsertActividad, + params: {'p_token': token, 'p_datos': datos}, + ); + } + + @override + Future updateActividad(int id, Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcUpdateActividad, + params: {'p_token': token, 'p_actividad_id': id, 'p_datos': datos}, + ); + } + + @override + Future deleteActividad(int id) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcDeleteActividad, + params: {'p_token': token, 'p_actividad_id': id}, + ); + return response == true; + } +} diff --git a/flutter_soma_app/lib/features/actividades/domain/entities/actividad.dart b/flutter_soma_app/lib/features/actividades/domain/entities/actividad.dart new file mode 100644 index 0000000..ca8b18c --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/domain/entities/actividad.dart @@ -0,0 +1,47 @@ +class Actividad { + final int id; + final String nombre; + final int duracion; // minutos + final int capacidadPorDefecto; + final bool libre; + final bool activo; + + const Actividad({ + required this.id, + required this.nombre, + required this.duracion, + required this.capacidadPorDefecto, + this.libre = false, + this.activo = true, + }); + + factory Actividad.fromMap(Map map) { + return Actividad( + id: map['id'] as int, + nombre: map['nombre'] as String? ?? '', + duracion: (map['duracion'] as num?)?.toInt() ?? 0, + capacidadPorDefecto: (map['capacidad_por_defecto'] as num?)?.toInt() ?? 0, + libre: map['libre'] as bool? ?? false, + activo: map['activo'] as bool? ?? true, + ); + } + + Map toMap() { + return { + 'nombre': nombre, + 'duracion': duracion, + 'capacidad_por_defecto': capacidadPorDefecto, + 'libre': libre, + 'activo': activo, + }; + } + + String get duracionDisplay { + if (duracion >= 60) { + final h = duracion ~/ 60; + final m = duracion % 60; + return m > 0 ? '${h}h ${m}min' : '${h}h'; + } + return '${duracion}min'; + } +} diff --git a/flutter_soma_app/lib/features/actividades/domain/repositories/actividades_repository.dart b/flutter_soma_app/lib/features/actividades/domain/repositories/actividades_repository.dart new file mode 100644 index 0000000..6653a3e --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/domain/repositories/actividades_repository.dart @@ -0,0 +1,8 @@ +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; + +abstract class ActividadesRepository { + Future> getActividades({bool? soloActivas}); + Future insertActividad(Map datos); + Future updateActividad(int id, Map datos); + Future deleteActividad(int id); +} diff --git a/flutter_soma_app/lib/features/actividades/presentation/providers/actividades_provider.dart b/flutter_soma_app/lib/features/actividades/presentation/providers/actividades_provider.dart new file mode 100644 index 0000000..baf81e6 --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/presentation/providers/actividades_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/actividades/data/repositories/actividades_repository_impl.dart'; +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; +import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +String _errorMessage(Object e) { + if (e is PostgrestException) return e.message; + return e.toString().replaceFirst('Exception: ', ''); +} + +final actividadesRepositoryProvider = Provider((ref) { + return ActividadesRepositoryImpl(); +}); + +final actividadesProvider = + StateNotifierProvider>>((ref) { + return ActividadesNotifier(ref.read(actividadesRepositoryProvider)); +}); + +class ActividadesNotifier extends StateNotifier>> { + final ActividadesRepository _repository; + + ActividadesNotifier(this._repository) : super(const AsyncValue.loading()) { + loadActividades(); + } + + Future loadActividades() async { + state = const AsyncValue.loading(); + try { + final actividades = await _repository.getActividades(); + state = AsyncValue.data(actividades); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future insertActividad(Map datos) async { + try { + await _repository.insertActividad(datos); + await loadActividades(); + return null; + } catch (e) { + return _errorMessage(e); + } + } + + Future updateActividad(int id, Map datos) async { + try { + await _repository.updateActividad(id, datos); + await loadActividades(); + return null; + } catch (e) { + return _errorMessage(e); + } + } + + Future deleteActividad(int id) async { + try { + await _repository.deleteActividad(id); + await loadActividades(); + return null; + } catch (e) { + return _errorMessage(e); + } + } +} diff --git a/flutter_soma_app/lib/features/actividades/presentation/screens/actividades_screen.dart b/flutter_soma_app/lib/features/actividades/presentation/screens/actividades_screen.dart new file mode 100644 index 0000000..471a972 --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/presentation/screens/actividades_screen.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/activity_colors.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; +import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart'; +import 'package:gimnasio_soma/features/actividades/presentation/widgets/actividad_form_dialog.dart'; + +class ActividadesScreen extends ConsumerStatefulWidget { + const ActividadesScreen({super.key}); + + @override + ConsumerState createState() => _ActividadesScreenState(); +} + +class _ActividadesScreenState extends ConsumerState { + Future _showCreateDialog() async { + final result = await showDialog>( + context: context, + builder: (_) => const ActividadFormDialog(), + ); + if (result == null || !mounted) return; + + final error = + await ref.read(actividadesProvider.notifier).insertActividad(result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Actividad creada', type: ToastType.success); + } + } + + Future _showEditDialog(Actividad actividad) async { + final result = await showDialog>( + context: context, + builder: (_) => ActividadFormDialog(actividad: actividad), + ); + if (result == null || !mounted) return; + + final error = await ref + .read(actividadesProvider.notifier) + .updateActividad(actividad.id, result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Actividad actualizada', type: ToastType.success); + } + } + + Future _deleteActividad(Actividad actividad) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Eliminar actividad'), + content: Text( + '¿Estás seguro de que querés eliminar "${actividad.nombre}"?\n' + 'Esta acción no se puede deshacer.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Eliminar'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + + final error = await ref + .read(actividadesProvider.notifier) + .deleteActividad(actividad.id); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Actividad eliminada', type: ToastType.success); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(actividadesProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + + return Scaffold( + body: Column( + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + const Text( + 'Actividades', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const Spacer(), + _AddButton(isWide: isWide, onTap: _showCreateDialog), + ], + ), + ), + + // Lista + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => ref + .read(actividadesProvider.notifier) + .loadActividades(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (actividades) { + if (actividades.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.fitness_center_outlined, + size: 56, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(60)), + const SizedBox(height: 12), + Text( + 'No hay actividades', + style: TextStyle( + fontSize: 15, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + color: SomaColors.primary, + onRefresh: () => ref + .read(actividadesProvider.notifier) + .loadActividades(), + child: ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 4, isWide ? 32 : 16, 80, + ), + itemCount: actividades.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final act = actividades[index]; + return _ActividadCard( + actividad: act, + onEdit: () => _showEditDialog(act), + onDelete: () => _deleteActividad(act), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _ActividadCard extends StatelessWidget { + final Actividad actividad; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _ActividadCard({ + required this.actividad, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onEdit, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: Row( + children: [ + // Ícono + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: actividad.activo + ? ActivityColors.forId(actividad.id).withAlpha(30) + : theme.colorScheme.onSurface.withAlpha(15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.fitness_center, + size: 20, + color: actividad.activo + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withAlpha(100), + ), + ), + const SizedBox(width: 14), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + actividad.nombre, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (!actividad.activo) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withAlpha(20), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Inactiva', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: + theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + if (actividad.libre) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.success.withAlpha(20), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Libre', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: SomaColors.success, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + children: [ + Icon(Icons.timer_outlined, + size: 14, + color: + theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(width: 4), + Text( + actividad.duracionDisplay, + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(130), + ), + ), + Text( + ' • ', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(80), + ), + ), + Icon(Icons.group_outlined, + size: 14, + color: + theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(width: 4), + Text( + '${actividad.capacidadPorDefecto} personas', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ], + ), + ), + + // Actions + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Text('Editar'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, size: 18, color: SomaColors.error), + SizedBox(width: 8), + Text('Eliminar', + style: TextStyle(color: SomaColors.error)), + ], + ), + ), + ], + onSelected: (v) { + if (v == 'edit') onEdit(); + if (v == 'delete') onDelete(); + }, + ), + ], + ), + ), + ); + } +} + +class _AddButton extends StatelessWidget { + final bool isWide; + final VoidCallback onTap; + + const _AddButton({required this.isWide, required this.onTap}); + + @override + Widget build(BuildContext context) { + if (isWide) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.add, size: 20), + label: const Text('Nueva'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + ); + } + + return SizedBox( + height: 42, + width: 42, + child: IconButton.filled( + onPressed: onTap, + icon: const Icon(Icons.add, size: 22), + style: IconButton.styleFrom( + backgroundColor: SomaColors.primary, + foregroundColor: SomaColors.onPrimary, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/actividades/presentation/widgets/actividad_form_dialog.dart b/flutter_soma_app/lib/features/actividades/presentation/widgets/actividad_form_dialog.dart new file mode 100644 index 0000000..101b5fe --- /dev/null +++ b/flutter_soma_app/lib/features/actividades/presentation/widgets/actividad_form_dialog.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; + +class ActividadFormDialog extends StatefulWidget { + final Actividad? actividad; + + const ActividadFormDialog({super.key, this.actividad}); + + @override + State createState() => _ActividadFormDialogState(); +} + +class _ActividadFormDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nombreCtrl; + late final TextEditingController _duracionCtrl; + late final TextEditingController _capacidadCtrl; + late bool _libre; + + bool get _isEditing => widget.actividad != null; + + @override + void initState() { + super.initState(); + _nombreCtrl = TextEditingController(text: widget.actividad?.nombre ?? ''); + _duracionCtrl = TextEditingController( + text: widget.actividad != null ? widget.actividad!.duracion.toString() : '', + ); + _capacidadCtrl = TextEditingController( + text: widget.actividad != null + ? widget.actividad!.capacidadPorDefecto.toString() + : '', + ); + _libre = widget.actividad?.libre ?? false; + } + + @override + void dispose() { + _nombreCtrl.dispose(); + _duracionCtrl.dispose(); + _capacidadCtrl.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + + final data = { + 'nombre': _nombreCtrl.text.trim(), + 'duracion': int.tryParse(_duracionCtrl.text.trim()) ?? 0, + 'capacidad_por_defecto': int.tryParse(_capacidadCtrl.text.trim()) ?? 0, + 'libre': _libre, + }; + + Navigator.of(context).pop(data); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 440) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Text( + _isEditing ? 'Editar Actividad' : 'Nueva Actividad', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Form + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SomaTextField( + controller: _nombreCtrl, + labelText: 'Nombre *', + prefixIcon: Icons.fitness_center, + validator: (v) => v == null || v.trim().isEmpty + ? 'Nombre requerido' + : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SomaTextField( + controller: _duracionCtrl, + labelText: 'Duración (min) *', + prefixIcon: Icons.timer_outlined, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Requerido'; + } + final n = int.tryParse(v.trim()); + if (n == null || n <= 0) return 'Inválido'; + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SomaTextField( + controller: _capacidadCtrl, + labelText: 'Capacidad *', + prefixIcon: Icons.group_outlined, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Requerido'; + } + final n = int.tryParse(v.trim()); + if (n == null || n <= 0) return 'Inválido'; + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text( + 'Actividad libre', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: const Text( + 'No requiere turno previo', + style: TextStyle(fontSize: 12), + ), + value: _libre, + activeThumbColor: SomaColors.primary, + contentPadding: EdgeInsets.zero, + onChanged: (v) => setState(() => _libre = v), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: Text(_isEditing ? 'Guardar' : 'Crear'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/data/repositories/auth_repository_impl.dart b/flutter_soma_app/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..6e37196 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,137 @@ +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; +import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthRepositoryImpl implements AuthRepository { + @override + Future login(String dni, String password) async { + // fc_ingresar(dni_input, password_plain_input) → TABLE(token uuid, rol text) + final loginResponse = await SupabaseConfig.rpc( + AppConstants.rpcLogin, + params: { + 'dni_input': dni, + 'password_plain_input': password, + }, + ); + + if (loginResponse == null || + (loginResponse is List && loginResponse.isEmpty)) { + throw Exception('DNI o contraseña incorrectos'); + } + + // Supabase devuelve TABLE como List + final row = (loginResponse as List)[0] as Map; + final token = row['token'] as String; + final rol = row['rol'] as String; + + // Persistir token + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(AppConstants.tokenKey, token); + + // Intentar obtener datos completos del usuario + // fc_obtener_usuario_por_token(p_token, p_user_token) → jsonb + try { + final userData = await SupabaseConfig.rpc( + AppConstants.rpcGetUserByToken, + params: { + 'p_token': token, + 'p_user_token': token, + }, + ); + + if (userData != null && userData is Map) { + return UserSession.fromUserData(userData, token); + } + } catch (_) { + // Si falla (ej: sin permiso 'ver_usuarios'), usamos datos básicos + } + + // Fallback: datos básicos del login + return UserSession.fromLogin(token: token, role: rol, dni: dni); + } + + @override + Future validateSession() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + + if (token == null || token.isEmpty) return null; + + try { + // fc_iniciar_sesion_por_token(p_token) → text (rol), extiende TTL + final rol = await SupabaseConfig.rpc( + AppConstants.rpcIniciarSesionPorToken, + params: {'p_token': token}, + ); + + if (rol == null || (rol is String && rol.isEmpty)) { + await prefs.remove(AppConstants.tokenKey); + return null; + } + + // Intentar obtener datos completos + try { + final userData = await SupabaseConfig.rpc( + AppConstants.rpcGetUserByToken, + params: { + 'p_token': token, + 'p_user_token': token, + }, + ); + + if (userData != null && userData is Map) { + return UserSession.fromUserData(userData, token); + } + } catch (_) { + // Sin permiso, usamos datos básicos + } + + return UserSession(token: token, role: rol as String); + } catch (_) { + await prefs.remove(AppConstants.tokenKey); + return null; + } + } + + @override + Future logout() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + + if (token != null) { + try { + // fc_eliminar_sesion(p_token) → void + await SupabaseConfig.rpc( + AppConstants.rpcDestroySession, + params: {'p_token': token}, + ); + } catch (_) { + // Silently fail - limpiamos el token local de todas formas + } + } + + await prefs.remove(AppConstants.tokenKey); + } + + @override + Future cambiarPropiaContrasena({ + required String passwordActual, + required String passwordNueva, + }) async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + + // fc_cambiar_propia_contrasena(p_token, p_password_actual, p_password_nueva) → void + await SupabaseConfig.rpc( + AppConstants.rpcCambiarPropiaContrasena, + params: { + 'p_token': token, + 'p_password_actual': passwordActual, + 'p_password_nueva': passwordNueva, + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/domain/entities/user_session.dart b/flutter_soma_app/lib/features/auth/domain/entities/user_session.dart new file mode 100644 index 0000000..f4f8115 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/domain/entities/user_session.dart @@ -0,0 +1,62 @@ +class UserSession { + final String token; + final String role; + // UUID del usuario. Null al recién loguear (fc_ingresar no lo devuelve); + // se rellena al primer fc_obtener_usuario_por_token. Necesario para + // ownership checks (ej. "este pago lo creé yo"). + final String? id; + final String nombre; + final String apellido; + final String dni; + final String? mail; + final String? telefono; + + const UserSession({ + required this.token, + required this.role, + this.id, + this.nombre = '', + this.apellido = '', + this.dni = '', + this.mail, + this.telefono, + }); + + /// Crear desde fc_ingresar (solo token + rol) + el DNI que ingresó el usuario. + factory UserSession.fromLogin({ + required String token, + required String role, + required String dni, + }) { + return UserSession(token: token, role: role, dni: dni); + } + + /// Crear desde fc_obtener_usuario_por_token (jsonb completo). + factory UserSession.fromUserData(Map map, String token) { + return UserSession( + token: token, + role: map['rol'] as String? ?? '', + id: map['id'] as String?, + nombre: map['nombre'] as String? ?? '', + apellido: map['apellido'] as String? ?? '', + dni: map['dni'] as String? ?? '', + mail: map['mail'] as String?, + telefono: map['telefono'] as String?, + ); + } + + String get displayName { + if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido'; + if (nombre.isNotEmpty) return nombre; + return dni; + } + + /// Getter unificado: staff incluye superadmin, admin y profesor + bool get isStaff => role == 'superadmin' || role == 'admin' || role == 'profesor'; + + bool get isUsuario => role == 'usuario' || role == 'cliente'; + + /// True sólo para el rol 'superadmin'. Habilita en UI las acciones + /// que el backend gobierna con el permiso 'gestionar_cualquier_pago'. + bool get isSuperadmin => role == 'superadmin'; +} diff --git a/flutter_soma_app/lib/features/auth/domain/repositories/auth_repository.dart b/flutter_soma_app/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..d6fb408 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,11 @@ +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; + +abstract class AuthRepository { + Future login(String dni, String password); + Future validateSession(); + Future logout(); + Future cambiarPropiaContrasena({ + required String passwordActual, + required String passwordNueva, + }); +} diff --git a/flutter_soma_app/lib/features/auth/presentation/providers/auth_provider.dart b/flutter_soma_app/lib/features/auth/presentation/providers/auth_provider.dart new file mode 100644 index 0000000..c27bcc7 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/providers/auth_provider.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/auth/data/repositories/auth_repository_impl.dart'; +import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart'; +import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart'; + +final authRepositoryProvider = Provider((ref) { + return AuthRepositoryImpl(); +}); + +final authStateProvider = + StateNotifierProvider>((ref) { + return AuthNotifier(ref.read(authRepositoryProvider)); +}); + +/// Listenable que notifica al GoRouter cuando cambia el estado de auth. +/// Solo notifica cuando el estado pasa de logueado a no-logueado o viceversa, +/// no cuando pasa a loading (para evitar recrear el router innecesariamente). +final authChangeNotifierProvider = Provider((ref) { + final notifier = AuthChangeNotifier(); + ref.listen>(authStateProvider, (prev, next) { + final wasLoggedIn = prev?.valueOrNull != null; + final isLoggedIn = next.valueOrNull != null; + if (wasLoggedIn != isLoggedIn) { + notifier.notify(); + } + }); + return notifier; +}); + +class AuthChangeNotifier extends ChangeNotifier { + void notify() => notifyListeners(); +} + +class AuthNotifier extends StateNotifier> { + final AuthRepository _repository; + + AuthNotifier(this._repository) : super(const AsyncValue.data(null)); + + Future checkSession() async { + state = const AsyncValue.loading(); + try { + final session = await _repository.validateSession(); + state = AsyncValue.data(session); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future login(String dni, String password) async { + try { + final session = await _repository.login(dni, password); + state = AsyncValue.data(session); + return null; + } catch (e) { + state = const AsyncValue.data(null); + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future logout() async { + await _repository.logout(); + state = const AsyncValue.data(null); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/screens/login_screen.dart b/flutter_soma_app/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..d905dcd --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_desktop.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_mobile.dart'; + +/// Breakpoint para cambiar entre mobile y desktop layout. +const _kDesktopBreakpoint = 800.0; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _animController; + late final Animation _fadeIn; + late final Animation _slideMobile; + late final Animation _slideDesktop; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeIn = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + + // Mobile: form slides up + _slideMobile = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + )); + + // Desktop: form slides in from right + _slideDesktop = Tween( + begin: const Offset(0.08, 0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + )); + + _animController.forward(); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + Future _handleLogin(String dni, String password) async { + final error = await ref + .read(authStateProvider.notifier) + .login(dni, password) + .timeout( + const Duration(seconds: 15), + onTimeout: () => 'Tiempo de espera agotado. Verificá tu conexión.', + ); + + if (!mounted || error == null) return; + + SomaToast.show(context, message: error, type: ToastType.error); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isDesktop = width >= _kDesktopBreakpoint; + + return Scaffold( + body: isDesktop + ? LoginDesktop( + onSubmit: _handleLogin, + fadeIn: _fadeIn, + slideIn: _slideDesktop, + ) + : LoginMobile( + onSubmit: _handleLogin, + fadeIn: _fadeIn, + slideUp: _slideMobile, + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/screens/splash_screen.dart b/flutter_soma_app/lib/features/auth/presentation/screens/splash_screen.dart new file mode 100644 index 0000000..9cd791c --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/screens/splash_screen.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_logo.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkSession()); + } + + Future _checkSession() async { + try { + await ref.read(authStateProvider.notifier).checkSession(); + } catch (_) { + // Error en la validación, ir a login + } + + if (!mounted) return; + + final user = ref.read(authStateProvider).valueOrNull; + if (user == null) { + context.go('/login'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SomaLogo(width: 160), + const SizedBox(height: 32), + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: SomaColors.primary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/widgets/login_desktop.dart b/flutter_soma_app/lib/features/auth/presentation/widgets/login_desktop.dart new file mode 100644 index 0000000..5219d07 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/widgets/login_desktop.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart'; + +class LoginDesktop extends StatelessWidget { + final Future Function(String dni, String password) onSubmit; + final Animation fadeIn; + final Animation slideIn; + + const LoginDesktop({ + super.key, + required this.onSubmit, + required this.fadeIn, + required this.slideIn, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // ── Hero (left half) ── + const Expanded( + flex: 5, + child: LoginHero(), + ), + + // ── Form (right half) ── + Expanded( + flex: 4, + child: FadeTransition( + opacity: fadeIn, + child: SlideTransition( + position: slideIn, + child: Container( + height: double.infinity, + color: Theme.of(context).colorScheme.surface, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 48, + vertical: 40, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LoginForm(onSubmit: onSubmit), + const SizedBox(height: 32), + Center( + child: Text( + 'v0.1.0', + style: TextStyle( + fontSize: 11, + color: SomaColors.darkOnSurfaceVariant + .withAlpha(100), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/widgets/login_form.dart b/flutter_soma_app/lib/features/auth/presentation/widgets/login_form.dart new file mode 100644 index 0000000..d7a422d --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/widgets/login_form.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; + +class LoginForm extends StatefulWidget { + final Future Function(String dni, String password) onSubmit; + + const LoginForm({super.key, required this.onSubmit}); + + @override + State createState() => _LoginFormState(); +} + +class _LoginFormState extends State { + final _formKey = GlobalKey(); + final _dniController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + bool _isLoading = false; + + @override + void dispose() { + _dniController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + await widget.onSubmit( + _dniController.text.trim(), + _passwordController.text, + ); + + if (mounted) setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Iniciar sesión', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + 'Ingresá tus datos para continuar', + style: TextStyle( + fontSize: 14, + color: SomaColors.darkOnSurfaceVariant, + ), + ), + const SizedBox(height: 28), + SomaTextField( + controller: _dniController, + hintText: 'Ej: 12345678', + labelText: 'DNI', + prefixIcon: Icons.badge_outlined, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(8), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return 'El DNI es requerido'; + } + if (value.length < 7) { + return 'DNI inválido'; + } + return null; + }, + ), + const SizedBox(height: 16), + SomaTextField( + controller: _passwordController, + hintText: 'Tu contraseña', + labelText: 'Contraseña', + prefixIcon: Icons.lock_outline, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + size: 20, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'La contraseña es requerida'; + } + if (value.length < 4) { + return 'La contraseña es muy corta'; + } + return null; + }, + ), + const SizedBox(height: 32), + SomaPrimaryButton( + text: 'Ingresar', + onPressed: _handleSubmit, + isLoading: _isLoading, + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/widgets/login_hero.dart b/flutter_soma_app/lib/features/auth/presentation/widgets/login_hero.dart new file mode 100644 index 0000000..9732b31 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/widgets/login_hero.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; + +class LoginHero extends StatelessWidget { + const LoginHero({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1A1A1A), Color(0xFF121212)], + ), + ), + child: Stack( + children: [ + // Decorative circles + Positioned( + top: -60, + left: -60, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: SomaColors.primary.withAlpha(15), + ), + ), + ), + Positioned( + bottom: -40, + right: -40, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: SomaColors.primary.withAlpha(10), + ), + ), + ), + // Content + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/logo.png', + width: 180, + ), + const SizedBox(height: 12), + Text( + 'Gimnasio', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w300, + letterSpacing: 6, + color: SomaColors.darkOnSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/auth/presentation/widgets/login_mobile.dart b/flutter_soma_app/lib/features/auth/presentation/widgets/login_mobile.dart new file mode 100644 index 0000000..162d356 --- /dev/null +++ b/flutter_soma_app/lib/features/auth/presentation/widgets/login_mobile.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart'; +import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart'; + +class LoginMobile extends StatelessWidget { + final Future Function(String dni, String password) onSubmit; + final Animation fadeIn; + final Animation slideUp; + + const LoginMobile({ + super.key, + required this.onSubmit, + required this.fadeIn, + required this.slideUp, + }); + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: SingleChildScrollView( + child: SizedBox( + height: screenHeight, + child: Column( + children: [ + // ── Hero (top 35%) ── + const Expanded( + flex: 35, + child: SafeArea( + bottom: false, + child: LoginHero(), + ), + ), + + // ── Form (bottom 65%) ── + Expanded( + flex: 65, + child: FadeTransition( + opacity: fadeIn, + child: SlideTransition( + position: slideUp, + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(28, 36, 28, 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(32), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(80), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: LoginForm(onSubmit: onSubmit), + ), + const SizedBox(height: 12), + Center( + child: Text( + 'v0.1.0', + style: TextStyle( + fontSize: 11, + color: SomaColors.darkOnSurfaceVariant + .withAlpha(100), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/dev/logs_screen.dart b/flutter_soma_app/lib/features/dev/logs_screen.dart new file mode 100644 index 0000000..5e1b4f8 --- /dev/null +++ b/flutter_soma_app/lib/features/dev/logs_screen.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/services/soma_logger.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; + +class LogsScreen extends StatefulWidget { + const LogsScreen({super.key}); + + @override + State createState() => _LogsScreenState(); +} + +class _LogsScreenState extends State { + LogLevel? _filter; + String _search = ''; + + List get _filteredEntries { + var entries = SomaLogger.instance.entries.reversed.toList(); + if (_filter != null) { + entries = entries.where((e) => e.level == _filter).toList(); + } + if (_search.isNotEmpty) { + final q = _search.toLowerCase(); + entries = entries + .where((e) => + e.message.toLowerCase().contains(q) || + e.tag.toLowerCase().contains(q) || + (e.detail?.toLowerCase().contains(q) ?? false)) + .toList(); + } + return entries; + } + + void _copyAll() { + final text = SomaLogger.instance.export(); + Clipboard.setData(ClipboardData(text: text)); + SomaToast.show(context, + message: 'Logs copiados al portapapeles', type: ToastType.success); + } + + void _clearLogs() { + SomaLogger.instance.clear(); + setState(() {}); + SomaToast.show(context, message: 'Logs limpiados', type: ToastType.info); + } + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width >= 800; + final theme = Theme.of(context); + final entries = _filteredEntries; + + return Scaffold( + body: Column( + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 8, + ), + child: Column( + children: [ + Row( + children: [ + const Text( + 'Logs', + style: + TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(25), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + SomaLogger.instance.platform, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + const Spacer(), + IconButton( + onPressed: _copyAll, + icon: const Icon(Icons.copy, size: 20), + tooltip: 'Copiar logs', + ), + IconButton( + onPressed: _clearLogs, + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: 'Limpiar logs', + ), + IconButton( + onPressed: () => setState(() {}), + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Refrescar', + ), + ], + ), + const SizedBox(height: 8), + // Filtros + Row( + children: [ + Expanded( + child: SizedBox( + height: 36, + child: TextField( + onChanged: (v) => setState(() => _search = v), + decoration: InputDecoration( + hintText: 'Buscar...', + prefixIcon: + const Icon(Icons.search, size: 18), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: + theme.colorScheme.surfaceContainerHighest, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ), + style: const TextStyle(fontSize: 13), + ), + ), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Todos', + selected: _filter == null, + onTap: () => setState(() => _filter = null), + ), + const SizedBox(width: 4), + _FilterChip( + label: 'RPC', + selected: _search == 'RPC', + onTap: () => setState(() { + _search = _search == 'RPC' ? '' : 'RPC'; + }), + color: SomaColors.primary, + ), + const SizedBox(width: 4), + _FilterChip( + label: 'Error', + selected: _filter == LogLevel.error, + onTap: () => setState(() { + _filter = + _filter == LogLevel.error ? null : LogLevel.error; + }), + color: SomaColors.error, + ), + ], + ), + ], + ), + ), + + // Contador + Padding( + padding: EdgeInsets.symmetric(horizontal: isWide ? 32 : 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${entries.length} entradas', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ), + ), + const SizedBox(height: 4), + + // Lista + Expanded( + child: entries.isEmpty + ? Center( + child: Text( + 'Sin logs', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ) + : ListView.builder( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 12, + 0, + isWide ? 32 : 12, + 80, + ), + itemCount: entries.length, + itemBuilder: (context, index) { + return _LogEntryTile(entry: entries[index]); + }, + ), + ), + ], + ), + ); + } +} + +class _LogEntryTile extends StatelessWidget { + final LogEntry entry; + + const _LogEntryTile({required this.entry}); + + Color _levelColor() { + switch (entry.level) { + case LogLevel.debug: + return Colors.grey; + case LogLevel.info: + return Colors.blue; + case LogLevel.warning: + return Colors.orange; + case LogLevel.error: + return SomaColors.error; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ts = + '${entry.timestamp.hour.toString().padLeft(2, '0')}:' + '${entry.timestamp.minute.toString().padLeft(2, '0')}:' + '${entry.timestamp.second.toString().padLeft(2, '0')}'; + final dur = entry.duration != null + ? ' ${entry.duration!.inMilliseconds}ms' + : ''; + + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: entry.detail != null + ? () => _showDetail(context) + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: entry.level == LogLevel.error + ? SomaColors.error.withAlpha(8) + : Colors.transparent, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timestamp + Text( + ts, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: theme.colorScheme.onSurface.withAlpha(80), + ), + ), + const SizedBox(width: 6), + // Level badge + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _levelColor(), + ), + ), + const SizedBox(width: 6), + // Tag + Text( + '[${entry.tag}]', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: theme.colorScheme.onSurface.withAlpha(140), + ), + ), + const SizedBox(width: 6), + // Message + Expanded( + child: Text( + entry.message, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: theme.colorScheme.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // Duration + if (dur.isNotEmpty) + Text( + dur, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + // Detail indicator + if (entry.detail != null) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.info_outline, + size: 14, + color: theme.colorScheme.onSurface.withAlpha(60), + ), + ), + ], + ), + ), + ), + ); + } + + void _showDetail(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + '[${entry.tag}] ${entry.message}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: SelectableText( + entry.detail ?? '', + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + ), + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: '${entry.formatted}\n${entry.detail}')); + Navigator.of(ctx).pop(); + }, + child: const Text('Copiar'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cerrar'), + ), + ], + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + final Color? color; + + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final c = color ?? SomaColors.primary; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: selected ? c.withAlpha(25) : Colors.transparent, + border: Border.all( + color: selected ? c : theme.colorScheme.surfaceContainerHighest, + width: 0.8, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? c : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/dev/widget_gallery_screen.dart b/flutter_soma_app/lib/features/dev/widget_gallery_screen.dart new file mode 100644 index 0000000..1dff774 --- /dev/null +++ b/flutter_soma_app/lib/features/dev/widget_gallery_screen.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/widgets/soma_logo.dart'; +import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart'; +import 'package:gimnasio_soma/core/widgets/soma_secondary_button.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +class WidgetGalleryScreen extends ConsumerStatefulWidget { + const WidgetGalleryScreen({super.key}); + + @override + ConsumerState createState() => + _WidgetGalleryScreenState(); +} + +class _WidgetGalleryScreenState extends ConsumerState { + final _textController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _obscurePassword = true; + + @override + void dispose() { + _textController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _simulateLoading() async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authStateProvider); + final user = authState.valueOrNull; + + return Scaffold( + appBar: AppBar( + title: const Text('Widget Gallery'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => ref.read(authStateProvider.notifier).logout(), + tooltip: 'Cerrar sesión', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + // User Info + if (user != null) ...[ + _SectionTitle('Usuario Logueado'), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Nombre: ${user.displayName}', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 8), + Text( + 'DNI: ${user.dni}', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 8), + Text( + 'Rol: ${user.role}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + ], + + // Logo + _SectionTitle('Logo'), + const Center(child: SomaLogo(width: 200)), + const SizedBox(height: 32), + + // Primary Buttons + _SectionTitle('Botones Primarios'), + SomaPrimaryButton( + text: 'Botón Normal', + onPressed: () => SomaToast.show( + context, + message: 'Botón presionado', + type: ToastType.info, + ), + ), + const SizedBox(height: 12), + SomaPrimaryButton( + text: 'Con Ícono', + icon: Icons.check, + onPressed: () => SomaToast.show( + context, + message: 'Botón con ícono presionado', + type: ToastType.success, + ), + ), + const SizedBox(height: 12), + SomaPrimaryButton( + text: 'Loading...', + isLoading: _isLoading, + onPressed: _simulateLoading, + ), + const SizedBox(height: 12), + const SomaPrimaryButton(text: 'Deshabilitado', onPressed: null), + const SizedBox(height: 32), + + // Secondary Buttons + _SectionTitle('Botones Secundarios'), + SomaSecondaryButton( + text: 'Botón Outline', + onPressed: () => SomaToast.show( + context, + message: 'Botón secundario presionado', + type: ToastType.info, + ), + ), + const SizedBox(height: 12), + SomaSecondaryButton( + text: 'Con Ícono', + icon: Icons.edit, + onPressed: () {}, + ), + const SizedBox(height: 12), + SomaSecondaryButton( + text: 'Loading...', + isLoading: _isLoading, + onPressed: _simulateLoading, + ), + const SizedBox(height: 12), + const SomaSecondaryButton(text: 'Deshabilitado', onPressed: null), + const SizedBox(height: 32), + + // Text Fields + _SectionTitle('Campos de Texto'), + SomaTextField( + controller: _textController, + hintText: 'Ingresá tu nombre', + labelText: 'Nombre', + prefixIcon: Icons.person, + ), + const SizedBox(height: 16), + SomaTextField( + controller: _passwordController, + hintText: 'Ingresá tu contraseña', + labelText: 'Contraseña', + prefixIcon: Icons.lock, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + const SizedBox(height: 16), + const SomaTextField( + hintText: 'Campo deshabilitado', + labelText: 'Deshabilitado', + prefixIcon: Icons.block, + enabled: false, + ), + const SizedBox(height: 32), + + // Toasts + _SectionTitle('Notificaciones (Toasts)'), + ElevatedButton( + onPressed: () => SomaToast.show( + context, + message: 'Operación exitosa', + type: ToastType.success, + ), + child: const Text('Toast Success'), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => SomaToast.show( + context, + message: 'Error en la operación', + type: ToastType.error, + ), + child: const Text('Toast Error'), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => SomaToast.show( + context, + message: 'Información importante', + type: ToastType.info, + ), + child: const Text('Toast Info'), + ), + const SizedBox(height: 32), + + // Colors + _SectionTitle('Paleta de Colores'), + Row( + children: [ + Expanded( + child: _ColorBox( + color: Theme.of(context).colorScheme.primary, + label: 'Primary', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ColorBox( + color: Theme.of(context).colorScheme.surface, + label: 'Surface', + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _ColorBox( + color: Theme.of(context).colorScheme.error, + label: 'Error', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ColorBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + label: 'Surface Variant', + ), + ), + ], + ), + const SizedBox(height: 48), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String title; + + const _SectionTitle(this.title); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} + +class _ColorBox extends StatelessWidget { + final Color color; + final String label; + + const _ColorBox({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + height: 80, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface.withAlpha(51), + ), + ), + child: Center( + child: Text( + label, + style: TextStyle( + color: _getContrastColor(color), + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + Color _getContrastColor(Color background) { + final luminance = background.computeLuminance(); + return luminance > 0.5 ? Colors.black87 : Colors.white; + } +} diff --git a/flutter_soma_app/lib/features/horarios/data/repositories/horarios_repository_impl.dart b/flutter_soma_app/lib/features/horarios/data/repositories/horarios_repository_impl.dart new file mode 100644 index 0000000..d8a0bd7 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/data/repositories/horarios_repository_impl.dart @@ -0,0 +1,175 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; +import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart'; + +class HorariosRepositoryImpl implements HorariosRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + String _formatDate(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + @override + Future obtenerSemana(DateTime weekStart) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerHorarios, + params: { + 'p_token': token, + 'p_fecha_inicio': _formatDate(weekStart), + 'p_cantidad_dias': 7, + }, + ); + + if (response is Map) { + return SemanaHorarios.fromResponse(weekStart, response); + } + return SemanaHorarios.fromResponse(weekStart, {}); + } + + @override + Future guardarDia({ + required DateTime fecha, + required bool esEspecial, + String? motivo, + required List> bloques, + DateTime? validoDesde, + Alcance? alcance, + }) async { + final token = await _getToken(); + + final datos = { + 'fecha': _formatDate(fecha), + 'es_especial': esEspecial, + 'rangos': bloques, + }; + if (esEspecial && motivo != null && motivo.isNotEmpty) { + datos['motivo'] = motivo; + } + if (!esEspecial) { + if (validoDesde != null) { + datos['valido_desde'] = _formatDate(validoDesde); + } + if (alcance != null) { + datos['alcance'] = alcance.toJson(); + } + } + + await SupabaseConfig.rpc( + AppConstants.rpcInsertHorarioConActividades, + params: { + 'p_token': token, + 'p_datos': datos, + }, + ); + } + + @override + Future eliminarDiaEspecial(DateTime fecha) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcEliminarDiaEspecial, + params: { + 'p_token': token, + 'p_fecha': _formatDate(fecha), + }, + ); + } + + @override + Future> listarDiasEspeciales({int dias = 90}) async { + final token = await _getToken(); + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + + // fc_obtener_horarios acepta p_cantidad_dias entre 1 y 31, así que partimos + // el rango pedido en chunks de hasta 31 días y disparamos las llamadas en + // paralelo. + const chunkSize = 31; + final futures = >[]; + for (var offset = 0; offset < dias; offset += chunkSize) { + final restantes = dias - offset; + final tamano = restantes < chunkSize ? restantes : chunkSize; + futures.add( + SupabaseConfig.rpc( + AppConstants.rpcObtenerHorarios, + params: { + 'p_token': token, + 'p_fecha_inicio': _formatDate(hoy.add(Duration(days: offset))), + 'p_cantidad_dias': tamano, + }, + ), + ); + } + + final responses = await Future.wait(futures); + final resumen = []; + for (final response in responses) { + if (response is! Map) continue; + for (final entry in response.entries) { + final dayMap = entry.value; + if (dayMap is! Map) continue; + final tipo = dayMap['tipo'] as String?; + if (tipo == null || tipo == 'normal') continue; + final fecha = DateTime.tryParse(entry.key); + if (fecha == null) continue; + resumen.add(DiaEspecialResumen.fromHorariosResponse(fecha, dayMap)); + } + } + resumen.sort((a, b) => a.fecha.compareTo(b.fecha)); + return resumen; + } + + @override + Future> futurosParaDiaSemana({ + required int diaSemana, + required DateTime desde, + int meses = 6, + }) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerPlanificacionesFuturas, + params: { + 'p_token': token, + 'p_dia_semana': diaSemana, + 'p_desde': _formatDate(desde), + 'p_meses': meses, + }, + ); + + if (response is List) { + return response + .map((e) => PlanificacionFutura.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future contarHuerfanasDesde(DateTime instante) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerReservasHuerfanas, + params: { + 'p_token': token, + 'p_creada_desde': instante.toIso8601String(), + }, + ); + + if (response is List) return response.length; + return 0; + } +} diff --git a/flutter_soma_app/lib/features/horarios/domain/entities/alcance.dart b/flutter_soma_app/lib/features/horarios/domain/entities/alcance.dart new file mode 100644 index 0000000..510af64 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/entities/alcance.dart @@ -0,0 +1,44 @@ +/// Cómo interactúa una edición de plantilla regular con planificaciones +/// futuras existentes para el mismo día de la semana. +/// +/// El backend define tres variantes (ver §5.3 del brief de horarios): +/// * `indefinido` → borra las planificaciones futuras posteriores y deja el +/// nuevo horario sin fecha de fin. +/// * `hasta_proximo` (default backend) → respeta la próxima planificación +/// futura, cerrando el nuevo horario justo antes. +/// * `hasta` + fecha → cierra el nuevo horario en una fecha específica. Si +/// hay planificaciones futuras dentro del intervalo, el +/// backend rechaza con `ConflictoAlcance`. +sealed class Alcance { + const Alcance(); + + /// Serialización aceptada por `fc_insertar_horario_con_actividades`. + Map toJson(); +} + +class AlcanceIndefinido extends Alcance { + const AlcanceIndefinido(); + + @override + Map toJson() => const {'tipo': 'indefinido'}; +} + +class AlcanceHastaProximo extends Alcance { + const AlcanceHastaProximo(); + + @override + Map toJson() => const {'tipo': 'hasta_proximo'}; +} + +class AlcanceHasta extends Alcance { + final DateTime fecha; + + const AlcanceHasta(this.fecha); + + @override + Map toJson() { + final f = '${fecha.year}-${fecha.month.toString().padLeft(2, '0')}-' + '${fecha.day.toString().padLeft(2, '0')}'; + return {'tipo': 'hasta', 'fecha': f}; + } +} diff --git a/flutter_soma_app/lib/features/horarios/domain/entities/dia_especial.dart b/flutter_soma_app/lib/features/horarios/domain/entities/dia_especial.dart new file mode 100644 index 0000000..cb55791 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/entities/dia_especial.dart @@ -0,0 +1,63 @@ +class BloqueActividadEspecial { + final int id; + final String horaInicio; + final String horaFin; + final int actividadId; + final String actividadNombre; + final int actividadDuracion; + + const BloqueActividadEspecial({ + required this.id, + required this.horaInicio, + required this.horaFin, + required this.actividadId, + required this.actividadNombre, + required this.actividadDuracion, + }); + + factory BloqueActividadEspecial.fromMap(Map m) { + final act = m['actividad'] as Map; + return BloqueActividadEspecial( + id: m['id'] as int, + horaInicio: m['hora_inicio'] as String, + horaFin: m['hora_fin'] as String, + actividadId: act['id'] as int, + actividadNombre: act['nombre'] as String, + actividadDuracion: act['duracion'] as int, + ); + } +} + +class DiaEspecialResumen { + final DateTime fecha; + final String tipo; // 'cerrado' | 'horario_diferente' + final String? motivo; + final List rangos; + + const DiaEspecialResumen({ + required this.fecha, + required this.tipo, + this.motivo, + required this.rangos, + }); + + bool get esCerrado => tipo == 'cerrado'; + + /// Construye un resumen a partir del item de día devuelto por + /// `fc_obtener_horarios` (donde la fecha viene como clave del objeto raíz y + /// el valor trae `tipo`, `motivo`, `horarios`). + factory DiaEspecialResumen.fromHorariosResponse( + DateTime fecha, + Map m, + ) { + final rawHorarios = m['horarios'] as List? ?? []; + return DiaEspecialResumen( + fecha: fecha, + tipo: m['tipo'] as String, + motivo: m['motivo'] as String?, + rangos: rawHorarios + .map((e) => BloqueActividadEspecial.fromMap(e as Map)) + .toList(), + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/domain/entities/horario_error.dart b/flutter_soma_app/lib/features/horarios/domain/entities/horario_error.dart new file mode 100644 index 0000000..e374349 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/entities/horario_error.dart @@ -0,0 +1,127 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +String _fmt(DateTime d) => '${d.day} ${_mesesCortos[d.month]} ${d.year}'; + +/// Errores tipados que el módulo de horarios produce al consumir la API de +/// backend. El traductor [fromException] mapea mensajes conocidos de las +/// funciones PL/pgSQL a una variante específica; mensajes no reconocidos +/// caen en [Desconocido] conservando el texto original. +sealed class HorarioError { + const HorarioError(); + + /// Texto en español listo para mostrar al usuario. Distinto del raw del + /// backend: explica el problema y, donde aplica, sugiere la acción. + String mensajeUsuario(); + + static final RegExp _conflictoRegex = RegExp( + r'Conflicto: existe una planificación con valido_desde = ' + r'(\d{4}-\d{2}-\d{2}) dentro del intervalo ' + r'\[(\d{4}-\d{2}-\d{2}), (\d{4}-\d{2}-\d{2})\]', + ); + + static final RegExp _alcanceFechaRegex = RegExp( + r'alcance\.fecha \((\d{4}-\d{2}-\d{2})\) no puede ser anterior a ' + r'valido_desde \((\d{4}-\d{2}-\d{2})\)', + ); + + factory HorarioError.fromException(Object e) { + final raw = e is PostgrestException + ? e.message + : e.toString().replaceFirst('Exception: ', ''); + + final mConflicto = _conflictoRegex.firstMatch(raw); + if (mConflicto != null) { + final vd = DateTime.tryParse(mConflicto.group(1)!); + final id = DateTime.tryParse(mConflicto.group(2)!); + final ih = DateTime.tryParse(mConflicto.group(3)!); + if (vd != null && id != null && ih != null) { + return ConflictoAlcance( + validoDesdeConflicto: vd, + intervaloDesde: id, + intervaloHasta: ih, + ); + } + } + + final mAlcance = _alcanceFechaRegex.firstMatch(raw); + if (mAlcance != null) { + final af = DateTime.tryParse(mAlcance.group(1)!); + final vd = DateTime.tryParse(mAlcance.group(2)!); + if (af != null && vd != null) { + return AlcanceFechaAnterior(alcanceFecha: af, validoDesde: vd); + } + } + + if (raw.contains('valido_desde no puede ser una fecha pasada')) { + return const FechaPasada(); + } + if (raw.contains('No se pueden alterar horarios en fechas pasadas')) { + return const FechaPasadaEspecial(); + } + + return Desconocido(raw); + } +} + +class ConflictoAlcance extends HorarioError { + final DateTime validoDesdeConflicto; + final DateTime intervaloDesde; + final DateTime intervaloHasta; + + const ConflictoAlcance({ + required this.validoDesdeConflicto, + required this.intervaloDesde, + required this.intervaloHasta, + }); + + @override + String mensajeUsuario() => + 'Ya hay un horario planificado para el ${_fmt(validoDesdeConflicto)}, ' + 'que cae dentro del rango elegido (${_fmt(intervaloDesde)} → ' + '${_fmt(intervaloHasta)}). Cambiá el alcance o eliminá esa ' + 'planificación antes de continuar.'; +} + +class FechaPasada extends HorarioError { + const FechaPasada(); + + @override + String mensajeUsuario() => + 'La fecha de vigencia no puede ser anterior a hoy.'; +} + +class AlcanceFechaAnterior extends HorarioError { + final DateTime alcanceFecha; + final DateTime validoDesde; + + const AlcanceFechaAnterior({ + required this.alcanceFecha, + required this.validoDesde, + }); + + @override + String mensajeUsuario() => + 'La fecha de fin (${_fmt(alcanceFecha)}) no puede ser anterior al ' + 'inicio de vigencia (${_fmt(validoDesde)}).'; +} + +class FechaPasadaEspecial extends HorarioError { + const FechaPasadaEspecial(); + + @override + String mensajeUsuario() => + 'No se pueden modificar horarios en fechas pasadas.'; +} + +class Desconocido extends HorarioError { + final String raw; + const Desconocido(this.raw); + + @override + String mensajeUsuario() => raw; +} diff --git a/flutter_soma_app/lib/features/horarios/domain/entities/horario_semana.dart b/flutter_soma_app/lib/features/horarios/domain/entities/horario_semana.dart new file mode 100644 index 0000000..6727343 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/entities/horario_semana.dart @@ -0,0 +1,131 @@ +class BloqueActividadInfo { + final int id; + final String nombre; + final int duracion; + final int capacidad; + + const BloqueActividadInfo({ + required this.id, + required this.nombre, + required this.duracion, + required this.capacidad, + }); + + factory BloqueActividadInfo.fromMap(Map m) { + return BloqueActividadInfo( + id: m['id'] as int, + nombre: m['nombre'] as String, + duracion: m['duracion'] as int, + capacidad: m['capacidad'] as int, + ); + } +} + +class BloqueHorario { + final int id; + final String horaInicio; + final String horaFin; + final BloqueActividadInfo actividad; + + const BloqueHorario({ + required this.id, + required this.horaInicio, + required this.horaFin, + required this.actividad, + }); + + factory BloqueHorario.fromMap(Map m) { + return BloqueHorario( + id: m['id'] as int, + horaInicio: m['hora_inicio'] as String, + horaFin: m['hora_fin'] as String, + actividad: + BloqueActividadInfo.fromMap(m['actividad'] as Map), + ); + } +} + +enum TipoDia { normal, horarioDiferente, cerrado } + +class DiaHorarios { + final DateTime fecha; + final int diaSemana; + final TipoDia tipo; + final String? motivo; + final List bloques; + /// Solo presente para días `normal`: cuándo entró a regir la plantilla. + final DateTime? validoDesde; + /// Solo presente para días `normal`: cuándo deja de regir la plantilla + /// (`null` = vigencia indefinida). + final DateTime? validoHasta; + + const DiaHorarios({ + required this.fecha, + required this.diaSemana, + required this.tipo, + this.motivo, + required this.bloques, + this.validoDesde, + this.validoHasta, + }); + + bool get esCerrado => tipo == TipoDia.cerrado; + bool get esEspecial => tipo != TipoDia.normal; + + factory DiaHorarios.fromMap(DateTime fecha, Map m) { + final tipoStr = m['tipo'] as String; + final tipo = switch (tipoStr) { + 'cerrado' => TipoDia.cerrado, + 'horario_diferente' => TipoDia.horarioDiferente, + _ => TipoDia.normal, + }; + + final rawBloques = m['horarios'] as List? ?? []; + final validoDesdeStr = m['valido_desde'] as String?; + final validoHastaStr = m['valido_hasta'] as String?; + return DiaHorarios( + fecha: fecha, + diaSemana: m['dia_semana'] as int, + tipo: tipo, + motivo: m['motivo'] as String?, + bloques: rawBloques + .map((e) => BloqueHorario.fromMap(e as Map)) + .toList(), + validoDesde: + validoDesdeStr != null ? DateTime.tryParse(validoDesdeStr) : null, + validoHasta: + validoHastaStr != null ? DateTime.tryParse(validoHastaStr) : null, + ); + } +} + +class SemanaHorarios { + final DateTime weekStart; + final Map _byKey; + + SemanaHorarios({required this.weekStart, required Map byKey}) + : _byKey = byKey; + + factory SemanaHorarios.fromResponse(DateTime weekStart, Map raw) { + final byKey = {}; + for (final entry in raw.entries) { + final fecha = DateTime.tryParse(entry.key); + if (fecha == null) continue; + final diaMap = entry.value; + if (diaMap is Map) { + byKey[entry.key] = DiaHorarios.fromMap(fecha, diaMap); + } + } + return SemanaHorarios(weekStart: weekStart, byKey: byKey); + } + + String _key(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + DiaHorarios? diaPara(DateTime fecha) => _byKey[_key(fecha)]; + + List get dias => _byKey.values.toList() + ..sort((a, b) => a.fecha.compareTo(b.fecha)); + + bool contieneFecha(DateTime fecha) => _byKey.containsKey(_key(fecha)); +} diff --git a/flutter_soma_app/lib/features/horarios/domain/entities/planificacion_futura.dart b/flutter_soma_app/lib/features/horarios/domain/entities/planificacion_futura.dart new file mode 100644 index 0000000..d358d24 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/entities/planificacion_futura.dart @@ -0,0 +1,27 @@ +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; + +/// Plantilla regular futura para un día de la semana, tal como la devuelve +/// `fc_obtener_planificaciones_futuras`. Sólo se incluyen plantillas cuyo +/// `valido_desde` es estrictamente posterior al `p_desde` consultado. +class PlanificacionFutura { + final DateTime validoDesde; + final DateTime? validoHasta; + final List rangos; + + const PlanificacionFutura({ + required this.validoDesde, + this.validoHasta, + required this.rangos, + }); + + factory PlanificacionFutura.fromMap(Map m) { + final vh = m['valido_hasta'] as String?; + return PlanificacionFutura( + validoDesde: DateTime.parse(m['valido_desde'] as String), + validoHasta: vh != null ? DateTime.tryParse(vh) : null, + rangos: (m['rangos'] as List? ?? []) + .map((e) => BloqueHorario.fromMap(e as Map)) + .toList(), + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/domain/repositories/horarios_repository.dart b/flutter_soma_app/lib/features/horarios/domain/repositories/horarios_repository.dart new file mode 100644 index 0000000..c674aec --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/domain/repositories/horarios_repository.dart @@ -0,0 +1,48 @@ +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; + +abstract class HorariosRepository { + /// Obtener horarios de una semana (7 días desde [weekStart]). + Future obtenerSemana(DateTime weekStart); + + /// Guardar horario para un día (PUT: reemplaza todos los bloques). + /// [esEspecial]=false → SCD upsert en horario regular. + /// [esEspecial]=true + bloques vacíos → marca el día como cerrado. + /// [esEspecial]=true + bloques → crea horario_diferente. + /// + /// [validoDesde] y [alcance] **sólo aplican a día regular**; el backend + /// los ignora cuando [esEspecial] es true. Si vienen `null`, no se mandan + /// y el backend usa sus defaults (`valido_desde = hoy`, + /// `alcance = hasta_proximo`). + Future guardarDia({ + required DateTime fecha, + required bool esEspecial, + String? motivo, + required List> bloques, + DateTime? validoDesde, + Alcance? alcance, + }); + + /// Eliminar día especial (restaura el horario normal para esa fecha). + Future eliminarDiaEspecial(DateTime fecha); + + /// Listar los días especiales programados en una ventana hacia adelante. + /// [dias] = cantidad de días a inspeccionar desde hoy (default 90). + Future> listarDiasEspeciales({int dias = 90}); + + /// Plantillas regulares con `valido_desde` estrictamente posterior a [desde] + /// para el [diaSemana] dado (1=lunes ... 7=domingo, ISODOW). Ventana de + /// inspección controlada por [meses] (default 6, máx 24). + Future> futurosParaDiaSemana({ + required int diaSemana, + required DateTime desde, + int meses = 6, + }); + + /// Cuenta las reservas huérfanas generadas desde [instante] (UTC). + /// Usado para informar al admin cuántas reservas quedaron sin turno + /// tras una operación de escritura en horarios. + Future contarHuerfanasDesde(DateTime instante); +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/providers/horarios_provider.dart b/flutter_soma_app/lib/features/horarios/presentation/providers/horarios_provider.dart new file mode 100644 index 0000000..427d0d1 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/providers/horarios_provider.dart @@ -0,0 +1,177 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/horarios/data/repositories/horarios_repository_impl.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart'; + +final horariosRepositoryProvider = Provider((ref) { + return HorariosRepositoryImpl(); +}); + +/// Semana actual de horarios. +final horariosProvider = + StateNotifierProvider>((ref) { + return HorariosNotifier(ref, ref.read(horariosRepositoryProvider)); +}); + +class HorariosNotifier extends StateNotifier> { + final Ref _ref; + final HorariosRepository _repository; + DateTime? _currentWeekStart; + + HorariosNotifier(this._ref, this._repository) + : super(const AsyncValue.data(null)); + + /// Refresca las vistas que dependen de los mismos datos que la semana pero + /// que viven en otros providers: la lista de días especiales (puntos + /// naranjas del calendario + pestaña "Especiales") y el mapa de cambios de + /// plantilla a futuro (puntos azules). Se llama tras cada escritura para que + /// ninguna vista quede desincronizada. + /// + /// Usa `invalidate` y no `load()` a propósito: si se hacen varias escrituras + /// seguidas —p. ej. copiar un día a varios destinos— las invalidaciones se + /// fusionan en una sola recarga por vista en vez de una por escritura. + void _refrescarDerivados() { + _ref.invalidate(diasEspecialesProvider); + _ref.invalidate(diasCambioProvider); + } + + Future cargarSemana(DateTime weekStart, {bool force = false}) async { + final monday = _toMonday(weekStart); + if (!force && _currentWeekStart != null && _currentWeekStart == monday) { + return; + } + _currentWeekStart = monday; + state = const AsyncValue.loading(); + try { + final data = await _repository.obtenerSemana(monday); + state = AsyncValue.data(data); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future refrescar() async { + if (_currentWeekStart == null) return; + await cargarSemana(_currentWeekStart!, force: true); + } + + /// Guarda el horario de un día y refresca la semana. + /// + /// Retorna `(null, N)` si tuvo éxito, donde N es la cantidad de reservas + /// que quedaron huérfanas a raíz de la operación (0 = ninguna). + /// Retorna `(HorarioError, 0)` si hubo error. + /// + /// [validoDesde] y [alcance] sólo aplican a día regular; el backend los + /// ignora cuando [esEspecial] es true. Si vienen null se usan los defaults + /// del backend (hoy, `hasta_proximo`). + Future<(HorarioError?, int)> guardarDia({ + required DateTime fecha, + required bool esEspecial, + String? motivo, + required List> bloques, + DateTime? validoDesde, + Alcance? alcance, + }) async { + final preOp = DateTime.now().toUtc(); + try { + await _repository.guardarDia( + fecha: fecha, + esEspecial: esEspecial, + motivo: motivo, + bloques: bloques, + validoDesde: validoDesde, + alcance: alcance, + ); + await refrescar(); + _refrescarDerivados(); + final n = await _repository.contarHuerfanasDesde(preOp); + return (null, n); + } catch (e) { + return (HorarioError.fromException(e), 0); + } + } + + /// Elimina la excepción de un día especial y refresca. + /// + /// Retorna `(null, N)` si tuvo éxito (N = huérfanas generadas), + /// o `(HorarioError, 0)` si hubo error. + Future<(HorarioError?, int)> eliminarDiaEspecial(DateTime fecha) async { + final preOp = DateTime.now().toUtc(); + try { + await _repository.eliminarDiaEspecial(fecha); + await refrescar(); + _refrescarDerivados(); + final n = await _repository.contarHuerfanasDesde(preOp); + return (null, n); + } catch (e) { + return (HorarioError.fromException(e), 0); + } + } + + DateTime _toMonday(DateTime d) => + d.subtract(Duration(days: d.weekday - 1)); +} + +/// Lista de días especiales para la vista auxiliar. +final diasEspecialesProvider = + StateNotifierProvider>>( + (ref) { + return DiasEspecialesNotifier(ref.read(horariosRepositoryProvider)); +}); + +class DiasEspecialesNotifier + extends StateNotifier>> { + final HorariosRepository _repository; + + DiasEspecialesNotifier(this._repository) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + try { + final data = await _repository.listarDiasEspeciales(); + data.sort((a, b) => a.fecha.compareTo(b.fecha)); + state = AsyncValue.data(data); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } +} + +/// Días (normalizados a medianoche) en los que arranca una nueva plantilla +/// regular dentro de los próximos 12 meses. Alimenta los puntos azules del +/// calendario. +/// +/// Es un [FutureProvider] para que el panel lo lea de forma perezosa (sólo al +/// abrirse) y para que [HorariosNotifier] pueda invalidarlo tras cada +/// escritura, manteniéndolo en sync con el resto de las vistas. +final diasCambioProvider = FutureProvider>((ref) async { + final repo = ref.watch(horariosRepositoryProvider); + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + final results = await Future.wait( + List.generate( + 7, + (i) => repo.futurosParaDiaSemana( + diaSemana: i + 1, + desde: hoy, + meses: 12, + ), + ), + ); + final cambios = {}; + for (final list in results) { + for (final p in list) { + cambios.add(DateTime( + p.validoDesde.year, + p.validoDesde.month, + p.validoDesde.day, + )); + } + } + return cambios; +}); diff --git a/flutter_soma_app/lib/features/horarios/presentation/screens/horarios_screen.dart b/flutter_soma_app/lib/features/horarios/presentation/screens/horarios_screen.dart new file mode 100644 index 0000000..81ff14f --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/screens/horarios_screen.dart @@ -0,0 +1,597 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/dias_especiales_view.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/copiar_dia_dialog.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/horarios_calendar_panel.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/semana_tabla_view.dart'; + +enum _HorariosTab { semanal, especiales } + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' +]; +const _meses = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +class HorariosScreen extends ConsumerStatefulWidget { + const HorariosScreen({super.key}); + + @override + ConsumerState createState() => _HorariosScreenState(); +} + +class _HorariosScreenState extends ConsumerState { + _HorariosTab _currentTab = _HorariosTab.semanal; + late DateTime _weekStart; + late DateTime _selectedDay; + List _diasVisibles = [0, 1, 2, 3, 4]; + + @override + void initState() { + super.initState(); + final today = DateTime.now(); + _weekStart = _toMonday(today); + _selectedDay = today; + WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana()); + } + + DateTime _toMonday(DateTime d) => d.subtract(Duration(days: d.weekday - 1)); + + String _fmtShort(DateTime d) => '${d.day} ${_meses[d.month]}'; + + String _weekLabel() { + final end = _weekStart.add(const Duration(days: 6)); + return '${_fmtShort(_weekStart)} – ${_fmtShort(end)} ${end.year}'; + } + + void _cargarSemana() { + ref.read(horariosProvider.notifier).cargarSemana(_weekStart); + } + + void _prevWeek() { + setState(() { + _weekStart = _weekStart.subtract(const Duration(days: 7)); + _selectedDay = _weekStart; + }); + _cargarSemana(); + } + + void _nextWeek() { + setState(() { + _weekStart = _weekStart.add(const Duration(days: 7)); + _selectedDay = _weekStart; + }); + _cargarSemana(); + } + + bool get _isAdmin { + final user = ref.read(authStateProvider).valueOrNull; + return user != null && user.isStaff; + } + + Future _editarDia(DateTime fecha, DiaHorarios? dia) async { + final huerfanas = await showDialog( + context: context, + builder: (_) => EditarDiaDialog( + dia: dia, + fecha: fecha, + weekStart: _weekStart, + ), + ); + if (!mounted) return; + final n = huerfanas ?? 0; + if (n > 0) _showHuerfanasToast(n); + } + + void _showHuerfanasToast(int n) { + SomaToast.show( + context, + message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}', + type: ToastType.info, + action: SnackBarAction( + label: 'Ver', + textColor: SomaColors.onPrimary, + onPressed: () => context.go('/huerfanas'), + ), + ); + } + + Future _eliminarBloque( + DiaHorarios dia, BloqueHorario bloque) async { + final seen = {}; + final restantes = >[]; + for (final b in dia.bloques) { + if (b.id == bloque.id) continue; + final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}'; + if (!seen.add(key)) continue; + restantes.add({ + 'actividad_id': b.actividad.id, + 'hora_inicio': b.horaInicio, + 'hora_fin': b.horaFin, + }); + } + final (error, huerfanas) = await ref.read(horariosProvider.notifier).guardarDia( + fecha: dia.fecha, + esEspecial: dia.esEspecial, + motivo: dia.motivo, + bloques: restantes, + ); + if (!mounted) return; + if (error != null) { + SomaToast.show( + context, + message: error.mensajeUsuario(), + type: ToastType.error, + ); + } else if (huerfanas > 0) { + _showHuerfanasToast(huerfanas); + } + } + + void _navigateToWeek(DateTime fecha) { + setState(() { + _weekStart = _toMonday(fecha); + _selectedDay = fecha; + _currentTab = _HorariosTab.semanal; + }); + _cargarSemana(); + } + + Future _copiarDia(DiaHorarios origen, SemanaHorarios semana) async { + final huerfanas = await showDialog( + context: context, + builder: (_) => CopiarDiaDialog( + origen: origen, + weekStart: _weekStart, + semana: semana, + ), + ); + if (!mounted) return; + if ((huerfanas ?? 0) > 0) _showHuerfanasToast(huerfanas!); + } + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width >= 800; + final isAdmin = _isAdmin; + + return Scaffold( + body: Column( + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 0, + ), + child: Row( + children: [ + const Text( + 'Horarios', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + SomaHeaderHelp( + items: [ + if (isAdmin) + const SomaHelpItem( + icon: Icons.calendar_view_week_outlined, + text: 'Semanal / Especiales: cambiá entre la grilla ' + 'semanal y los días especiales (feriados, eventos).', + ), + const SomaHelpItem( + icon: Icons.chevron_left, + text: 'Las flechas navegan entre semanas.', + ), + const SomaHelpItem( + icon: Icons.tune, + text: 'Días visibles: elegí qué días de la semana se ' + 'muestran en la tabla.', + ), + if (isAdmin) + const SomaHelpItem( + icon: Icons.edit_outlined, + text: 'Editar día: modificá los horarios y ' + 'actividades del día seleccionado.', + ), + if (isAdmin) + const SomaHelpItem( + icon: Icons.copy_outlined, + text: 'Copiar a...: replica los bloques del día ' + 'seleccionado a otros días.', + ), + const SomaHelpItem( + icon: Icons.refresh, + text: 'Recarga los horarios de la semana actual.', + ), + ], + ), + const Spacer(), + if (isAdmin) ...[ + _ViewToggle( + currentTab: _currentTab, + onChanged: (tab) => setState(() => _currentTab = tab), + ), + const SizedBox(width: 8), + ], + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Recargar', + onPressed: () { + if (_currentTab == _HorariosTab.semanal) { + ref.read(horariosProvider.notifier).refrescar(); + } else { + ref.read(diasEspecialesProvider.notifier).load(); + } + }, + ), + ], + ), + ), + + // Content + Expanded( + child: Stack( + children: [ + _currentTab == _HorariosTab.especiales && isAdmin + ? const DiasEspecialesView() + : _SemanalView( + weekStart: _weekStart, + selectedDay: _selectedDay, + isAdmin: isAdmin, + isWide: isWide, + weekLabel: _weekLabel(), + onPrevWeek: _prevWeek, + onNextWeek: _nextWeek, + onDaySelected: (d) => setState(() => _selectedDay = d), + onEditarDia: _editarDia, + onCopiarDia: _copiarDia, + onEliminarBloque: isAdmin ? _eliminarBloque : null, + diasVisibles: _diasVisibles, + onDiasVisiblesChanged: (v) => + setState(() => _diasVisibles = v), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: HorariosCalendarPanel( + onNavigateToWeek: _navigateToWeek, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── View Toggle ──────────────────────────────────────────────────────────────── + +class _ViewToggle extends StatelessWidget { + final _HorariosTab currentTab; + final ValueChanged<_HorariosTab> onChanged; + + const _ViewToggle({required this.currentTab, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.inputDecorationTheme.fillColor, + ), + padding: const EdgeInsets.all(2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ToggleItem( + label: 'Semanal', + selected: currentTab == _HorariosTab.semanal, + onTap: () => onChanged(_HorariosTab.semanal), + ), + _ToggleItem( + label: 'Especiales', + selected: currentTab == _HorariosTab.especiales, + onTap: () => onChanged(_HorariosTab.especiales), + ), + ], + ), + ); + } +} + +class _ToggleItem extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _ToggleItem({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: selected ? theme.colorScheme.surface : Colors.transparent, + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withAlpha(15), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + ); + } +} + +// ── Vista Semanal ────────────────────────────────────────────────────────────── + +class _SemanalView extends ConsumerWidget { + final DateTime weekStart; + final DateTime selectedDay; + final bool isAdmin; + final bool isWide; + final String weekLabel; + final VoidCallback onPrevWeek; + final VoidCallback onNextWeek; + final ValueChanged onDaySelected; + final Future Function(DateTime, DiaHorarios?) onEditarDia; + final Future Function(DiaHorarios, SemanaHorarios) onCopiarDia; + final Future Function(DiaHorarios, BloqueHorario)? onEliminarBloque; + final List diasVisibles; + final ValueChanged> onDiasVisiblesChanged; + + const _SemanalView({ + required this.weekStart, + required this.selectedDay, + required this.isAdmin, + required this.isWide, + required this.weekLabel, + required this.onPrevWeek, + required this.onNextWeek, + required this.onDaySelected, + required this.onEditarDia, + required this.onCopiarDia, + this.onEliminarBloque, + required this.diasVisibles, + required this.onDiasVisiblesChanged, + }); + + void _openDiasConfig(BuildContext context) { + var local = List.from(diasVisibles); + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: const Text('Días visibles'), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + content: SizedBox( + width: 260, + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(7, (i) { + final checked = local.contains(i); + return CheckboxListTile( + title: Text(_diasSemana[i]), + value: checked, + activeColor: SomaColors.primary, + checkColor: SomaColors.onPrimary, + onChanged: (local.length == 1 && checked) + ? null + : (val) { + setLocal(() { + if (val == true) { + local = ([...local, i])..sort(); + } else { + local = + local.where((d) => d != i).toList(); + } + }); + onDiasVisiblesChanged(local); + }, + ); + }), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Listo'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(horariosProvider); + final theme = Theme.of(context); + final hPad = isWide ? 32.0 : 16.0; + + return Column( + children: [ + // Week navigation + Padding( + padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: onPrevWeek, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Text( + weekLabel, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: onNextWeek, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.tune, size: 20), + tooltip: 'Días visibles', + visualDensity: VisualDensity.compact, + onPressed: () => _openDiasConfig(context), + ), + ], + ), + ), + + // Tabla semanal + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => + ref.read(horariosProvider.notifier).refrescar(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (semana) { + if (semana == null) { + return const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ); + } + final dia = semana.diaPara(selectedDay); + return Column( + children: [ + Expanded( + child: SemanaTablaView( + semana: semana, + diasVisibles: diasVisibles, + weekStart: weekStart, + selectedDay: selectedDay, + isAdmin: isAdmin, + onSelectDia: onDaySelected, + onEditarDia: (fecha, dia) { + onDaySelected(fecha); + onEditarDia(fecha, dia); + }, + onCopiarDia: isAdmin + ? (d) => onCopiarDia(d, semana) + : null, + onEliminarBloque: onEliminarBloque, + ), + ), + if (isAdmin) + Padding( + padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => onEditarDia(selectedDay, dia), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar día'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 44), + side: BorderSide( + color: SomaColors.primary.withAlpha(120)), + ), + ), + ), + if (dia != null && + !dia.esEspecial && + dia.bloques.isNotEmpty) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => onCopiarDia(dia, semana), + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Copiar a...'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 44), + side: BorderSide( + color: theme.colorScheme.onSurface + .withAlpha(60)), + foregroundColor: + theme.colorScheme.onSurface.withAlpha(160), + ), + ), + ], + ], + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} + diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/agregar_bloque_dialog.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/agregar_bloque_dialog.dart new file mode 100644 index 0000000..92005fa --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/agregar_bloque_dialog.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart'; +import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart'; + +class AgregarBloqueDialog extends ConsumerStatefulWidget { + const AgregarBloqueDialog({super.key}); + + @override + ConsumerState createState() => + _AgregarBloqueDialogState(); +} + +class _AgregarBloqueDialogState extends ConsumerState { + Actividad? _actividad; + TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0); + TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0); + + int get _duracion => _actividad?.duracion ?? 0; + int get _totalMinutos => + _timeToMinutes(_horaFin) - _timeToMinutes(_horaInicio); + int get _sobrante => _duracion > 0 ? _totalMinutos % _duracion : 0; + bool get _esFaltante => + _duracion > 0 && _totalMinutos > 0 && _totalMinutos < _duracion; + bool get _haySobrante => + _duracion > 0 && _totalMinutos > 0 && !_esFaltante && _sobrante > 0; + + TimeOfDay get _horaFinEfectiva { + if (!_haySobrante) return _horaFin; + final mins = _timeToMinutes(_horaFin) - _sobrante; + return TimeOfDay(hour: mins ~/ 60, minute: mins % 60); + } + + Future _pickTime({required bool isStart}) async { + final initial = isStart ? _horaInicio : _horaFin; + final picked = await showTimePicker( + context: context, + initialTime: initial, + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + ), + ), + child: child!, + ); + }, + ); + if (picked == null) return; + setState(() { + if (isStart) { + _horaInicio = picked; + // Auto-ajustar hora fin si es menor + if (_timeToMinutes(picked) >= _timeToMinutes(_horaFin)) { + _horaFin = TimeOfDay( + hour: (picked.hour + 1) % 24, + minute: picked.minute, + ); + } + } else { + _horaFin = picked; + } + }); + } + + int _timeToMinutes(TimeOfDay t) => t.hour * 60 + t.minute; + + String _formatTime(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + void _submit() { + if (_actividad == null) return; + if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin)) return; + if (_esFaltante) return; + + Navigator.of(context).pop({ + 'actividad_id': _actividad!.id, + '_nombre': _actividad!.nombre, + 'hora_inicio': _formatTime(_horaInicio), + 'hora_fin': _formatTime(_horaFinEfectiva), + }); + } + + @override + Widget build(BuildContext context) { + final actividadesAsync = ref.watch(actividadesProvider); + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + final theme = Theme.of(context); + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 420) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + const Text( + 'Agregar bloque', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Form + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Actividad dropdown + actividadesAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (e, _) => Text( + 'Error cargando actividades', + style: TextStyle(color: SomaColors.error, fontSize: 13), + ), + data: (actividades) { + final activas = + actividades.where((a) => a.activo).toList(); + return DropdownButtonFormField( + initialValue: _actividad?.id, + items: activas + .map((a) => DropdownMenuItem( + value: a.id, + child: Text(a.nombre), + )) + .toList(), + onChanged: (v) { + if (v == null) return; + setState(() => _actividad = + activas.firstWhere((a) => a.id == v)); + }, + decoration: const InputDecoration( + labelText: 'Actividad *', + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + ), + validator: (v) => + v == null ? 'Seleccioná una actividad' : null, + ); + }, + ), + const SizedBox(height: 20), + + // Hora inicio / fin + Row( + children: [ + Expanded( + child: _TimePickerField( + label: 'Desde', + value: _formatTime(_horaInicio), + onTap: () => _pickTime(isStart: true), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Icon(Icons.arrow_forward, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(100)), + ), + Expanded( + child: _TimePickerField( + label: 'Hasta', + value: _formatTime(_horaFin), + onTap: () => _pickTime(isStart: false), + ), + ), + ], + ), + if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin)) + _BloqueWarning( + icon: Icons.error_outline, + color: SomaColors.error, + message: 'La hora de fin debe ser mayor a la de inicio', + ) + else if (_esFaltante) + _BloqueWarning( + icon: Icons.error_outline, + color: SomaColors.error, + message: + 'El rango (${_totalMinutos}min) es menor a la duración mínima de ${_actividad!.nombre} (${_duracion}min). No se puede insertar.', + ) + else if (_haySobrante) + _BloqueWarning( + icon: Icons.info_outline, + color: Colors.amber.shade700, + message: + 'Se recortarán ${_sobrante}min — se insertará hasta ${_formatTime(_horaFinEfectiva)}.', + ), + const SizedBox(height: 8), + ], + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _actividad != null && + _timeToMinutes(_horaInicio) < + _timeToMinutes(_horaFin) && + !_esFaltante + ? _submit + : null, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: const Text('Agregar'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _BloqueWarning extends StatelessWidget { + final IconData icon; + final Color color; + final String message; + + const _BloqueWarning({ + required this.icon, + required this.color, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 15, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + message, + style: TextStyle(color: color, fontSize: 12), + ), + ), + ], + ), + ); + } +} + +class _TimePickerField extends StatelessWidget { + final String label; + final String value; + final VoidCallback onTap; + + const _TimePickerField({ + required this.label, + required this.value, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + ), + child: Row( + children: [ + Icon( + Icons.schedule, + size: 20, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + const SizedBox(width: 10), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/alcance_selector.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/alcance_selector.dart new file mode 100644 index 0000000..40cb3bf --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/alcance_selector.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; + +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +String _fmt(DateTime d) => + '${d.day} ${_mesesCortos[d.month]} ${d.year}'; + +/// Selector tipado para el campo `alcance` del upsert regular. +/// +/// Sólo tiene sentido cuando hay [futuros]; el caller debe encargarse de +/// ocultarlo cuando la lista está vacía. La fecha límite válida para el +/// caso `hasta` se calcula a partir del primer elemento de [futuros] menos +/// un día. +class AlcanceSelector extends StatelessWidget { + final List futuros; + final DateTime validoDesde; + final Alcance alcance; + final ValueChanged onChanged; + + const AlcanceSelector({ + super.key, + required this.futuros, + required this.validoDesde, + required this.alcance, + required this.onChanged, + }) : assert(futuros.length > 0, + 'AlcanceSelector debe recibir al menos una planificación futura'); + + DateTime get _proximo => futuros.first.validoDesde; + DateTime get _lastDateHasta => _proximo.subtract(const Duration(days: 1)); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hayMultiples = futuros.length > 1; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: SomaColors.primary.withAlpha(10), + border: Border.all( + color: SomaColors.primary.withAlpha(60), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 16, color: SomaColors.primaryText), + const SizedBox(width: 6), + Expanded( + child: Text( + hayMultiples + ? 'Hay ${futuros.length} horarios planificados a futuro (próximo: ${_fmt(_proximo)})' + : 'Hay un horario planificado a partir del ${_fmt(_proximo)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '¿Cómo interactúa este cambio con lo ya planificado?', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 10), + _AlcanceOption( + label: 'Respetar planificación futura', + sublabel: + 'El nuevo horario regirá hasta el ${_fmt(_lastDateHasta)}. ' + 'Desde el ${_fmt(_proximo)} se mantiene lo ya planificado.', + selected: alcance is AlcanceHastaProximo, + onTap: () => onChanged(const AlcanceHastaProximo()), + ), + const SizedBox(height: 6), + _AlcanceOption( + label: hayMultiples + ? 'Sobrescribir todas las planificaciones futuras' + : 'Sobrescribir y eliminar la planificación futura', + sublabel: hayMultiples + ? 'Se eliminarán las ${futuros.length} planificaciones futuras para este día. El nuevo horario regirá sin fecha de fin.' + : 'Se eliminará el horario planificado para el ${_fmt(_proximo)}. El nuevo horario regirá sin fecha de fin.', + selected: alcance is AlcanceIndefinido, + destructive: true, + onTap: () => onChanged(const AlcanceIndefinido()), + ), + ], + ), + ); + } +} + +class _AlcanceOption extends StatelessWidget { + final String label; + final String sublabel; + final bool selected; + final bool destructive; + final VoidCallback onTap; + + const _AlcanceOption({ + required this.label, + required this.sublabel, + required this.selected, + required this.onTap, + this.destructive = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = destructive ? SomaColors.error : SomaColors.primary; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: selected ? color.withAlpha(18) : theme.colorScheme.surface, + border: Border.all( + color: selected + ? color.withAlpha(110) + : theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 16, + color: selected + ? color + : theme.colorScheme.onSurface.withAlpha(110), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: selected + ? (destructive + ? SomaColors.error + : SomaColors.primaryText) + : theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + sublabel, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/copiar_dia_dialog.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/copiar_dia_dialog.dart new file mode 100644 index 0000000..29400d0 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/copiar_dia_dialog.dart @@ -0,0 +1,621 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart'; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' +]; + +const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + +/// Diálogo para copiar los bloques de un día regular a uno o más días de la +/// semana. Expone controles de vigencia (valido_desde + alcance) y llama +/// al upsert RPC una vez por destino seleccionado. +/// +/// Retorna `int` (total de huérfanas generadas) o `null` si se canceló. +class CopiarDiaDialog extends ConsumerStatefulWidget { + final DiaHorarios origen; + final DateTime weekStart; + final SemanaHorarios semana; + + const CopiarDiaDialog({ + super.key, + required this.origen, + required this.weekStart, + required this.semana, + }); + + @override + ConsumerState createState() => _CopiarDiaDialogState(); +} + +class _CopiarDiaDialogState extends ConsumerState { + final Set _destinos = {}; + late DateTime _validoDesde; + Alcance _alcance = const AlcanceHastaProximo(); + bool _saving = false; + bool _futurosLoading = false; + List? _futurosCombinados; + Map? _errores; + + int get _origenIdx => widget.origen.diaSemana - 1; // ISODOW 1-based → 0-based + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _validoDesde = DateTime(now.year, now.month, now.day); + } + + Future _cargarFuturos() async { + if (_destinos.isEmpty) { + setState(() { + _futurosCombinados = []; + _futurosLoading = false; + }); + return; + } + + setState(() { + _futurosLoading = true; + _futurosCombinados = null; + }); + + try { + final repo = ref.read(horariosRepositoryProvider); + final lists = await Future.wait( + _destinos.map((i) => repo.futurosParaDiaSemana( + diaSemana: i + 1, + desde: _validoDesde, + )), + ); + if (!mounted) return; + + final todos = lists.expand((l) => l).toList(); + todos.sort((a, b) => a.validoDesde.compareTo(b.validoDesde)); + + setState(() { + _futurosCombinados = todos; + _futurosLoading = false; + if (todos.isEmpty) { + _alcance = const AlcanceIndefinido(); + } else if (_alcance is! AlcanceIndefinido) { + _alcance = const AlcanceHastaProximo(); + } + }); + } catch (_) { + if (!mounted) return; + setState(() { + _futurosCombinados = const []; + _futurosLoading = false; + _alcance = const AlcanceHastaProximo(); + }); + } + } + + Future _onValidoDesdeChanged(DateTime nuevo) async { + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + setState(() { + _validoDesde = nuevo.isBefore(hoy) ? hoy : nuevo; + _futurosCombinados = null; + }); + await _cargarFuturos(); + } + + void _toggleDestino(int i) { + setState(() { + if (_destinos.contains(i)) { + _destinos.remove(i); + } else { + _destinos.add(i); + } + _futurosCombinados = null; + _errores = null; + }); + _cargarFuturos(); + } + + List> get _bloquesCopia { + final seen = {}; + final result = >[]; + for (final b in widget.origen.bloques) { + final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}'; + if (!seen.add(key)) continue; + result.add({ + 'actividad_id': b.actividad.id, + 'hora_inicio': b.horaInicio, + 'hora_fin': b.horaFin, + }); + } + return result; + } + + Future _guardar() async { + setState(() { + _saving = true; + _errores = null; + }); + + final bloques = _bloquesCopia; + final mandarMetadata = _futurosCombinados != null; + final validoDesde = mandarMetadata ? _validoDesde : null; + final alcance = mandarMetadata ? _alcance : null; + + final destinosSorted = _destinos.toList()..sort(); + final errores = {}; + int totalHuerfanas = 0; + + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + + for (final i in destinosSorted) { + // El RPC solo usa la fecha para derivar el ISODOW en días regulares. + // Si la fecha de esta semana ya pasó, avanzamos 7 días para obtener + // el mismo weekday la semana siguiente y evitar el rechazo del backend. + DateTime fecha = widget.weekStart.add(Duration(days: i)); + if (fecha.isBefore(hoy)) fecha = fecha.add(const Duration(days: 7)); + final (error, huerfanas) = + await ref.read(horariosProvider.notifier).guardarDia( + fecha: fecha, + esEspecial: false, + bloques: bloques, + validoDesde: validoDesde, + alcance: alcance, + ); + if (error != null) { + errores[i] = error; + if (error is ConflictoAlcance) { + if (!mounted) return; + setState(() { + _saving = false; + _errores = errores; + _alcance = const AlcanceHastaProximo(); + _futurosCombinados = null; + }); + _cargarFuturos(); + return; + } + } else { + totalHuerfanas += huerfanas; + } + } + + if (!mounted) return; + setState(() => _saving = false); + + if (errores.isEmpty) { + final n = destinosSorted.length; + SomaToast.show( + context, + message: n == 1 + ? 'Horario copiado a ${_diasSemana[destinosSorted.first]}' + : 'Horario copiado a $n días', + type: ToastType.success, + ); + Navigator.of(context).pop(totalHuerfanas); + } else { + setState(() => _errores = errores); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final origenLabel = _diasSemana[_origenIdx]; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 620), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Copiar $origenLabel a...', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: + _saving ? null : () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Body + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Day chips + Text( + 'Días destino', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 8), + _DestinosChips( + origenIdx: _origenIdx, + destinos: _destinos, + onToggle: _toggleDestino, + ), + + // Sub-labels per selected destination + if (_destinos.isNotEmpty) ...[ + const SizedBox(height: 10), + for (final i in _destinos.toList()..sort()) + _DestinoInfo( + label: _diasSemana[i], + diaActual: widget.semana.diaPara( + widget.weekStart.add(Duration(days: i))), + ), + ], + + // Bloques preview + const SizedBox(height: 20), + Text( + 'Actividades a copiar', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 8), + _BloquesPreview(bloques: widget.origen.bloques), + + // Vigencia (only when destinations are selected) + if (_destinos.isNotEmpty) ...[ + const SizedBox(height: 20), + ValidoDesdeSelector( + fecha: _validoDesde, + weekdayTarget: null, + onChanged: _onValidoDesdeChanged, + ), + const SizedBox(height: 12), + if (_futurosLoading && _futurosCombinados == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.primary, + ), + ), + SizedBox(width: 8), + Text( + 'Verificando planificaciones futuras…', + style: TextStyle(fontSize: 12), + ), + ], + ), + ) + else if (_futurosCombinados != null && + _futurosCombinados!.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + 'No hay horarios planificados a futuro: el horario ' + 'copiado regirá de manera indefinida.', + style: TextStyle( + fontSize: 11, + color: + theme.colorScheme.onSurface.withAlpha(140), + fontStyle: FontStyle.italic, + ), + ), + ) + else if (_futurosCombinados != null && + _futurosCombinados!.isNotEmpty) + AlcanceSelector( + futuros: _futurosCombinados!, + validoDesde: _validoDesde, + alcance: _alcance, + onChanged: (a) => setState(() => _alcance = a), + ), + ], + + // Error panel + if (_errores != null && _errores!.isNotEmpty) ...[ + const SizedBox(height: 12), + for (final entry in _errores!.entries) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '${_diasSemana[entry.key]}: ${entry.value.mensajeUsuario()}', + style: const TextStyle( + color: SomaColors.error, fontSize: 12), + ), + ), + ], + + const SizedBox(height: 4), + ], + ), + ), + ), + + const Divider(height: 1), + + // Footer + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + children: [ + const Spacer(), + TextButton( + onPressed: + _saving ? null : () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: + _destinos.isEmpty || _saving || _futurosLoading + ? null + : _guardar, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42)), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.onPrimary, + ), + ) + : Text( + _destinos.isEmpty + ? 'Copiar' + : 'Copiar a ${_destinos.length} ' + '${_destinos.length == 1 ? 'día' : 'días'}', + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Chips de destino (multi-select) ─────────────────────────────────────────── + +class _DestinosChips extends StatelessWidget { + final int origenIdx; + final Set destinos; + final ValueChanged onToggle; + + const _DestinosChips({ + required this.origenIdx, + required this.destinos, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: List.generate(7, (i) { + final isOrigen = i == origenIdx; + final isSelected = destinos.contains(i); + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: i < 6 ? 4 : 0), + child: InkWell( + onTap: isOrigen ? null : () => onToggle(i), + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isOrigen + ? theme.colorScheme.surfaceContainerHighest.withAlpha(30) + : isSelected + ? SomaColors.primary.withAlpha(22) + : theme.colorScheme.surfaceContainerHighest + .withAlpha(60), + border: Border.all( + color: isOrigen + ? theme.colorScheme.surfaceContainerHighest + .withAlpha(60) + : isSelected + ? SomaColors.primary.withAlpha(100) + : theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Center( + child: Text( + _diasCortos[i], + style: TextStyle( + fontSize: 12, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + color: isOrigen + ? theme.colorScheme.onSurface.withAlpha(60) + : isSelected + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + ), + ), + ), + ); + }), + ); + } +} + +// ── Info por destino seleccionado ───────────────────────────────────────────── + +class _DestinoInfo extends StatelessWidget { + final String label; + final DiaHorarios? diaActual; + + const _DestinoInfo({required this.label, required this.diaActual}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bloques = diaActual?.bloques ?? []; + final tieneContenido = bloques.isNotEmpty; + + final String desc; + final Color color; + if (tieneContenido) { + final n = bloques.length; + desc = '$n ${n == 1 ? 'actividad' : 'actividades'} — se reemplazarán'; + color = Colors.orange; + } else { + desc = 'vacío'; + color = theme.colorScheme.onSurface.withAlpha(100); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon(Icons.arrow_forward, size: 12, + color: SomaColors.primary.withAlpha(160)), + const SizedBox(width: 6), + Text( + '$label: ', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + Expanded( + child: Text( + desc, + style: TextStyle(fontSize: 12, color: color), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +// ── Preview de bloques (read-only) ──────────────────────────────────────────── + +class _BloquesPreview extends StatelessWidget { + final List bloques; + + const _BloquesPreview({required this.bloques}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (bloques.isEmpty) { + return Text( + 'Sin actividades', + style: TextStyle( + fontSize: 12, color: theme.colorScheme.onSurface.withAlpha(120)), + ); + } + + final seen = {}; + final unique = []; + for (final b in bloques) { + final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}'; + if (!seen.add(key)) continue; + unique.add(b); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: unique + .map((b) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.surfaceContainerHighest + .withAlpha(50), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 3, + height: 20, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 84, + child: Text( + '${b.horaInicio}–${b.horaFin}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + Expanded( + child: Text( + b.actividad.nombre, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + )) + .toList(), + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/dia_columna.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/dia_columna.dart new file mode 100644 index 0000000..12a3a2f --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/dia_columna.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/horario_actividad_tile.dart'; + +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +class DiaColumna extends StatelessWidget { + final DateTime fecha; + final String nombreDia; + final DiaHorarios? dia; + final bool isSelected; + final bool isAdmin; + final VoidCallback onSelectDia; + final void Function(DateTime, DiaHorarios?) onEditarDia; + final VoidCallback? onCopiarDia; + final void Function(BloqueHorario)? onEliminarBloque; + + const DiaColumna({ + super.key, + required this.fecha, + required this.nombreDia, + required this.dia, + required this.isSelected, + required this.isAdmin, + required this.onSelectDia, + required this.onEditarDia, + this.onCopiarDia, + this.onEliminarBloque, + }); + + @override + Widget build(BuildContext context) { + final col = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DiaHeader( + nombreDia: nombreDia, + fecha: fecha, + dia: dia, + isSelected: isSelected, + onTap: onSelectDia, + ), + Expanded( + child: _DiaBody( + fecha: fecha, + dia: dia, + isAdmin: isAdmin, + onEditarDia: onEditarDia, + onEliminarBloque: onEliminarBloque, + ), + ), + ], + ); + + if (!isAdmin) return col; + + return GestureDetector( + onSecondaryTapDown: (details) { + showContextMenu( + context, + contextMenu: ContextMenu( + position: details.globalPosition, + entries: [ + MenuItem( + label: const Text('Editar día'), + icon: const Icon(Icons.edit_outlined, size: 16), + value: 'edit', + ), + if (onCopiarDia != null) + MenuItem( + label: const Text('Copiar a...'), + icon: const Icon(Icons.copy_outlined, size: 16), + value: 'copy', + ), + ], + ), + onItemSelected: (v) { + if (v == 'edit') onEditarDia(fecha, dia); + if (v == 'copy') onCopiarDia!(); + }, + ); + }, + child: col, + ); + } +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +class _DiaHeader extends StatelessWidget { + final String nombreDia; + final DateTime fecha; + final DiaHorarios? dia; + final bool isSelected; + final VoidCallback onTap; + + const _DiaHeader({ + required this.nombreDia, + required this.fecha, + required this.dia, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final esEspecial = dia?.esEspecial ?? false; + final esCerrado = dia?.esCerrado ?? false; + final hastaLabel = (dia != null && + dia!.tipo == TipoDia.normal && + dia!.validoHasta != null) + ? '→ ${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]}' + : null; + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: InkWell( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.fromLTRB(10, 10, 10, 10), + decoration: BoxDecoration( + color: isSelected + ? SomaColors.primary.withAlpha(28) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isSelected + ? SomaColors.primary + : theme.colorScheme.surfaceContainerHighest, + width: isSelected ? 2 : 1, + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nombreDia, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 1), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '${fecha.day} ${_mesesCortos[fecha.month]}', + style: TextStyle( + fontSize: 11, + color: isSelected + ? SomaColors.primaryText.withAlpha(180) + : theme.colorScheme.onSurface.withAlpha(120), + ), + ), + if (hastaLabel != null) ...[ + const SizedBox(width: 6), + Flexible( + child: Tooltip( + message: + 'Esta plantilla rige hasta el ${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]} ${dia!.validoHasta!.year}.', + child: Text( + hastaLabel, + style: TextStyle( + fontSize: 10, + fontStyle: FontStyle.italic, + color: isSelected + ? SomaColors.primaryText.withAlpha(160) + : theme.colorScheme.onSurface + .withAlpha(100), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ), + ], + ), + ), + if (esCerrado) + _HeaderBadge(icon: Icons.block, color: SomaColors.error) + else if (esEspecial) + _HeaderBadge(icon: Icons.event_note, color: SomaColors.primary), + ], + ), + ), + ), + ); + } +} + +class _HeaderBadge extends StatelessWidget { + final IconData icon; + final Color color; + + const _HeaderBadge({required this.icon, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: color.withAlpha(20), + borderRadius: BorderRadius.circular(4), + ), + child: Icon(icon, size: 12, color: color.withAlpha(200)), + ); + } +} + +// ── Body ─────────────────────────────────────────────────────────────────────── + +class _DiaBody extends StatelessWidget { + final DateTime fecha; + final DiaHorarios? dia; + final bool isAdmin; + final void Function(DateTime, DiaHorarios?) onEditarDia; + final void Function(BloqueHorario)? onEliminarBloque; + + const _DiaBody({ + required this.fecha, + required this.dia, + required this.isAdmin, + required this.onEditarDia, + this.onEliminarBloque, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (dia == null) { + return Center( + child: Text( + '—', + style: TextStyle( + fontSize: 18, + color: theme.colorScheme.onSurface.withAlpha(60), + ), + ), + ); + } + + if (dia!.esCerrado) { + return Container( + color: SomaColors.error.withAlpha(10), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.block, size: 24, color: SomaColors.error.withAlpha(140)), + const SizedBox(height: 6), + Text( + 'Cerrado', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: SomaColors.error.withAlpha(160), + ), + ), + if (dia!.motivo != null && dia!.motivo!.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + dia!.motivo!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + ); + } + + if (dia!.bloques.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.event_busy_outlined, + size: 24, + color: theme.colorScheme.onSurface.withAlpha(50), + ), + const SizedBox(height: 6), + Text( + 'Sin actividades', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(8), + itemCount: dia!.bloques.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final bloque = dia!.bloques[index]; + return HorarioActividadTile( + bloque: bloque, + onTap: isAdmin ? () => onEditarDia(fecha, dia) : null, + onDelete: onEliminarBloque != null + ? () => onEliminarBloque!(bloque) + : null, + ); + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/dias_especiales_view.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/dias_especiales_view.dart new file mode 100644 index 0000000..ff0dd7f --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/dias_especiales_view.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; + +const _meses = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' +]; + +class DiasEspecialesView extends ConsumerWidget { + const DiasEspecialesView({super.key}); + + String _fmtFecha(DateTime d) => + '${_diasSemana[d.weekday - 1]}, ${d.day} de ${_meses[d.month]} ${d.year}'; + + Future _eliminar( + BuildContext context, WidgetRef ref, DiaEspecialResumen dia) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Restaurar horario normal'), + content: Text( + '¿Restaurar el horario regular para el ${_fmtFecha(dia.fecha)}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Restaurar'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + + final (error, huerfanas) = await ref + .read(horariosProvider.notifier) + .eliminarDiaEspecial(dia.fecha); + if (!context.mounted) return; + + if (error != null) { + SomaToast.show( + context, + message: error.mensajeUsuario(), + type: ToastType.error, + ); + } else { + if (huerfanas > 0) { + _showHuerfanasToast(context, huerfanas); + } else { + SomaToast.show( + context, + message: 'Excepción eliminada', + type: ToastType.success, + ); + } + // La recarga de la lista la dispara HorariosNotifier al invalidar + // diasEspecialesProvider tras eliminar la excepción. + } + } + + void _showHuerfanasToast(BuildContext context, int n) { + SomaToast.show( + context, + message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}', + type: ToastType.info, + action: SnackBarAction( + label: 'Ver', + textColor: SomaColors.onPrimary, + onPressed: () => context.go('/huerfanas'), + ), + ); + } + + Future _editar( + BuildContext context, WidgetRef ref, DiaEspecialResumen resumen) async { + // Convert DiaEspecialResumen to DiaHorarios for EditarDiaDialog + final tipo = resumen.esCerrado ? TipoDia.cerrado : TipoDia.horarioDiferente; + final bloques = resumen.rangos + .map((r) => BloqueHorario( + id: r.id, + horaInicio: r.horaInicio, + horaFin: r.horaFin, + actividad: BloqueActividadInfo( + id: r.actividadId, + nombre: r.actividadNombre, + duracion: r.actividadDuracion, + capacidad: 0, + ), + )) + .toList(); + + final diaHorarios = DiaHorarios( + fecha: resumen.fecha, + diaSemana: resumen.fecha.weekday, + tipo: tipo, + motivo: resumen.motivo, + bloques: bloques, + ); + + final huerfanas = await showDialog( + context: context, + builder: (_) => EditarDiaDialog( + dia: diaHorarios, + fecha: resumen.fecha, + weekStart: resumen.fecha.subtract( + Duration(days: resumen.fecha.weekday - 1), + ), + ), + ); + + if (!context.mounted) return; + final n = huerfanas ?? 0; + if (n > 0) _showHuerfanasToast(context, n); + // Si el diálogo guardó algo, HorariosNotifier ya invalidó + // diasEspecialesProvider y la lista se recarga sola; si se canceló, no hay + // nada que refrescar. + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(diasEspecialesProvider); + final theme = Theme.of(context); + final isWide = MediaQuery.of(context).size.width >= 800; + + return state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => + ref.read(diasEspecialesProvider.notifier).load(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (especiales) { + if (especiales.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_note_outlined, + size: 56, + color: theme.colorScheme.onSurface.withAlpha(60)), + const SizedBox(height: 12), + Text( + 'Sin días especiales configurados', + style: TextStyle( + fontSize: 15, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + const SizedBox(height: 6), + Text( + 'Editá un día desde la vista Semanal para marcarlo como especial.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ], + ), + ); + } + + return ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 16, isWide ? 32 : 16, 32, + ), + itemCount: especiales.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final dia = especiales[index]; + return _EspecialCard( + dia: dia, + fechaLabel: _fmtFecha(dia.fecha), + onEditar: () => _editar(context, ref, dia), + onEliminar: () => _eliminar(context, ref, dia), + ); + }, + ); + }, + ); + } +} + +class _EspecialCard extends StatelessWidget { + final DiaEspecialResumen dia; + final String fechaLabel; + final VoidCallback onEditar; + final VoidCallback onEliminar; + + const _EspecialCard({ + required this.dia, + required this.fechaLabel, + required this.onEditar, + required this.onEliminar, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final railColor = dia.esCerrado + ? SomaColors.error.withAlpha(180) + : SomaColors.primary.withAlpha(180); + + return InkWell( + onTap: onEditar, + borderRadius: BorderRadius.circular(12), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: railColor), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 8, 11), + child: Row( + children: [ + // Icon + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: dia.esCerrado + ? SomaColors.error.withAlpha(16) + : SomaColors.primary.withAlpha(18), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + dia.esCerrado ? Icons.block : Icons.schedule, + size: 20, + color: dia.esCerrado + ? SomaColors.error + : SomaColors.primary, + ), + ), + const SizedBox(width: 14), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Text( + fechaLabel, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _TipoBadge(esCerrado: dia.esCerrado), + ], + ), + const SizedBox(height: 3), + Text( + dia.motivo?.isNotEmpty == true + ? dia.motivo! + : dia.esCerrado + ? 'Sin motivo especificado' + : '${dia.rangos.length} actividad${dia.rangos.length == 1 ? '' : 'es'}', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(130), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + // Menu + PopupMenuButton( + icon: Icon(Icons.more_vert, + size: 18, + color: + theme.colorScheme.onSurface.withAlpha(130)), + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Text('Editar'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.restore_outlined, + size: 18, color: SomaColors.error), + SizedBox(width: 8), + Text('Restaurar normal', + style: + TextStyle(color: SomaColors.error)), + ], + ), + ), + ], + onSelected: (v) { + if (v == 'edit') onEditar(); + if (v == 'delete') onEliminar(); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TipoBadge extends StatelessWidget { + final bool esCerrado; + const _TipoBadge({required this.esCerrado}); + + @override + Widget build(BuildContext context) { + final color = esCerrado ? SomaColors.error : SomaColors.primary; + final textColor = esCerrado ? SomaColors.error : SomaColors.primaryText; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withAlpha(60), width: 0.5), + ), + child: Text( + esCerrado ? 'Cerrado' : 'Especial', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/editar_dia_dialog.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/editar_dia_dialog.dart new file mode 100644 index 0000000..5c5bad2 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/editar_dia_dialog.dart @@ -0,0 +1,975 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/agregar_bloque_dialog.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart'; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' +]; +const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +class EditarDiaDialog extends ConsumerStatefulWidget { + final DiaHorarios? dia; + final DateTime fecha; + final DateTime weekStart; + + const EditarDiaDialog({ + super.key, + required this.dia, + required this.fecha, + required this.weekStart, + }); + + @override + ConsumerState createState() => _EditarDiaDialogState(); +} + +class _EditarDiaDialogState extends ConsumerState { + late bool _esEspecial; + late bool _esCerrado; + final _motivoController = TextEditingController(); + late List> _bloques; + late int _selectedWeekdayIndex; + late DateTime _selectedFecha; + bool _saving = false; + HorarioError? _error; + + // Modo Normal: estado de vigencia y alcance del cambio. + late DateTime _validoDesde; + Alcance _alcance = const AlcanceHastaProximo(); + List? _futuros; + bool _futurosLoading = false; + + @override + void initState() { + super.initState(); + _esEspecial = widget.dia?.esEspecial ?? false; + _selectedWeekdayIndex = widget.fecha.weekday - 1; + _selectedFecha = widget.fecha; + _esCerrado = false; + _bloques = []; + _applyDia(widget.dia); + + final hoy = _hoy(); + // CU2.c — editar planificación futura existente: si el día actual ya + // tiene una plantilla vigente con valido_desde futuro, pre-cargamos ese + // valor para que guardar equivalga a editar esa misma planificación. + // Si no, usamos la fecha exacta del calendario que Juani está viendo + // (alineado con el nuevo default del backend: valido_desde = fecha). + // Clampear a hoy por si Juani navega hacia semanas pasadas. + final validoDesdeDia = widget.dia?.validoDesde; + _validoDesde = + (validoDesdeDia != null && validoDesdeDia.isAfter(hoy)) + ? validoDesdeDia + : (widget.fecha.isBefore(hoy) ? hoy : widget.fecha); + + if (!_esEspecial) { + WidgetsBinding.instance.addPostFrameCallback((_) => _cargarFuturos()); + } + } + + DateTime _hoy() { + final n = DateTime.now(); + return DateTime(n.year, n.month, n.day); + } + + Future _cargarFuturos() async { + final diaSemana = _fechaParaGuardar.weekday; // ISODOW 1..7 + setState(() => _futurosLoading = true); + try { + final lista = + await ref.read(horariosRepositoryProvider).futurosParaDiaSemana( + diaSemana: diaSemana, + desde: _validoDesde, + ); + if (!mounted) return; + setState(() { + _futuros = lista; + _futurosLoading = false; + // Si no hay futuros, el alcance es 'indefinido' implícito. + // Si hay, mantenemos el default backend 'hasta_proximo' salvo que ya + // hubiera una elección del usuario distinta. + if (lista.isEmpty) { + _alcance = const AlcanceIndefinido(); + } else if (_alcance is AlcanceHasta) { + // Si la fecha del 'hasta' previa quedó fuera del rango válido tras + // recargar futuros, retrocedemos al default. + final lastValid = + lista.first.validoDesde.subtract(const Duration(days: 1)); + final f = (_alcance as AlcanceHasta).fecha; + if (f.isBefore(_validoDesde) || f.isAfter(lastValid)) { + _alcance = const AlcanceHastaProximo(); + } + } else if (_alcance is AlcanceIndefinido) { + // Mantener selección explícita del usuario. + } else { + _alcance = const AlcanceHastaProximo(); + } + }); + } catch (_) { + if (!mounted) return; + // Si falla, asumimos que no hay futuros conocidos: el backend usará + // su default 'hasta_proximo' al guardar. El usuario verá el formulario + // sin selector hasta que vuelva a abrir. + setState(() { + _futuros = const []; + _futurosLoading = false; + _alcance = const AlcanceHastaProximo(); + }); + } + } + + /// Mutates _esCerrado, _motivoController, _bloques from a DiaHorarios snapshot. + /// Must be called inside setState (or during initState). + void _applyDia(DiaHorarios? dia) { + _esCerrado = dia?.esCerrado ?? false; + _motivoController.text = dia?.motivo ?? ''; + final seen = {}; + _bloques = []; + for (final b in dia?.bloques ?? []) { + final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}'; + if (!seen.add(key)) continue; + _bloques.add({ + 'actividad_id': b.actividad.id, + 'hora_inicio': b.horaInicio, + 'hora_fin': b.horaFin, + '_nombre': b.actividad.nombre, + }); + } + } + + void _onWeekdayChanged(int index) { + final semana = ref.read(horariosProvider).valueOrNull; + final newFecha = widget.weekStart.add(Duration(days: index)); + final nuevoDia = semana?.diaPara(newFecha); + setState(() { + _selectedWeekdayIndex = index; + _error = null; + _applyDia(nuevoDia); + // Si el nuevo día tiene plantilla vigente con valido_desde futuro, + // saltamos a esa fecha (CU2.c). Si no, al próximo día con esa weekday. + final hoy = _hoy(); + final vd = nuevoDia?.validoDesde; + _validoDesde = (vd != null && vd.isAfter(hoy)) + ? vd + : (newFecha.isBefore(hoy) ? hoy : newFecha); + _alcance = const AlcanceHastaProximo(); + _futuros = null; + }); + _cargarFuturos(); + } + + Future _onValidoDesdeChanged(DateTime nuevo) async { + final hoy = _hoy(); + final clamped = nuevo.isBefore(hoy) ? hoy : nuevo; + setState(() { + _validoDesde = clamped; + _futuros = null; + _error = null; + }); + await _cargarFuturos(); + } + + @override + void dispose() { + _motivoController.dispose(); + super.dispose(); + } + + DateTime get _fechaParaGuardar => _esEspecial + ? _selectedFecha + : widget.weekStart.add(Duration(days: _selectedWeekdayIndex)); + + String get _diaLabel => _esEspecial + ? '${_diasSemana[_selectedFecha.weekday - 1]} ${_selectedFecha.day} ${_mesesCortos[_selectedFecha.month]}' + : _diasSemana[_selectedWeekdayIndex]; + + Future _addBloque() async { + final result = await showDialog>( + context: context, + builder: (_) => const AgregarBloqueDialog(), + ); + if (result == null) return; + setState(() => _bloques = [..._bloques, result]); + } + + void _removeBloque(int index) { + setState(() { + _bloques = List>.from(_bloques)..removeAt(index); + }); + } + + Future _guardar() async { + setState(() { + _saving = true; + _error = null; + }); + + final bloquesPayload = _bloques.map((b) { + return { + 'actividad_id': b['actividad_id'], + 'hora_inicio': b['hora_inicio'], + 'hora_fin': b['hora_fin'], + }; + }).toList(); + + // Para modo Normal, solo mandamos alcance/validoDesde si tenemos info + // confiable. Si _futuros vino vacío explícitamente, mandamos los valores + // elegidos. Si es null (todavía cargando o falló), dejamos que decida + // el backend con sus defaults. + final esRegular = !_esEspecial; + final mandarMetadata = esRegular && _futuros != null; + + final (error, huerfanas) = + await ref.read(horariosProvider.notifier).guardarDia( + fecha: _fechaParaGuardar, + esEspecial: _esEspecial, + motivo: _esEspecial ? _motivoController.text.trim() : null, + bloques: _esEspecial && _esCerrado ? [] : bloquesPayload, + validoDesde: mandarMetadata ? _validoDesde : null, + alcance: mandarMetadata ? _alcance : null, + ); + + if (!mounted) return; + setState(() => _saving = false); + + if (error != null) { + setState(() { + _error = error; + // Si el backend rechazó por conflicto de alcance, retrocedemos a + // 'hasta_proximo' para que el usuario reintente con una opción que + // siempre es segura. También refrescamos el mapa de futuros por si + // el conflicto delata una planificación que no teníamos cacheada. + if (error is ConflictoAlcance) { + _alcance = const AlcanceHastaProximo(); + } + }); + if (error is ConflictoAlcance) { + await _cargarFuturos(); + } + } else { + SomaToast.show(context, message: 'Horario guardado', type: ToastType.success); + Navigator.of(context).pop(huerfanas); + } + } + + Future _eliminarExcepcion() async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Restaurar horario normal'), + content: const Text( + 'Se eliminará la excepción y el día volverá a usar el horario regular.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Restaurar'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + + setState(() { + _saving = true; + _error = null; + }); + + final (error, huerfanas) = + await ref.read(horariosProvider.notifier).eliminarDiaEspecial( + widget.dia!.fecha, + ); + + if (!mounted) return; + setState(() => _saving = false); + + if (error != null) { + setState(() => _error = error); + } else { + SomaToast.show( + context, + message: 'Excepción eliminada, se aplica horario regular', + type: ToastType.success, + ); + Navigator.of(context).pop(huerfanas); + } + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + final theme = Theme.of(context); + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 480) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 600), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Editar – $_diaLabel', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: + _saving ? null : () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Body + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tipo toggle + Row( + children: [ + Expanded( + child: _TipoOption( + label: 'Normal', + icon: Icons.calendar_today_outlined, + selected: !_esEspecial, + onTap: () { + setState(() { + _esEspecial = false; + _esCerrado = false; + _selectedWeekdayIndex = + _selectedFecha.weekday - 1; + _futuros = null; + }); + _cargarFuturos(); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _TipoOption( + label: 'Especial', + icon: Icons.event_note_outlined, + selected: _esEspecial, + onTap: () => setState(() { + _esEspecial = true; + _selectedFecha = widget.weekStart + .add(Duration(days: _selectedWeekdayIndex)); + }), + ), + ), + ], + ), + + // Día selector + const SizedBox(height: 16), + if (!_esEspecial) + _WeekdaySelector( + selected: _selectedWeekdayIndex, + onChanged: _onWeekdayChanged, + ) + else + _FechaSelector( + fecha: _selectedFecha, + onChanged: (d) => setState(() => _selectedFecha = d), + ), + + // Vigencia y alcance (sólo modo Normal) + if (!_esEspecial) ...[ + const SizedBox(height: 16), + ValidoDesdeSelector( + fecha: _validoDesde, + weekdayTarget: _fechaParaGuardar.weekday, + onChanged: _onValidoDesdeChanged, + ), + const SizedBox(height: 12), + if (_futurosLoading && _futuros == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.primary, + ), + ), + SizedBox(width: 8), + Text( + 'Verificando planificaciones futuras…', + style: TextStyle(fontSize: 12), + ), + ], + ), + ) + else if (_futuros != null && _futuros!.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + 'No hay horarios planificados a futuro: este ' + 'horario regirá desde el inicio de vigencia ' + 'de manera indefinida.', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(140), + fontStyle: FontStyle.italic, + ), + ), + ) + else if (_futuros != null && _futuros!.isNotEmpty) + AlcanceSelector( + futuros: _futuros!, + validoDesde: _validoDesde, + alcance: _alcance, + onChanged: (a) => setState(() => _alcance = a), + ), + ], + + // Especial options + if (_esEspecial) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _SubOption( + label: 'Horario diferente', + selected: !_esCerrado, + onTap: () => + setState(() => _esCerrado = false), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SubOption( + label: 'Cerrado', + selected: _esCerrado, + isDestructive: true, + onTap: () => + setState(() => _esCerrado = true), + ), + ), + ], + ), + const SizedBox(height: 14), + TextField( + controller: _motivoController, + decoration: const InputDecoration( + labelText: 'Motivo (opcional)', + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + ), + ), + ], + + // Bloques (solo si no está cerrado) + if (!(_esEspecial && _esCerrado)) ...[ + const SizedBox(height: 20), + Text( + 'Actividades', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 6), + if (_bloques.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text( + 'Sin actividades — el día quedará vacío', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ), + ) + else + ...List.generate(_bloques.length, (i) { + final b = _bloques[i]; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _BloqueEditRow( + horaInicio: b['hora_inicio'] as String, + horaFin: b['hora_fin'] as String, + nombre: b['_nombre'] as String? ?? '—', + onDelete: () => _removeBloque(i), + ), + ); + }), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _addBloque, + icon: const Icon(Icons.add, size: 18), + label: const Text('Agregar actividad'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 44), + foregroundColor: SomaColors.primary, + side: BorderSide( + color: SomaColors.primary.withAlpha(100)), + ), + ), + ], + + // Error + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!.mensajeUsuario(), + style: TextStyle( + color: SomaColors.error, + fontSize: 12, + ), + ), + ], + + const SizedBox(height: 4), + ], + ), + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + children: [ + // Eliminar excepción (solo si el día actual ya es especial en BD) + if (widget.dia?.esEspecial == true) + TextButton( + onPressed: _saving ? null : _eliminarExcepcion, + style: TextButton.styleFrom( + foregroundColor: SomaColors.error, + ), + child: const Text('Restaurar normal'), + ), + const Spacer(), + TextButton( + onPressed: + _saving ? null : () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _saving ? null : _guardar, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.onPrimary, + ), + ) + : const Text('Guardar'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _TipoOption extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _TipoOption({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selected + ? SomaColors.primary.withAlpha(22) + : theme.colorScheme.surfaceContainerHighest.withAlpha(80), + border: Border.all( + color: selected + ? SomaColors.primary.withAlpha(100) + : theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, + color: selected + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(130)), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + ); + } +} + +class _SubOption extends StatelessWidget { + final String label; + final bool selected; + final bool isDestructive; + final VoidCallback onTap; + + const _SubOption({ + required this.label, + required this.selected, + this.isDestructive = false, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = isDestructive ? SomaColors.error : SomaColors.primary; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: selected ? color.withAlpha(18) : Colors.transparent, + border: Border.all( + color: selected + ? color.withAlpha(80) + : theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (selected) + Icon(Icons.radio_button_checked, + size: 14, color: color) + else + Icon(Icons.radio_button_unchecked, + size: 14, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected + ? (isDestructive ? SomaColors.error : SomaColors.primaryText) + : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + ); + } +} + +class _BloqueEditRow extends StatelessWidget { + final String horaInicio; + final String horaFin; + final String nombre; + final VoidCallback onDelete; + + const _BloqueEditRow({ + required this.horaInicio, + required this.horaFin, + required this.nombre, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.surfaceContainerHighest.withAlpha(60), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: SomaColors.primary), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 4, 10), + child: Row( + children: [ + SizedBox( + width: 94, + child: Text( + '$horaInicio – $horaFin', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + Container( + width: 1, + height: 24, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: theme.colorScheme.surfaceContainerHighest, + ), + Expanded( + child: Text( + nombre, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon( + Icons.delete_outline, + size: 18, + color: SomaColors.error.withAlpha(180), + ), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 40, minHeight: 40), + onPressed: onDelete, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +// ── Weekday selector (Normal) ────────────────────────────────────────────────── + +class _WeekdaySelector extends StatelessWidget { + final int selected; + final ValueChanged onChanged; + + const _WeekdaySelector({required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: List.generate(_diasCortos.length, (i) { + final isSelected = i == selected; + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: i < _diasCortos.length - 1 ? 4 : 0), + child: InkWell( + onTap: () => onChanged(i), + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? SomaColors.primary.withAlpha(22) + : theme.colorScheme.surfaceContainerHighest.withAlpha(60), + border: Border.all( + color: isSelected + ? SomaColors.primary.withAlpha(100) + : theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Center( + child: Text( + _diasCortos[i], + style: TextStyle( + fontSize: 12, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + ), + ), + ), + ); + }), + ); + } +} + +// ── Date picker button (Especial) ────────────────────────────────────────────── + +class _FechaSelector extends StatelessWidget { + final DateTime fecha; + final ValueChanged onChanged; + + const _FechaSelector({required this.fecha, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final label = + '${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}'; + + return InkWell( + onTap: () async { + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + final initial = fecha.isBefore(hoy) ? hoy : fecha; + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: hoy, + lastDate: DateTime(2100), + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + ), + ), + child: child!, + ), + ); + if (picked != null) onChanged(picked); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.surfaceContainerHighest.withAlpha(60), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.calendar_month_outlined, + size: 18, + color: SomaColors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + Icon( + Icons.expand_more, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ], + ), + ), + ); + } +} + diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/horario_actividad_tile.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/horario_actividad_tile.dart new file mode 100644 index 0000000..4e51040 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/horario_actividad_tile.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:gimnasio_soma/core/theme/activity_colors.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; + +class _CapacidadBadge extends StatelessWidget { + final int capacidad; + const _CapacidadBadge({required this.capacidad}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people_outline, + size: 11, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + const SizedBox(width: 3), + Flexible( + child: Text( + '$capacidad personas', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class HorarioActividadTile extends StatelessWidget { + final BloqueHorario bloque; + final VoidCallback? onTap; + final VoidCallback? onDelete; + + const HorarioActividadTile({ + super.key, + required this.bloque, + this.onTap, + this.onDelete, + }); + + Widget _buildCompactContent(ThemeData theme) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${bloque.horaInicio} – ${bloque.horaFin}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(170), + fontFeatures: const [FontFeature.tabularFigures()], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + bloque.actividad.nombre, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + Widget _buildWideContent(ThemeData theme) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + // Hora — monoespaciada, ancho fijo + SizedBox( + width: 94, + child: Text( + '${bloque.horaInicio} – ${bloque.horaFin}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + + // Separador vertical sutil + Container( + width: 1, + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: theme.colorScheme.surfaceContainerHighest, + ), + + // Nombre actividad + capacidad + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + bloque.actividad.nombre, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + _CapacidadBadge( + capacidad: bloque.actividad.capacidad, + ), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 200; + + final tile = MouseRegion( + cursor: + onTap != null ? SystemMouseCursors.click : MouseCursor.defer, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail de actividad — color por actividad + Container( + width: 4, + color: ActivityColors.forId(bloque.actividad.id), + ), + + // Contenido + Expanded( + child: isCompact + ? _buildCompactContent(theme) + : _buildWideContent(theme), + ), + ], + ), + ), + ), + ), + ), + ); + + if (onDelete == null) return tile; + + return GestureDetector( + onSecondaryTapDown: (details) { + showContextMenu( + context, + contextMenu: ContextMenu( + position: details.globalPosition, + entries: [ + MenuItem( + label: const Text( + 'Eliminar', + style: TextStyle(color: SomaColors.error), + ), + icon: const Icon(Icons.delete_outline, + size: 16, color: SomaColors.error), + value: 'delete', + ), + ], + ), + onItemSelected: (v) { + if (v == 'delete') onDelete!(); + }, + ); + }, + child: tile, + ); + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/horarios_calendar_panel.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/horarios_calendar_panel.dart new file mode 100644 index 0000000..7807318 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/horarios_calendar_panel.dart @@ -0,0 +1,531 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; + +const _colorEspecial = Color(0xFFFF9800); +const _colorCambio = Color(0xFF2196F3); +const _handleWidth = 22.0; +const _panelWidth = 280.0; + +const _mesesLargos = [ + '', + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', +]; + +const _diasCortos = ['L', 'M', 'X', 'J', 'V', 'S', 'D']; + +/// Panel de calendario que se desliza desde el borde derecho de la pantalla. +/// +/// Debe colocarse con [Positioned(right: 0, top: 0, bottom: 0)] dentro de un +/// [Stack] que envuelva el área de contenido. La franja-handle (~22px) siempre +/// está visible en el borde derecho; al hacer clic el panel de 280px se +/// desliza hacia la izquierda superponiéndose sobre la tabla semanal. +/// +/// Indicadores en el calendario: +/// • Naranja → día especial (cualquier tipo) +/// • Azul → arranca nueva plantilla regular ese día +class HorariosCalendarPanel extends ConsumerStatefulWidget { + final ValueChanged onNavigateToWeek; + + const HorariosCalendarPanel({super.key, required this.onNavigateToWeek}); + + @override + ConsumerState createState() => + _HorariosCalendarPanelState(); +} + +class _HorariosCalendarPanelState + extends ConsumerState { + bool _open = false; + late DateTime _month; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _month = DateTime(now.year, now.month); + } + + void _toggle() => setState(() => _open = !_open); + + void _prevMonth() => setState( + () => _month = DateTime(_month.year, _month.month - 1), + ); + + void _nextMonth() => setState( + () => _month = DateTime(_month.year, _month.month + 1), + ); + + @override + Widget build(BuildContext context) { + // Observamos los providers sólo con el panel abierto: así no disparamos sus + // RPC hasta que el usuario lo abre, y mientras está cerrado las + // invalidaciones tras escribir se fusionan en una sola recarga (relevante + // al copiar un día a varios destinos). + final especiales = _open + ? (ref.watch(diasEspecialesProvider).valueOrNull ?? + const []) + : const []; + final especSet = {}; + for (final e in especiales) { + especSet.add(DateTime(e.fecha.year, e.fecha.month, e.fecha.day)); + } + + final cambiosAsync = _open ? ref.watch(diasCambioProvider) : null; + final diasCambio = cambiosAsync?.valueOrNull ?? const {}; + final cargandoCambios = cambiosAsync?.isLoading ?? false; + + // Row: [Panel animado (izq)] [Handle (der)] + // Posicionado con right:0, top:0, bottom:0 desde el parent Stack. + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Panel: crece de 0 → _panelWidth hacia la izquierda + ClipRect( + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + width: _open ? _panelWidth : 0, + child: OverflowBox( + maxWidth: _panelWidth, + alignment: Alignment.centerRight, + child: _PanelContent( + month: _month, + diasEspeciales: especSet, + diasCambio: diasCambio, + cargandoCambios: cargandoCambios, + onPrevMonth: _prevMonth, + onNextMonth: _nextMonth, + onDayTap: (fecha) { + widget.onNavigateToWeek(fecha); + setState(() => _open = false); + }, + ), + ), + ), + ), + // Handle: siempre visible en el borde derecho + _SideHandle(isOpen: _open, onTap: _toggle), + ], + ); + } +} + +// ── Side handle ──────────────────────────────────────────────────────────────── + +class _SideHandle extends StatefulWidget { + final bool isOpen; + final VoidCallback onTap; + + const _SideHandle({required this.isOpen, required this.onTap}); + + @override + State<_SideHandle> createState() => _SideHandleState(); +} + +class _SideHandleState extends State<_SideHandle> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final visible = _hovered || widget.isOpen; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Opacity( + opacity: visible ? 1.0 : 0.0, + child: IgnorePointer( + ignoring: !visible, + child: GestureDetector( + onTap: widget.onTap, + child: Container( + width: _handleWidth, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + left: BorderSide( + color: theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(10), + blurRadius: 4, + offset: const Offset(-2, 0), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.calendar_month_outlined, + size: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + const SizedBox(height: 5), + AnimatedRotation( + turns: widget.isOpen ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + child: Icon( + Icons.chevron_right, + size: 13, + color: theme.colorScheme.onSurface.withAlpha(90), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ── Panel content ────────────────────────────────────────────────────────────── + +class _PanelContent extends StatelessWidget { + final DateTime month; + final Set diasEspeciales; + final Set diasCambio; + final bool cargandoCambios; + final VoidCallback onPrevMonth; + final VoidCallback onNextMonth; + final ValueChanged onDayTap; + + const _PanelContent({ + required this.month, + required this.diasEspeciales, + required this.diasCambio, + required this.cargandoCambios, + required this.onPrevMonth, + required this.onNextMonth, + required this.onDayTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: _panelWidth, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + left: BorderSide( + color: theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + ), + child: Column( + children: [ + _MonthHeader( + month: month, + onPrev: onPrevMonth, + onNext: onNextMonth, + ), + _CalendarGrid( + month: month, + diasEspeciales: diasEspeciales, + diasCambio: diasCambio, + onDayTap: onDayTap, + ), + const SizedBox(height: 10), + _Legend(cargandoCambios: cargandoCambios), + ], + ), + ), + ); + } +} + +// ── Month header ─────────────────────────────────────────────────────────────── + +class _MonthHeader extends StatelessWidget { + final DateTime month; + final VoidCallback onPrev; + final VoidCallback onNext; + + const _MonthHeader({ + required this.month, + required this.onPrev, + required this.onNext, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 6), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, size: 18), + onPressed: onPrev, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + Expanded( + child: Text( + '${_mesesLargos[month.month]} ${month.year}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, size: 18), + onPressed: onNext, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} + +// ── Calendar grid ────────────────────────────────────────────────────────────── + +class _CalendarGrid extends StatelessWidget { + final DateTime month; + final Set diasEspeciales; + final Set diasCambio; + final ValueChanged onDayTap; + + const _CalendarGrid({ + required this.month, + required this.diasEspeciales, + required this.diasCambio, + required this.onDayTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + final firstDay = DateTime(month.year, month.month, 1); + final offset = firstDay.weekday - 1; // Lun=0, Dom=6 + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final rows = ((offset + daysInMonth) / 7).ceil(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Headers de días + Row( + children: _diasCortos.map((d) { + return Expanded( + child: Center( + child: Text( + d, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 2), + // Filas de días + ...List.generate(rows, (row) { + return Row( + children: List.generate(7, (col) { + final dayNum = row * 7 + col - offset + 1; + if (dayNum < 1 || dayNum > daysInMonth) { + return const Expanded(child: SizedBox(height: 34)); + } + final fecha = DateTime(month.year, month.month, dayNum); + return Expanded( + child: _DayCell( + day: dayNum, + isToday: fecha == today, + isEspecial: diasEspeciales.contains(fecha), + isCambio: diasCambio.contains(fecha), + onTap: () => onDayTap(fecha), + ), + ); + }), + ); + }), + ], + ), + ); + } +} + +// ── Day cell ─────────────────────────────────────────────────────────────────── + +class _DayCell extends StatelessWidget { + final int day; + final bool isToday; + final bool isEspecial; + final bool isCambio; + final VoidCallback onTap; + + const _DayCell({ + required this.day, + required this.isToday, + required this.isEspecial, + required this.isCambio, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + height: 34, + margin: const EdgeInsets.all(1), + decoration: isToday + ? BoxDecoration( + color: SomaColors.primary.withAlpha(50), + borderRadius: BorderRadius.circular(6), + ) + : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$day', + style: TextStyle( + fontSize: 12, + fontWeight: isToday ? FontWeight.w700 : FontWeight.w500, + color: isToday + ? SomaColors.primaryText + : theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + // Espacio reservado siempre para mantener altura uniforme + SizedBox( + height: 5, + child: (isEspecial || isCambio) + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (isEspecial) const _Dot(color: _colorEspecial), + if (isEspecial && isCambio) const SizedBox(width: 2), + if (isCambio) const _Dot(color: _colorCambio), + ], + ) + : null, + ), + ], + ), + ), + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + const _Dot({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 4, + height: 4, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +// ── Legend ───────────────────────────────────────────────────────────────────── + +class _Legend extends StatelessWidget { + final bool cargandoCambios; + const _Legend({required this.cargandoCambios}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + _LegendItem(color: _colorEspecial, label: 'Día especial'), + const SizedBox(width: 14), + if (cargandoCambios) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: theme.colorScheme.onSurface.withAlpha(80), + ), + ), + const SizedBox(width: 5), + Text( + 'Cargando...', + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ], + ) + else + _LegendItem(color: _colorCambio, label: 'Nuevo horario'), + ], + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final Color color; + final String label; + const _LegendItem({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/semana_tabla_view.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/semana_tabla_view.dart new file mode 100644 index 0000000..2599a95 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/semana_tabla_view.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/widgets/dia_columna.dart'; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo', +]; + +const _minColumnWidth = 160.0; + +bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +class SemanaTablaView extends StatelessWidget { + final SemanaHorarios semana; + + /// Índices de días a mostrar (0 = Lunes … 6 = Domingo). + final List diasVisibles; + final DateTime weekStart; + final DateTime? selectedDay; + final bool isAdmin; + final void Function(DateTime) onSelectDia; + final void Function(DateTime, DiaHorarios?) onEditarDia; + final void Function(DiaHorarios)? onCopiarDia; + final void Function(DiaHorarios, BloqueHorario)? onEliminarBloque; + + const SemanaTablaView({ + super.key, + required this.semana, + required this.diasVisibles, + required this.weekStart, + required this.selectedDay, + required this.isAdmin, + required this.onSelectDia, + required this.onEditarDia, + this.onCopiarDia, + this.onEliminarBloque, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final count = diasVisibles.length; + + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth; + final useScroll = available < count * _minColumnWidth; + + final rowChildren = []; + + for (int i = 0; i < count; i++) { + final diaIdx = diasVisibles[i]; + final fecha = weekStart.add(Duration(days: diaIdx)); + final dia = semana.diaPara(fecha); + final isSelected = + selectedDay != null && _isSameDay(fecha, selectedDay!); + + if (i > 0) { + rowChildren.add(Container( + width: 1, + color: theme.colorScheme.surfaceContainerHighest, + )); + } + + final columna = DiaColumna( + fecha: fecha, + nombreDia: _diasSemana[diaIdx], + dia: dia, + isSelected: isSelected, + isAdmin: isAdmin, + onSelectDia: () => onSelectDia(fecha), + onEditarDia: (f, d) => onEditarDia(f, d), + onCopiarDia: dia != null && + !dia.esEspecial && + dia.bloques.isNotEmpty && + onCopiarDia != null + ? () => onCopiarDia!(dia) + : null, + onEliminarBloque: dia != null && onEliminarBloque != null + ? (bloque) => onEliminarBloque!(dia, bloque) + : null, + ); + + rowChildren.add( + useScroll + ? SizedBox(width: _minColumnWidth, child: columna) + : Expanded(child: columna), + ); + } + + final row = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rowChildren, + ); + + if (!useScroll) return row; + + // Ancho total: columnas + separadores de 1px + final totalWidth = + count * _minColumnWidth + (count - 1).toDouble(); + + return Scrollbar( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: totalWidth, + height: constraints.maxHeight, + child: row, + ), + ), + ); + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/horarios/presentation/widgets/valido_desde_selector.dart b/flutter_soma_app/lib/features/horarios/presentation/widgets/valido_desde_selector.dart new file mode 100644 index 0000000..c14f2a4 --- /dev/null +++ b/flutter_soma_app/lib/features/horarios/presentation/widgets/valido_desde_selector.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' +]; + +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +/// Selector de fecha para el campo `valido_desde` de un horario regular. +/// +/// [weekdayTarget] (1=Lun..7=Dom) restringe la selección a fechas del mismo +/// día de la semana que el día siendo editado. Pasar `null` para permitir +/// cualquier fecha (útil cuando se copia a múltiples días de la semana). +class ValidoDesdeSelector extends StatelessWidget { + final DateTime fecha; + final int? weekdayTarget; + final ValueChanged onChanged; + + const ValidoDesdeSelector({ + super.key, + required this.fecha, + required this.weekdayTarget, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final label = + '${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Entra en vigor el', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 6), + InkWell( + onTap: () async { + final now = DateTime.now(); + final hoy = DateTime(now.year, now.month, now.day); + final target = weekdayTarget; + + final DateTime initial; + if (target != null) { + if (!fecha.isBefore(hoy) && fecha.weekday == target) { + initial = fecha; + } else { + final diff = (target - hoy.weekday + 7) % 7; + initial = hoy.add(Duration(days: diff)); + } + } else { + initial = fecha.isBefore(hoy) ? hoy : fecha; + } + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: hoy, + lastDate: DateTime(2100), + selectableDayPredicate: + target != null ? (d) => d.weekday == target : null, + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + ), + ), + child: child!, + ), + ); + if (picked != null) onChanged(picked); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.surfaceContainerHighest.withAlpha(60), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + children: [ + const Icon(Icons.schedule, size: 18, color: SomaColors.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + Icon( + Icons.expand_more, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/huerfanas/data/repositories/huerfanas_repository_impl.dart b/flutter_soma_app/lib/features/huerfanas/data/repositories/huerfanas_repository_impl.dart new file mode 100644 index 0000000..c2aa185 --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/data/repositories/huerfanas_repository_impl.dart @@ -0,0 +1,62 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart'; + +class HuerfanasRepositoryImpl implements HuerfanasRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + @override + Future> obtenerHuerfanas({String? estado}) async { + final token = await _getToken(); + + final params = {'p_token': token}; + if (estado != null) params['p_estado'] = estado; + + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerReservasHuerfanas, + params: params, + ); + + if (response is List) { + return response + .map((e) => ReservaHuerfana.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future resolverHuerfana(String huerfanaId, String nuevoEstado) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcResolverHuerfana, + params: { + 'p_token': token, + 'p_huerfana_id': huerfanaId, + 'p_nuevo_estado': nuevoEstado, + }, + ); + } + + @override + Future moverHuerfana(String huerfanaId, String turnoId) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcMoverReservaHuerfana, + params: { + 'p_token': token, + 'p_huerfana_id': huerfanaId, + 'p_turno_id': turnoId, + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/huerfanas/domain/entities/reserva_huerfana.dart b/flutter_soma_app/lib/features/huerfanas/domain/entities/reserva_huerfana.dart new file mode 100644 index 0000000..fcb66a2 --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/domain/entities/reserva_huerfana.dart @@ -0,0 +1,59 @@ +enum EstadoHuerfana { pendiente, reubicado, resuelta } + +class ReservaHuerfana { + final String huerfanaId; + final String clienteId; + final String nombre; + final String? apellido; + final String? telefono; + final String actividadNombre; + final String fechaOriginal; + final String horaInicioOriginal; + final EstadoHuerfana estado; + final String creadaEn; + + const ReservaHuerfana({ + required this.huerfanaId, + required this.clienteId, + required this.nombre, + this.apellido, + this.telefono, + required this.actividadNombre, + required this.fechaOriginal, + required this.horaInicioOriginal, + required this.estado, + required this.creadaEn, + }); + + String get displayName => + apellido != null ? '$nombre $apellido' : nombre; + + String get initials { + final parts = displayName.trim().split(' '); + if (parts.length == 1) return parts[0][0].toUpperCase(); + return '${parts[0][0]}${parts.last[0]}'.toUpperCase(); + } + + static EstadoHuerfana _parseEstado(String s) { + return switch (s) { + 'reubicado' => EstadoHuerfana.reubicado, + 'resuelta' => EstadoHuerfana.resuelta, + _ => EstadoHuerfana.pendiente, + }; + } + + factory ReservaHuerfana.fromMap(Map m) { + return ReservaHuerfana( + huerfanaId: m['huerfana_id'] as String, + clienteId: m['cliente_id'] as String, + nombre: m['nombre'] as String, + apellido: m['apellido'] as String?, + telefono: m['telefono'] as String?, + actividadNombre: m['actividad_nombre'] as String, + fechaOriginal: m['fecha_original'] as String, + horaInicioOriginal: m['hora_inicio_original'] as String, + estado: _parseEstado(m['estado_resolucion'] as String? ?? 'pendiente'), + creadaEn: m['creada_en'] as String, + ); + } +} diff --git a/flutter_soma_app/lib/features/huerfanas/domain/repositories/huerfanas_repository.dart b/flutter_soma_app/lib/features/huerfanas/domain/repositories/huerfanas_repository.dart new file mode 100644 index 0000000..50450e5 --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/domain/repositories/huerfanas_repository.dart @@ -0,0 +1,13 @@ +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; + +abstract class HuerfanasRepository { + /// [estado] puede ser 'pendiente', 'reubicado', 'resuelta', o null para todos. + Future> obtenerHuerfanas({String? estado}); + + /// [nuevoEstado] debe ser 'pendiente', 'reubicado' o 'resuelta'. + Future resolverHuerfana(String huerfanaId, String nuevoEstado); + + /// Reserva [turnoId] para el cliente de la huérfana y la marca como 'reubicado' + /// en una sola transacción atómica. + Future moverHuerfana(String huerfanaId, String turnoId); +} diff --git a/flutter_soma_app/lib/features/huerfanas/presentation/providers/huerfanas_provider.dart b/flutter_soma_app/lib/features/huerfanas/presentation/providers/huerfanas_provider.dart new file mode 100644 index 0000000..79c987b --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/presentation/providers/huerfanas_provider.dart @@ -0,0 +1,140 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/features/huerfanas/data/repositories/huerfanas_repository_impl.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; + +final huerfanasRepositoryProvider = Provider((ref) { + return HuerfanasRepositoryImpl(); +}); + +String _errorMessage(Object e) { + if (e is PostgrestException) return e.message; + return e.toString().replaceFirst('Exception: ', ''); +} + +final huerfanasProvider = + StateNotifierProvider>>( + (ref) { + return HuerfanasNotifier(ref, ref.read(huerfanasRepositoryProvider)); +}); + +class HuerfanasNotifier + extends StateNotifier>> { + final Ref _ref; + final HuerfanasRepository _repository; + String? _currentEstado = 'pendiente'; + + HuerfanasNotifier(this._ref, this._repository) + : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + try { + final data = await _repository.obtenerHuerfanas(estado: _currentEstado); + state = AsyncValue.data(data); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future filtrar(String? estado) async { + _currentEstado = estado; + await load(); + } + + String? get currentEstado => _currentEstado; + + /// Retorna null si tuvo éxito, o un mensaje de error. + Future resolver(String huerfanaId, String nuevoEstado) async { + try { + await _repository.resolverHuerfana(huerfanaId, nuevoEstado); + await load(); + return null; + } catch (e) { + return _errorMessage(e); + } + } + + /// Reserva el turno y marca la huérfana como 'reubicado' atómicamente. + /// Retorna null si tuvo éxito, o un mensaje de error. + /// + /// La reubicación ocupa un cupo en [turnoId]. Invalidamos turnosProvider + /// para que la pantalla de Turnos no muestre un cupo desactualizado si ya + /// tenía esa semana cacheada de antes. + Future mover(String huerfanaId, String turnoId) async { + try { + await _repository.moverHuerfana(huerfanaId, turnoId); + await load(); + _ref.invalidate(turnosProvider); + return null; + } catch (e) { + return _errorMessage(e); + } + } + + /// Marca todas las huérfanas del conjunto como 'resuelta' (best-effort). + Future notificarLote(Iterable ids) async { + for (final id in ids) { + try { + await _repository.resolverHuerfana(id, 'resuelta'); + } catch (_) { + // best-effort: continúa con las demás aunque alguna falle + } + } + await load(); + } +} + +// ── Selección múltiple ──────────────────────────────────────────────────────── + +final huerfanasModoSeleccionProvider = StateProvider((ref) => false); + +class _SeleccionNotifier extends StateNotifier> { + _SeleccionNotifier() : super({}); + + void toggle(String id) { + final next = {...state}; + if (next.contains(id)) { + next.remove(id); + } else { + next.add(id); + } + state = next; + } + + void limpiar() => state = {}; +} + +final huerfanasSeleccionProvider = + StateNotifierProvider<_SeleccionNotifier, Set>( + (ref) => _SeleccionNotifier()); + +// ── Badge sidebar ───────────────────────────────────────────────────────────── + +/// Cantidad de reservas huérfanas pendientes — usado para el badge en sidebar. +/// +/// Observa [huerfanasProvider] para recomputarse tras cualquier mutación. +/// Si el filtro activo es 'pendiente' o null derivamos el conteo en memoria +/// (sin RPC extra). Si el filtro es otro, hacemos una consulta independiente. +final huerfanasPendienteCountProvider = + FutureProvider.autoDispose((ref) async { + final state = ref.watch(huerfanasProvider); + final notifier = ref.read(huerfanasProvider.notifier); + + final lista = state.valueOrNull; + if (lista != null) { + if (notifier.currentEstado == 'pendiente') return lista.length; + if (notifier.currentEstado == null) { + return lista.where((r) => r.estado == EstadoHuerfana.pendiente).length; + } + } + + // Filtro activo no es pendiente/todas: hacemos la consulta directa. + final repo = ref.read(huerfanasRepositoryProvider); + final pendientes = await repo.obtenerHuerfanas(estado: 'pendiente'); + return pendientes.length; +}); diff --git a/flutter_soma_app/lib/features/huerfanas/presentation/screens/huerfanas_screen.dart b/flutter_soma_app/lib/features/huerfanas/presentation/screens/huerfanas_screen.dart new file mode 100644 index 0000000..4e66c00 --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/presentation/screens/huerfanas_screen.dart @@ -0,0 +1,926 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/services/whatsapp_service.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/bulk_notify_dialog.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/turno_picker_sheet.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; + +const _meses = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +class HuerfanasScreen extends ConsumerWidget { + const HuerfanasScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(huerfanasProvider); + final notifier = ref.read(huerfanasProvider.notifier); + final currentEstado = notifier.currentEstado; + final isWide = MediaQuery.of(context).size.width >= 800; + final theme = Theme.of(context); + + final modoSeleccion = ref.watch(huerfanasModoSeleccionProvider); + final seleccionadas = ref.watch(huerfanasSeleccionProvider); + final seleccionNotifier = ref.read(huerfanasSeleccionProvider.notifier); + + // Sólo aplica a pendientes + final lista = state.valueOrNull ?? []; + final pendientes = lista + .where((r) => r.estado == EstadoHuerfana.pendiente) + .toList(); + final seleccionadasValidas = seleccionadas + .where((id) => pendientes.any((r) => r.huerfanaId == id)) + .toSet(); + + void toggleModoSeleccion() { + if (modoSeleccion) { + seleccionNotifier.limpiar(); + } + ref.read(huerfanasModoSeleccionProvider.notifier).state = !modoSeleccion; + } + + void abrirBulkNotify() { + final items = pendientes + .where((r) => seleccionadasValidas.contains(r.huerfanaId)) + .toList(); + if (items.isEmpty) return; + showDialog( + context: context, + builder: (_) => BulkNotifyDialog(seleccionadas: items), + ).then((_) { + // limpiar selección al cerrar el dialog + seleccionNotifier.limpiar(); + ref.read(huerfanasModoSeleccionProvider.notifier).state = false; + }); + } + + void abrirPickerSheet(ReservaHuerfana reserva) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => TurnoPickerSheet( + reserva: reserva, + onReubicadoExito: (Turno turno, DateTime fecha) { + if (!context.mounted) return; + final fechaStr = + '${fecha.day} ${_meses[fecha.month]} ${turno.horaInicio}'; + SomaToast.show( + context, + message: 'Reubicado al $fechaStr', + type: ToastType.success, + action: reserva.telefono != null + ? SnackBarAction( + label: 'Avisar por WhatsApp', + textColor: SomaColors.onPrimary, + onPressed: () => WhatsAppService.abrirChat( + telefono: reserva.telefono, + mensaje: 'Hola ${reserva.nombre}, te reasignamos al turno de ' + '${turno.actividad.nombre} del $fechaStr. ¡Te esperamos!', + ), + ) + : null, + ); + }, + ), + ); + } + + Future abrirWhatsApp(ReservaHuerfana reserva) async { + final nombre = reserva.nombre; + final actividad = reserva.actividadNombre; + final d = DateTime.tryParse(reserva.fechaOriginal); + final fecha = d != null ? '${d.day} ${_meses[d.month]} ${d.year}' : reserva.fechaOriginal; + + final ok = await WhatsAppService.abrirChat( + telefono: reserva.telefono, + mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. ' + 'Tu reserva de $actividad del $fecha quedó sin turno disponible. ' + 'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!', + ); + + if (!context.mounted) return; + + if (!ok) { + final sinNumero = WhatsAppService.normalizarNumeroAr(reserva.telefono) == null; + SomaToast.show( + context, + message: sinNumero + ? '${reserva.displayName} no tiene número de teléfono registrado. ' + 'Podés agregarlo desde la pantalla de Usuarios.' + : 'No se pudo abrir WhatsApp.', + type: ToastType.error, + ); + return; + } + + // Sólo ofrecer marcar si está pendiente + if (reserva.estado == EstadoHuerfana.pendiente) { + SomaToast.show( + context, + message: 'WhatsApp abierto', + type: ToastType.info, + action: SnackBarAction( + label: 'Marcar resuelta', + textColor: SomaColors.onPrimary, + onPressed: () async { + final error = await ref + .read(huerfanasProvider.notifier) + .resolver(reserva.huerfanaId, 'resuelta'); + if (!context.mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } + }, + ), + ); + } + } + + return Scaffold( + body: Column( + children: [ + // ── Header ────────────────────────────────────────────────────────── + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 0, + ), + child: Row( + children: [ + const Text( + 'Reservas sin turno', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SomaHeaderHelp( + items: [ + SomaHelpItem( + icon: Icons.filter_alt_outlined, + text: 'Filtrá por estado: pendientes, reubicadas, ' + 'resueltas o todas.', + ), + SomaHelpItem( + icon: Icons.checklist_outlined, + text: 'Modo selección: elegí varias reservas para ' + 'notificarlas por WhatsApp de una sola vez.', + ), + SomaHelpItem( + icon: Icons.refresh, + text: 'Recarga la lista de reservas sin turno.', + ), + ], + ), + const Spacer(), + // Toggle selección múltiple + IconButton( + icon: Icon( + modoSeleccion + ? Icons.checklist_rounded + : Icons.checklist_outlined, + size: 20, + color: modoSeleccion + ? SomaColors.primary + : null, + ), + tooltip: modoSeleccion ? 'Cancelar selección' : 'Seleccionar', + onPressed: toggleModoSeleccion, + ), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Recargar', + onPressed: () { + seleccionNotifier.limpiar(); + ref.read(huerfanasModoSeleccionProvider.notifier).state = + false; + notifier.load(); + }, + ), + ], + ), + ), + + // ── Filter chips (ocultos en modo selección) ───────────────────── + if (!modoSeleccion) + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 12, isWide ? 32 : 16, 0, + ), + child: Row( + children: [ + _FilterChip( + label: 'Pendientes', + selected: currentEstado == 'pendiente', + color: SomaColors.error, + onTap: () => notifier.filtrar('pendiente'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Reubicadas', + selected: currentEstado == 'reubicado', + color: SomaColors.primary, + onTap: () => notifier.filtrar('reubicado'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Resueltas', + selected: currentEstado == 'resuelta', + color: theme.colorScheme.secondary, + onTap: () => notifier.filtrar('resuelta'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Todas', + selected: currentEstado == null, + color: theme.colorScheme.onSurface, + onTap: () => notifier.filtrar(null), + ), + ], + ), + ) + else + // Etiqueta modo selección + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 12, isWide ? 32 : 16, 0, + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 14, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + const SizedBox(width: 6), + Text( + 'Seleccioná los clientes pendientes que querés notificar en lote', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(140), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + // ── Content ────────────────────────────────────────────────────── + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => notifier.load(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (lista) { + // En modo selección sólo mostramos pendientes + final listaFiltrada = + modoSeleccion ? pendientes : lista; + + if (listaFiltrada.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, + size: 56, + color: + theme.colorScheme.onSurface.withAlpha(60)), + const SizedBox(height: 12), + Text( + modoSeleccion + ? 'Sin reservas pendientes para notificar' + : currentEstado == 'pendiente' + ? 'Sin reservas pendientes' + : 'Sin reservas en este estado', + style: TextStyle( + fontSize: 15, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ); + } + + return ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 8, isWide ? 32 : 16, 32, + ), + itemCount: listaFiltrada.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final reserva = listaFiltrada[index]; + return _HuerfanaCard( + reserva: reserva, + modoSeleccion: modoSeleccion, + seleccionada: seleccionadasValidas + .contains(reserva.huerfanaId), + onToggleSeleccion: () => + seleccionNotifier.toggle(reserva.huerfanaId), + onWhatsApp: + () => abrirWhatsApp(reserva), + onReubicar: () => abrirPickerSheet(reserva), + onResolver: (nuevoEstado) async { + final error = await ref + .read(huerfanasProvider.notifier) + .resolver(reserva.huerfanaId, nuevoEstado); + if (!context.mounted) return; + if (error != null) { + SomaToast.show(context, + message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Estado actualizado', + type: ToastType.success); + } + }, + ); + }, + ); + }, + ), + ), + + // ── Bottom bar de selección ────────────────────────────────────── + if (modoSeleccion) + _SelectionBar( + count: seleccionadasValidas.length, + onNotificar: seleccionadasValidas.isEmpty ? null : abrirBulkNotify, + onCancelar: toggleModoSeleccion, + ), + ], + ), + ); + } +} + +// ── Filter chip ─────────────────────────────────────────────────────────────── + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final Color color; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.selected, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + mouseCursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: selected ? color.withAlpha(22) : Colors.transparent, + border: Border.all( + color: selected ? color.withAlpha(100) : color.withAlpha(40), + width: 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? color : color.withAlpha(150), + ), + ), + ), + ); + } +} + +// ── Card de reserva huérfana ────────────────────────────────────────────────── + +class _HuerfanaCard extends StatelessWidget { + final ReservaHuerfana reserva; + final bool modoSeleccion; + final bool seleccionada; + final VoidCallback onToggleSeleccion; + final Future Function() onWhatsApp; + final VoidCallback onReubicar; + final Future Function(String nuevoEstado) onResolver; + + const _HuerfanaCard({ + required this.reserva, + required this.modoSeleccion, + required this.seleccionada, + required this.onToggleSeleccion, + required this.onWhatsApp, + required this.onReubicar, + required this.onResolver, + }); + + String _fmtFecha(String fechaIso) { + final d = DateTime.tryParse(fechaIso); + if (d == null) return fechaIso; + return '${d.day} ${_meses[d.month]} ${d.year}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isPendiente = reserva.estado == EstadoHuerfana.pendiente; + + return GestureDetector( + onTap: modoSeleccion ? onToggleSeleccion : null, + onSecondaryTapDown: modoSeleccion + ? null + : (details) { + final entries = >[]; + + if (isPendiente) { + entries.add(MenuItem( + label: const Text('Reubicar'), + icon: const Icon(Icons.swap_horiz, size: 16), + value: 'reubicar', + )); + if (reserva.telefono != null) { + entries.add(MenuItem( + label: const Text('Notificar por WhatsApp'), + icon: const Icon(Icons.chat_outlined, + size: 16, color: Color(0xFF25D366)), + value: 'whatsapp', + )); + } + entries.add(MenuItem( + label: const Text('Marcar como resuelta'), + icon: const Icon(Icons.notifications_none, size: 16), + value: 'resuelta', + )); + } else { + if (reserva.telefono != null) { + entries.add(MenuItem( + label: const Text('Notificar por WhatsApp'), + icon: const Icon(Icons.chat_outlined, + size: 16, color: Color(0xFF25D366)), + value: 'whatsapp', + )); + } + entries.add(MenuItem( + label: const Text('Volver a pendiente'), + icon: const Icon(Icons.undo, size: 16), + value: 'pendiente', + )); + } + + showContextMenu( + context, + contextMenu: ContextMenu( + position: details.globalPosition, + entries: entries, + ), + onItemSelected: (v) { + switch (v) { + case 'reubicar': + onReubicar(); + case 'whatsapp': + onWhatsApp(); + case 'resuelta': + onResolver('resuelta'); + case 'pendiente': + onResolver('pendiente'); + } + }, + ); + }, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: modoSeleccion && seleccionada + ? SomaColors.primary.withAlpha(140) + : theme.colorScheme.surfaceContainerHighest, + width: modoSeleccion && seleccionada ? 1.5 : 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Checkbox en modo selección / rail de estado normal + if (modoSeleccion) + _SelectionRail(seleccionada: seleccionada) + else + Container( + width: 4, + color: switch (reserva.estado) { + EstadoHuerfana.pendiente => SomaColors.error.withAlpha(180), + EstadoHuerfana.reubicado => SomaColors.primary.withAlpha(180), + EstadoHuerfana.resuelta => + theme.colorScheme.secondary.withAlpha(180), + }, + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row superior: avatar + info + badge + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text( + reserva.initials, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: SomaColors.primaryText, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + reserva.displayName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (reserva.telefono != null) + Text( + reserva.telefono!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ], + ), + ), + if (!modoSeleccion) _EstadoBadge(estado: reserva.estado), + ], + ), + const SizedBox(height: 10), + + // Actividad + fecha + Row( + children: [ + Icon(Icons.sports_gymnastics_outlined, + size: 14, + color: theme.colorScheme.onSurface.withAlpha(120)), + const SizedBox(width: 5), + Expanded( + child: Text( + reserva.actividadNombre, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(180), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon(Icons.calendar_today_outlined, + size: 13, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(width: 4), + Text( + '${_fmtFecha(reserva.fechaOriginal)} ${reserva.horaInicioOriginal}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(150), + fontFeatures: const [ + FontFeature.tabularFigures() + ], + ), + ), + ], + ), + + // Acciones (sólo cuando no estamos en modo selección) + if (!modoSeleccion) ...[ + if (isPendiente) ...[ + const SizedBox(height: 10), + Row( + children: [ + _ActionButton( + icon: Icons.chat_outlined, + label: 'WhatsApp', + color: const Color(0xFF25D366), + onTap: onWhatsApp, + ), + const SizedBox(width: 8), + _ActionButton( + icon: Icons.swap_horiz, + label: 'Reubicar', + color: SomaColors.primary, + onTap: () => onReubicar(), + ), + const SizedBox(width: 8), + _ActionButton( + icon: Icons.notifications_none, + label: 'Resuelta', + color: theme.colorScheme.secondary, + onTap: () => onResolver('resuelta'), + ), + ], + ), + ] else ...[ + const SizedBox(height: 6), + Row( + children: [ + _ActionButton( + icon: Icons.chat_outlined, + label: 'WhatsApp', + color: const Color(0xFF25D366), + onTap: onWhatsApp, + ), + const SizedBox(width: 8), + InkWell( + onTap: () => onResolver('pendiente'), + borderRadius: BorderRadius.circular(6), + mouseCursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + child: Text( + 'Volver a pendiente', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface + .withAlpha(120), + decoration: TextDecoration.underline, + decorationColor: + theme.colorScheme.onSurface + .withAlpha(80), + ), + ), + ), + ), + ], + ), + ], + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Rail de selección ───────────────────────────────────────────────────────── + +class _SelectionRail extends StatelessWidget { + final bool seleccionada; + const _SelectionRail({required this.seleccionada}); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 40, + color: seleccionada + ? SomaColors.primary.withAlpha(20) + : Colors.transparent, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 18, + height: 18, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: seleccionada ? SomaColors.primary : Colors.transparent, + border: Border.all( + color: seleccionada + ? SomaColors.primary + : Theme.of(context).colorScheme.onSurface.withAlpha(80), + width: 1.5, + ), + ), + child: seleccionada + ? const Icon(Icons.check, size: 12, color: SomaColors.onPrimary) + : null, + ), + ), + ); + } +} + +// ── Badge de estado ─────────────────────────────────────────────────────────── + +class _EstadoBadge extends StatelessWidget { + final EstadoHuerfana estado; + const _EstadoBadge({required this.estado}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (label, color) = switch (estado) { + EstadoHuerfana.pendiente => ('Pendiente', SomaColors.error), + EstadoHuerfana.reubicado => ('Reubicado', SomaColors.primary), + EstadoHuerfana.resuelta => + ('Resuelta', theme.colorScheme.secondary), + }; + final textColor = estado == EstadoHuerfana.reubicado + ? SomaColors.primaryText + : color; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withAlpha(60), width: 0.5), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ); + } +} + +// ── Botón de acción ─────────────────────────────────────────────────────────── + +class _ActionButton extends StatefulWidget { + final IconData icon; + final String label; + final Color color; + final dynamic Function() onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + State<_ActionButton> createState() => _ActionButtonState(); +} + +class _ActionButtonState extends State<_ActionButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: () => widget.onTap(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + color: widget.color.withAlpha(_hovered ? 38 : 16), + border: Border.all( + color: widget.color.withAlpha(_hovered ? 110 : 60), + width: _hovered ? 0.8 : 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 13, color: widget.color), + const SizedBox(width: 5), + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: widget.color, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Bottom bar de selección ─────────────────────────────────────────────────── + +class _SelectionBar extends StatelessWidget { + final int count; + final VoidCallback? onNotificar; + final VoidCallback onCancelar; + + const _SelectionBar({ + required this.count, + required this.onNotificar, + required this.onCancelar, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 14), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + ), + child: Row( + children: [ + Text( + count == 0 + ? 'Sin selección' + : count == 1 + ? '1 seleccionado' + : '$count seleccionados', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + const Spacer(), + TextButton( + onPressed: onCancelar, + child: const Text('Cancelar'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 38)), + onPressed: onNotificar, + icon: const Icon(Icons.chat_outlined, size: 16), + label: Text( + count == 0 + ? 'Notificar en lote' + : 'Notificar $count por WhatsApp', + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/huerfanas/presentation/widgets/bulk_notify_dialog.dart b/flutter_soma_app/lib/features/huerfanas/presentation/widgets/bulk_notify_dialog.dart new file mode 100644 index 0000000..5ddb03d --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/presentation/widgets/bulk_notify_dialog.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/services/whatsapp_service.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart'; + +const _meses = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +class BulkNotifyDialog extends ConsumerStatefulWidget { + final List seleccionadas; + + const BulkNotifyDialog({super.key, required this.seleccionadas}); + + @override + ConsumerState createState() => _BulkNotifyDialogState(); +} + +class _BulkNotifyDialogState extends ConsumerState { + late final Set _listos = {}; + bool _cargando = false; + + String _fmtFecha(String fechaIso) { + final d = DateTime.tryParse(fechaIso); + if (d == null) return fechaIso; + return '${d.day} ${_meses[d.month]} ${d.year}'; + } + + String _mensajeWa(ReservaHuerfana r) => + 'Hola ${r.nombre}, te contactamos desde el gimnasio SOMA. ' + 'Tu reserva de ${r.actividadNombre} del ${_fmtFecha(r.fechaOriginal)} ' + 'quedó sin turno disponible. ' + 'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!'; + + Future _abrirWa(ReservaHuerfana r) async { + final ok = await WhatsAppService.abrirChat( + telefono: r.telefono, + mensaje: _mensajeWa(r), + ); + if (!mounted) return; + if (!ok) { + final sinNumero = WhatsAppService.normalizarNumeroAr(r.telefono) == null; + SomaToast.show( + context, + message: sinNumero + ? '${r.displayName} no tiene número de teléfono registrado. ' + 'Podés agregarlo desde la pantalla de Usuarios.' + : 'No se pudo abrir WhatsApp para ${r.displayName}.', + type: ToastType.error, + ); + } + } + + Future _marcarListos() async { + if (_listos.isEmpty) return; + setState(() => _cargando = true); + await ref.read(huerfanasProvider.notifier).notificarLote(_listos); + if (!mounted) return; + final n = _listos.length; + Navigator.pop(context); + SomaToast.show( + context, + message: n == 1 ? '1 reserva marcada como resuelta' : '$n reservas marcadas como resueltas', + type: ToastType.success, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final n = widget.seleccionadas.length; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520, maxHeight: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Expanded( + child: Text( + n == 1 ? 'Notificar a 1 cliente' : 'Notificar a $n clientes', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.fromLTRB(24, 6, 24, 12), + child: Text( + 'Abrí WhatsApp para cada cliente y marcá "Listo" cuando lo hayas enviado.', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ), + + const Divider(height: 1), + + // Lista + Flexible( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + shrinkWrap: true, + itemCount: widget.seleccionadas.length, + separatorBuilder: (_, _) => const Divider(height: 1, indent: 20, endIndent: 20), + itemBuilder: (_, i) { + final r = widget.seleccionadas[i]; + final listo = _listos.contains(r.huerfanaId); + return _ClienteRow( + reserva: r, + listo: listo, + onAbrirWa: () => _abrirWa(r), + onToggleListo: () { + setState(() { + if (listo) { + _listos.remove(r.huerfanaId); + } else { + _listos.add(r.huerfanaId); + } + }); + }, + ); + }, + ), + ), + + const Divider(height: 1), + + // Footer + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Row( + children: [ + if (_listos.isNotEmpty) + Text( + '${_listos.length} listo${_listos.length == 1 ? '' : 's'}', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cerrar'), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)), + onPressed: _listos.isEmpty || _cargando ? null : _marcarListos, + child: _cargando + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.onPrimary, + ), + ) + : Text( + _listos.isEmpty + ? 'Marcar como resueltas' + : 'Marcar ${_listos.length} como resuelta${_listos.length == 1 ? '' : 's'}', + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ClienteRow extends StatefulWidget { + final ReservaHuerfana reserva; + final bool listo; + final VoidCallback onAbrirWa; + final VoidCallback onToggleListo; + + const _ClienteRow({ + required this.reserva, + required this.listo, + required this.onAbrirWa, + required this.onToggleListo, + }); + + @override + State<_ClienteRow> createState() => _ClienteRowState(); +} + +class _ClienteRowState extends State<_ClienteRow> { + bool _hoveredWa = false; + bool _hoveredListo = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const waColor = Color(0xFF25D366); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 16, + backgroundColor: SomaColors.primary.withAlpha(28), + child: Text( + widget.reserva.initials, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: SomaColors.primaryText, + ), + ), + ), + const SizedBox(width: 10), + + // Nombre + teléfono + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.reserva.displayName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.reserva.telefono ?? 'Sin teléfono', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha( + widget.reserva.telefono != null ? 130 : 80, + ), + ), + ), + ], + ), + ), + + // Botón WhatsApp + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoveredWa = true), + onExit: (_) => setState(() => _hoveredWa = false), + child: GestureDetector( + onTap: widget.onAbrirWa, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + color: waColor.withAlpha(_hoveredWa ? 38 : 16), + border: Border.all( + color: waColor.withAlpha(_hoveredWa ? 110 : 60), + width: _hoveredWa ? 0.8 : 0.5, + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_outlined, size: 13, color: waColor), + SizedBox(width: 4), + Text( + 'Abrir WA', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: waColor, + ), + ), + ], + ), + ), + ), + ), + + const SizedBox(width: 10), + + // Toggle "Listo" + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoveredListo = true), + onExit: (_) => setState(() => _hoveredListo = false), + child: GestureDetector( + onTap: widget.onToggleListo, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: widget.listo + ? SomaColors.primary.withAlpha( + _hoveredListo ? 35 : 20, + ) + : _hoveredListo + ? theme.colorScheme.onSurface.withAlpha(10) + : Colors.transparent, + border: Border.all( + color: widget.listo + ? SomaColors.primary.withAlpha( + _hoveredListo ? 160 : 100, + ) + : theme.colorScheme.onSurface.withAlpha( + _hoveredListo ? 90 : 50, + ), + width: 0.8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check, + size: 12, + color: widget.listo + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha( + _hoveredListo ? 100 : 60, + ), + ), + const SizedBox(width: 3), + Text( + 'Listo', + style: TextStyle( + fontSize: 11, + fontWeight: widget.listo + ? FontWeight.w700 + : FontWeight.w500, + color: widget.listo + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha( + _hoveredListo ? 140 : 100, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/huerfanas/presentation/widgets/turno_picker_sheet.dart b/flutter_soma_app/lib/features/huerfanas/presentation/widgets/turno_picker_sheet.dart new file mode 100644 index 0000000..97f0c88 --- /dev/null +++ b/flutter_soma_app/lib/features/huerfanas/presentation/widgets/turno_picker_sheet.dart @@ -0,0 +1,642 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart'; +import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; + +const _meses = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +const _diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + +class TurnoPickerSheet extends ConsumerStatefulWidget { + final ReservaHuerfana reserva; + + /// Se invoca tras reubicar exitosamente con el turno elegido y la fecha del día. + /// El llamador usa esta info para mostrar el toast de éxito con la acción de WA. + final void Function(Turno turno, DateTime fecha)? onReubicadoExito; + + const TurnoPickerSheet({ + super.key, + required this.reserva, + this.onReubicadoExito, + }); + + @override + ConsumerState createState() => _TurnoPickerSheetState(); +} + +class _TurnoPickerSheetState extends ConsumerState { + late DateTime _semanaActual; + bool _soloMismaActividad = true; + AsyncValue _semanaTurnos = const AsyncValue.loading(); + bool _reubicando = false; + + @override + void initState() { + super.initState(); + _semanaActual = _lunesDe(DateTime.now()); + WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana()); + } + + DateTime _lunesDe(DateTime d) => + DateTime(d.year, d.month, d.day - (d.weekday - 1)); + + Future _cargarSemana() async { + setState(() => _semanaTurnos = const AsyncValue.loading()); + try { + final repo = ref.read(turnosRepositoryProvider); + final semana = await repo.obtenerSemana(_semanaActual); + if (mounted) setState(() => _semanaTurnos = AsyncValue.data(semana)); + } catch (e, st) { + if (mounted) setState(() => _semanaTurnos = AsyncValue.error(e, st)); + } + } + + void _irSemanaAnterior() { + setState(() => _semanaActual = _semanaActual.subtract(const Duration(days: 7))); + _cargarSemana(); + } + + void _irSemanaSiguiente() { + setState(() => _semanaActual = _semanaActual.add(const Duration(days: 7))); + _cargarSemana(); + } + + String _fmtSemana() { + final fin = _semanaActual.add(const Duration(days: 6)); + final inicioStr = '${_semanaActual.day} ${_meses[_semanaActual.month]}'; + final finStr = '${fin.day} ${_meses[fin.month]}'; + return 'Sem del $inicioStr al $finStr'; + } + + String _fmtFechaCorta(DateTime d) => + '${_diasSemana[d.weekday]} ${d.day} ${_meses[d.month]}'; + + Future _seleccionarTurno(Turno turno, DiaTurnos dia) async { + final nombreFmt = + '${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]} ${turno.horaInicio}'; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmar reubicación'), + content: Text( + '¿Reubicar a ${widget.reserva.displayName} al turno de ' + '${turno.actividad.nombre} del $nombreFmt ' + '(${turno.disponible}/${turno.capacidadMaxima} cupos)?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 36)), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Confirmar'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + setState(() => _reubicando = true); + final error = await ref + .read(huerfanasProvider.notifier) + .mover(widget.reserva.huerfanaId, turno.id); + if (!mounted) return; + setState(() => _reubicando = false); + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + return; + } + + final callback = widget.onReubicadoExito; + Navigator.pop(context); + callback?.call(turno, dia.fecha); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DraggableScrollableSheet( + initialChildSize: 0.72, + maxChildSize: 0.92, + minChildSize: 0.4, + expand: false, + builder: (ctx, scrollController) { + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withAlpha(50), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Reubicar a ${widget.reserva.displayName}', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ], + ), + Text( + '${widget.reserva.actividadNombre} · ' + '${_fmtFechaCorta(DateTime.tryParse(widget.reserva.fechaOriginal) ?? DateTime.now())} ' + '${widget.reserva.horaInicioOriginal} (original)', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + + const Divider(height: 16, indent: 20, endIndent: 20), + + // Navegación semanal + filtro + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _irSemanaAnterior, + tooltip: 'Semana anterior', + ), + Expanded( + child: Text( + _fmtSemana(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _irSemanaSiguiente, + tooltip: 'Semana siguiente', + ), + ], + ), + ), + + // Toggle filtro actividad + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: _FiltroToggle( + actividadNombre: widget.reserva.actividadNombre, + soloMismaActividad: _soloMismaActividad, + onChanged: (v) => setState(() => _soloMismaActividad = v), + ), + ), + + // Contenido + Expanded( + child: _reubicando + ? const Center( + child: CircularProgressIndicator( + color: SomaColors.primary, + ), + ) + : _semanaTurnos.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: SomaColors.primary, + ), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 40, + color: theme.colorScheme.onSurface.withAlpha(80), + ), + const SizedBox(height: 8), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _cargarSemana, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (semana) => _buildDias( + semana, + scrollController, + theme, + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildDias( + SemanaTurnos semana, + ScrollController scrollController, + ThemeData theme, + ) { + final dias = List.generate(7, (i) { + final fecha = _semanaActual.add(Duration(days: i)); + return semana.diaPara(fecha) ?? + DiaTurnos( + fecha: fecha, + diaSemana: fecha.weekday, + estado: DiaEstado.cerrado, + turnos: const [], + ); + }); + + // Filtrar turnos por actividad si aplica + List turnosDelDia(DiaTurnos dia) { + if (dia.estado == DiaEstado.cerrado) return []; + final todos = dia.turnos.where((t) => !t.estaLleno).toList(); + if (!_soloMismaActividad) return todos; + return todos + .where( + (t) => + t.actividad.nombre.toLowerCase() == + widget.reserva.actividadNombre.toLowerCase(), + ) + .toList(); + } + + // Si con el filtro no hay nada en toda la semana, mostrar aviso + final hayAlgo = dias.any((d) => turnosDelDia(d).isNotEmpty); + + if (!hayAlgo) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.event_busy_outlined, + size: 44, + color: theme.colorScheme.onSurface.withAlpha(50), + ), + const SizedBox(height: 10), + Text( + _soloMismaActividad + ? 'Sin turnos disponibles de\n${widget.reserva.actividadNombre} esta semana' + : 'Sin turnos disponibles esta semana', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + itemCount: dias.length, + itemBuilder: (_, i) { + final dia = dias[i]; + final turnos = turnosDelDia(dia); + if (turnos.isEmpty) return const SizedBox.shrink(); + return _DiaSection( + dia: dia, + turnos: turnos, + onTurnoTap: _seleccionarTurno, + ); + }, + ); + } +} + +// ── Toggle filtro actividad ─────────────────────────────────────────────────── + +class _FiltroToggle extends StatelessWidget { + final String actividadNombre; + final bool soloMismaActividad; + final ValueChanged onChanged; + + const _FiltroToggle({ + required this.actividadNombre, + required this.soloMismaActividad, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 30, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withAlpha(80), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + _ToggleItem( + label: actividadNombre, + selected: soloMismaActividad, + onTap: () => onChanged(true), + ), + _ToggleItem( + label: 'Todas las actividades', + selected: !soloMismaActividad, + onTap: () => onChanged(false), + ), + ], + ), + ); + } +} + +class _ToggleItem extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _ToggleItem({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: selected ? theme.colorScheme.surface : Colors.transparent, + borderRadius: BorderRadius.circular(6), + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withAlpha(18), + blurRadius: 4, + offset: const Offset(0, 1), + ) + ] + : null, + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withAlpha(140), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} + +// ── Sección de día con turnos ───────────────────────────────────────────────── + +class _DiaSection extends StatelessWidget { + final DiaTurnos dia; + final List turnos; + final Future Function(Turno, DiaTurnos) onTurnoTap; + + const _DiaSection({ + required this.dia, + required this.turnos, + required this.onTurnoTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final label = + '${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]}'; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(160), + letterSpacing: 0.3, + ), + ), + ), + ...turnos.map((t) => _TurnoPill(turno: t, dia: dia, onTap: onTurnoTap)), + ], + ), + ); + } +} + +class _TurnoPill extends StatefulWidget { + final Turno turno; + final DiaTurnos dia; + final Future Function(Turno, DiaTurnos) onTap; + + const _TurnoPill({ + required this.turno, + required this.dia, + required this.onTap, + }); + + @override + State<_TurnoPill> createState() => _TurnoPillState(); +} + +class _TurnoPillState extends State<_TurnoPill> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: () => widget.onTap(widget.turno, widget.dia), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail izquierdo + AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 3, + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(_hovered ? 255 : 180), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(4), + ), + ), + ), + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + decoration: BoxDecoration( + color: _hovered + ? SomaColors.primary.withAlpha(12) + : theme.colorScheme.surface, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(8), + ), + border: Border.all( + color: _hovered + ? SomaColors.primary.withAlpha(80) + : theme.colorScheme.surfaceContainerHighest, + width: _hovered ? 0.8 : 0.5, + ), + ), + child: Row( + children: [ + // Hora + SizedBox( + width: 46, + child: Text( + widget.turno.horaInicio, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + Container( + width: 1, + height: 16, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: theme.colorScheme.surfaceContainerHighest, + ), + // Actividad + Expanded( + child: Text( + widget.turno.actividad.nombre, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Cupos + Text( + '${widget.turno.disponible}/${widget.turno.capacidadMaxima}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: widget.turno.disponible <= 2 + ? SomaColors.error.withAlpha(200) + : SomaColors.primary.withAlpha(200), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 6), + AnimatedContainer( + duration: const Duration(milliseconds: 120), + child: Icon( + Icons.arrow_forward_ios_rounded, + size: 12, + color: theme.colorScheme.onSurface + .withAlpha(_hovered ? 160 : 80), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/data/repositories/pagos_repository_impl.dart b/flutter_soma_app/lib/features/pagos/data/repositories/pagos_repository_impl.dart new file mode 100644 index 0000000..a63bf97 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/data/repositories/pagos_repository_impl.dart @@ -0,0 +1,156 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart'; + +class PagosRepositoryImpl implements PagosRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + @override + Future> getPagos({ + int pagina = 1, + int cantidad = 50, + String? dni, + bool incluirAnulados = false, + }) async { + final token = await _getToken(); + + final params = { + 'p_token': token, + 'p_pagina': pagina, + 'p_cantidad': cantidad, + 'p_incluir_anulados': incluirAnulados, + }; + if (dni != null && dni.isNotEmpty) { + params['p_dni'] = dni; + } + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetPagos, + params: params, + ); + + if (response is List) { + return response + .map((e) => Pago.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future> getMisPagos({ + int pagina = 1, + int cantidad = 20, + }) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetMisPagos, + params: { + 'p_token': token, + 'p_pagina': pagina, + 'p_cantidad': cantidad, + }, + ); + + if (response is List) { + return response + .map((e) => Pago.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future insertPago(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcInsertPago, + params: { + 'p_datos': datos, + 'p_token': token, + }, + ); + } + + @override + Future editPago(String id, Map datos) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcEditarPago, + params: { + 'p_id': id, + 'p_datos': datos, + 'p_token': token, + }, + ); + + if (response is Map) { + return Pago.fromMap(response); + } + throw Exception('Respuesta inválida al editar el pago.'); + } + + @override + Future anularPago(String id, String? motivo) async { + final token = await _getToken(); + // El backend hace NULLIF(trim(p_motivo), ''); igualmente normalizamos + // a null antes de mandar para evitar enviar whitespace innecesario. + final motivoNormalizado = + (motivo == null || motivo.trim().isEmpty) ? null : motivo; + + final response = await SupabaseConfig.rpc( + AppConstants.rpcAnularPago, + params: { + 'p_id': id, + 'p_motivo': motivoNormalizado, + 'p_token': token, + }, + ); + + if (response is Map) { + return Pago.fromMap(response); + } + throw Exception('Respuesta inválida al anular el pago.'); + } + + @override + Future> getMetodosPago() async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetMetodosPago, + params: {'p_token': token}, + ); + + if (response is List) { + return response + .map((e) => MetodoPago.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future updateMetodoPago(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcUpdateMetodoPago, + params: { + 'p_token': token, + 'p_datos': datos, + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/domain/entities/metodo_pago.dart b/flutter_soma_app/lib/features/pagos/domain/entities/metodo_pago.dart new file mode 100644 index 0000000..2550b9e --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/domain/entities/metodo_pago.dart @@ -0,0 +1,22 @@ +class MetodoPago { + final int id; + final String descripcion; + final bool activo; + final String? icono; + + const MetodoPago({ + required this.id, + required this.descripcion, + this.activo = true, + this.icono, + }); + + factory MetodoPago.fromMap(Map map) { + return MetodoPago( + id: map['id'] as int, + descripcion: map['descripcion'] as String? ?? '', + activo: map['activo'] as bool? ?? true, + icono: map['icono'] as String?, + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/domain/entities/pago.dart b/flutter_soma_app/lib/features/pagos/domain/entities/pago.dart new file mode 100644 index 0000000..191c78b --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/domain/entities/pago.dart @@ -0,0 +1,158 @@ +import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart'; + +class Pago { + final String id; + final PagoTipo tipo; + // Cuando tipo == ajuste, anioMesPagado puede venir vacío (NULL en DB). + final String anioMesPagado; // date string "YYYY-MM-DD" (siempre día 1) o vacío + final DateTime? fechaPago; + final double montoTotal; + // Cuando tipo == ajuste, metodo puede venir vacío (NULL en DB). + final String metodo; + final Map? detalle; + // Sólo presente en fc_obtener_pagos (admin), no en fc_obtener_mis_pagos. + final PagoCliente? cliente; + + // Auditoría de creación. createdBy es el UUID del autor — habilita + // gating local "este pago lo creé yo". + final DateTime? createdAt; + final String? createdBy; + final String? createdByNombre; + + // Auditoría de última edición (null si nunca se editó). + final DateTime? updatedAt; + final String? updatedByNombre; + + // Auditoría de anulación (soft-delete). + final DateTime? anuladoAt; + final String? anuladoPorNombre; + final String? motivoAnulacion; + + const Pago({ + required this.id, + required this.tipo, + required this.anioMesPagado, + this.fechaPago, + required this.montoTotal, + required this.metodo, + this.detalle, + this.cliente, + this.createdAt, + this.createdBy, + this.createdByNombre, + this.updatedAt, + this.updatedByNombre, + this.anuladoAt, + this.anuladoPorNombre, + this.motivoAnulacion, + }); + + factory Pago.fromMap(Map map) { + return Pago( + id: map['id'] as String, + tipo: PagoTipo.fromString(map['tipo'] as String?), + anioMesPagado: map['anio_mes_pagado'] as String? ?? '', + fechaPago: _parseDate(map['fecha_pago']), + montoTotal: (map['monto_total'] as num?)?.toDouble() ?? 0, + metodo: map['metodo'] as String? ?? '', + detalle: map['detalle'] as Map?, + cliente: map['cliente'] != null + ? PagoCliente.fromMap(map['cliente'] as Map) + : null, + createdAt: _parseDate(map['created_at']), + createdBy: map['created_by'] as String?, + createdByNombre: map['created_by_nombre'] as String?, + updatedAt: _parseDate(map['updated_at']), + updatedByNombre: map['updated_by_nombre'] as String?, + anuladoAt: _parseDate(map['anulado_at']), + anuladoPorNombre: map['anulado_por_nombre'] as String?, + motivoAnulacion: map['motivo_anulacion'] as String?, + ); + } + + static DateTime? _parseDate(dynamic raw) { + if (raw == null) return null; + return DateTime.tryParse(raw.toString()); + } + + /// Mes y año formateado: "Marzo 2026" + String get mesPagadoDisplay { + final date = DateTime.tryParse(anioMesPagado); + if (date == null) return anioMesPagado; + const meses = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + return '${meses[date.month]} ${date.year}'; + } + + /// Fecha de pago formateada: "15/03/2026" + String get fechaPagoDisplay { + if (fechaPago == null) return '-'; + final d = fechaPago!; + return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; + } + + bool get isAnulado => anuladoAt != null; + bool get isEditado => updatedAt != null; + + /// True si el pago todavía está dentro de la ventana de edición desde su + /// creación. La ventana se pasa como parámetro para no acoplar la entidad + /// a un provider; el caller obtiene el valor de + /// AppConstants.pagosVentanaEdicionMinutosDefault. + /// + /// El backend sigue siendo la fuente de verdad: este getter sirve sólo + /// para gating local de UI. + bool isEditableWindow(int ventanaMinutos) { + if (createdAt == null) return false; + final diff = DateTime.now().difference(createdAt!).inMinutes; + return diff < ventanaMinutos; + } + + /// True si el actor puede editar este pago. La ventana aplica a todos + /// (incluso superadmin); ownership sólo si NO es superadmin. + /// El backend es la fuente de verdad; este getter es para gating local. + bool puedeEditar(String? actorUserId, bool isSuperadmin, int ventanaMinutos) { + if (isAnulado) return false; + if (!isEditableWindow(ventanaMinutos)) return false; + if (isSuperadmin) return true; + if (actorUserId == null || createdBy == null) return false; + return actorUserId == createdBy; + } + + /// True si el actor puede anular este pago. Superadmin bypassea ownership + /// y ventana; el resto necesita ser owner y estar dentro de ventana. + bool puedeAnular(String? actorUserId, bool isSuperadmin, int ventanaMinutos) { + if (isAnulado) return false; + if (isSuperadmin) return true; + if (actorUserId == null || createdBy == null) return false; + if (actorUserId != createdBy) return false; + return isEditableWindow(ventanaMinutos); + } +} + +class PagoCliente { + final String nombre; + final String apellido; + final String dni; + + const PagoCliente({ + required this.nombre, + required this.apellido, + required this.dni, + }); + + factory PagoCliente.fromMap(Map map) { + return PagoCliente( + nombre: map['nombre'] as String? ?? '', + apellido: map['apellido'] as String? ?? '', + dni: map['dni'] as String? ?? '', + ); + } + + String get displayName { + if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido'; + if (nombre.isNotEmpty) return nombre; + return dni; + } +} diff --git a/flutter_soma_app/lib/features/pagos/domain/entities/pago_tipo.dart b/flutter_soma_app/lib/features/pagos/domain/entities/pago_tipo.dart new file mode 100644 index 0000000..52617c8 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/domain/entities/pago_tipo.dart @@ -0,0 +1,57 @@ +/// Discriminador de la tabla pagos. El backend acepta los cuatro valores +/// del schema; el frontend de este sprint sólo CREA cuotaMensual (los +/// correctivos llegan desde SQL directo hasta que tengan sus propias +/// funciones fc_*). +enum PagoTipo { + cuotaMensual, + devolucion, + descuentoRetroactivo, + ajuste; + + /// Parsea el valor del backend. Default defensivo: cuotaMensual. + /// El backfill de la migración garantiza que pagos.tipo nunca sea NULL, + /// pero ante un valor desconocido no rompemos el parseo. + factory PagoTipo.fromString(String? raw) { + switch (raw) { + case 'cuota_mensual': + return PagoTipo.cuotaMensual; + case 'devolucion': + return PagoTipo.devolucion; + case 'descuento_retroactivo': + return PagoTipo.descuentoRetroactivo; + case 'ajuste': + return PagoTipo.ajuste; + default: + return PagoTipo.cuotaMensual; + } + } + + /// Valor snake_case que espera el backend. + String get backendValue { + switch (this) { + case PagoTipo.cuotaMensual: + return 'cuota_mensual'; + case PagoTipo.devolucion: + return 'devolucion'; + case PagoTipo.descuentoRetroactivo: + return 'descuento_retroactivo'; + case PagoTipo.ajuste: + return 'ajuste'; + } + } + + /// Etiqueta legible en español. Vive con la entidad por simplicidad + /// (SOMA es app monolingüe). + String get displayName { + switch (this) { + case PagoTipo.cuotaMensual: + return 'Cuota mensual'; + case PagoTipo.devolucion: + return 'Devolución'; + case PagoTipo.descuentoRetroactivo: + return 'Descuento retroactivo'; + case PagoTipo.ajuste: + return 'Ajuste'; + } + } +} diff --git a/flutter_soma_app/lib/features/pagos/domain/repositories/pagos_repository.dart b/flutter_soma_app/lib/features/pagos/domain/repositories/pagos_repository.dart new file mode 100644 index 0000000..1a08e7d --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/domain/repositories/pagos_repository.dart @@ -0,0 +1,42 @@ +import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; + +abstract class PagosRepository { + /// Obtener pagos (admin: todos o filtrados por DNI). + /// [incluirAnulados] default false — equivale al toggle "Mostrar anulados" + /// que el backend gobierna con el parámetro p_incluir_anulados. + Future> getPagos({ + int pagina = 1, + int cantidad = 50, + String? dni, + bool incluirAnulados = false, + }); + + /// Obtener pagos propios del usuario logueado. Siempre incluye anulados: + /// el cliente puede haber visto el pago antes de la edición/anulación, + /// la app cliente marca visualmente los modificados. + Future> getMisPagos({int pagina = 1, int cantidad = 20}); + + /// Registrar un nuevo pago. + Future insertPago(Map datos); + + /// Editar un pago existente. Devuelve el pago actualizado (shape de + /// fc_obtener_pagos para un único objeto). + /// El backend rechaza el llamado si: + /// - el pago no existe o está anulado. + /// - el actor no tiene gestionar_cualquier_pago y no es el creador. + /// - el actor no tiene gestionar_cualquier_pago y la ventana venció. + /// - se intenta editar cliente_id o tipo. + Future editPago(String id, Map datos); + + /// Anular (soft-delete) un pago. [motivo] puede ser null o vacío; + /// el backend lo trimea y persiste como NULL en ese caso. Devuelve + /// el pago actualizado. Misma matriz de permisos que [editPago]. + Future anularPago(String id, String? motivo); + + /// Obtener métodos de pago activos. + Future> getMetodosPago(); + + /// Actualizar un método de pago (descripción, activo, icono). + Future updateMetodoPago(Map datos); +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_estado_provider.dart b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_estado_provider.dart new file mode 100644 index 0000000..bb7e09b --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_estado_provider.dart @@ -0,0 +1,89 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +class PagoConUsuario { + final Pago pago; + final Usuario usuario; + + const PagoConUsuario({required this.pago, required this.usuario}); +} + +class PagosEstadoMes { + final String mes; + final List pagaron; + final List unMesSinPagar; + final List masDe1MesSinPagar; + + const PagosEstadoMes({ + required this.mes, + required this.pagaron, + required this.unMesSinPagar, + required this.masDe1MesSinPagar, + }); + + int get total => pagaron.length + unMesSinPagar.length + masDe1MesSinPagar.length; + double get progressValue => total == 0 ? 0 : pagaron.length / total; +} + +/// Clasifica usuarios activos con plan en 3 grupos para el mes dado ("YYYY-MM"): +/// - pagaron: tienen pago registrado ese mes +/// - unMesSinPagar: no pagaron ese mes pero sí el anterior +/// - masDe1MesSinPagar: no pagaron ese mes ni el anterior +final pagosEstadoProvider = + FutureProvider.autoDispose.family((ref, mes) async { + final usuarios = await ref.watch(allUsuariosProvider.future); + final pagosValue = ref.watch(pagosProvider); + final pagos = pagosValue.valueOrNull ?? []; + + // Mes anterior + final parts = mes.split('-'); + final mesDate = DateTime(int.parse(parts[0]), int.parse(parts[1])); + final mesAnteriorDate = DateTime(mesDate.year, mesDate.month - 1); + final mesAnterior = + '${mesAnteriorDate.year}-${mesAnteriorDate.month.toString().padLeft(2, '0')}'; + + // Indexar pagos por DNI para el mes seleccionado y el anterior + final pagosMes = {}; + final pagosAnterior = {}; + for (final p in pagos) { + if (p.cliente == null) continue; + if (p.anioMesPagado.startsWith('$mes-')) { + pagosMes[p.cliente!.dni] = p; + } + if (p.anioMesPagado.startsWith('$mesAnterior-')) { + pagosAnterior[p.cliente!.dni] = true; + } + } + + final conPlan = + usuarios.where((u) => u.isActive && u.tipoCuota != null).toList(); + + final pagaron = []; + final unMes = []; + final masDe1Mes = []; + + for (final u in conPlan) { + final pago = pagosMes[u.dni]; + if (pago != null) { + pagaron.add(PagoConUsuario(pago: pago, usuario: u)); + } else if (pagosAnterior.containsKey(u.dni)) { + unMes.add(u); + } else { + masDe1Mes.add(u); + } + } + + pagaron.sort((a, b) => a.usuario.displayName.compareTo(b.usuario.displayName)); + unMes.sort((a, b) => a.displayName.compareTo(b.displayName)); + masDe1Mes.sort((a, b) => a.displayName.compareTo(b.displayName)); + + return PagosEstadoMes( + mes: mes, + pagaron: pagaron, + unMesSinPagar: unMes, + masDe1MesSinPagar: masDe1Mes, + ); +}); diff --git a/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_filter_provider.dart b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_filter_provider.dart new file mode 100644 index 0000000..f521b64 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_filter_provider.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PagosFilter { + final String? selectedMonth; // "YYYY-MM" o null para todos + final String? selectedMetodo; // Nombre del método o null para todos + + const PagosFilter({ + this.selectedMonth, + this.selectedMetodo, + }); + + PagosFilter copyWith({ + String? Function()? selectedMonth, + String? Function()? selectedMetodo, + }) { + return PagosFilter( + selectedMonth: + selectedMonth != null ? selectedMonth() : this.selectedMonth, + selectedMetodo: + selectedMetodo != null ? selectedMetodo() : this.selectedMetodo, + ); + } + + bool get hasActiveFilters => + selectedMonth != null || selectedMetodo != null; +} + +final pagosFilterProvider = + StateNotifierProvider.autoDispose((ref) { + return PagosFilterNotifier(); +}); + +class PagosFilterNotifier extends StateNotifier { + PagosFilterNotifier() : super(const PagosFilter()); + + void setMonth(String? month) { + state = state.copyWith(selectedMonth: () => month); + } + + void setMetodo(String? metodo) { + state = state.copyWith(selectedMetodo: () => metodo); + } + + void clearFilters() { + state = const PagosFilter(); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_provider.dart b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_provider.dart new file mode 100644 index 0000000..5bda506 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_provider.dart @@ -0,0 +1,259 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/pagos/data/repositories/pagos_repository_impl.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart'; +import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart'; + +final pagosRepositoryProvider = Provider((ref) { + return PagosRepositoryImpl(); +}); + +/// Métodos de pago con CRUD. Dato de configuración, persiste en sesión. +final metodosPagoProvider = StateNotifierProvider>>((ref) { + return MetodosPagoNotifier(ref.read(pagosRepositoryProvider)); +}); + +class MetodosPagoNotifier + extends StateNotifier>> { + final PagosRepository _repository; + + MetodosPagoNotifier(this._repository) + : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + try { + final data = await _repository.getMetodosPago(); + state = AsyncValue.data(data); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future update(Map datos) async { + try { + await _repository.updateMetodoPago(datos); + await load(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } +} + +/// Últimos pagos de un usuario por DNI (para detalle de usuario). +/// autoDispose: se descarta al salir del detalle y refetchea al volver. +/// [incluirAnulados] default false; cambiar a true para el toggle +/// "Mostrar anulados" en el detalle. +final userPagosProvider = FutureProvider.autoDispose + .family, ({String dni, bool incluirAnulados})>((ref, args) async { + final repo = ref.read(pagosRepositoryProvider); + return repo.getPagos( + dni: args.dni, + cantidad: 10, + incluirAnulados: args.incluirAnulados, + ); +}); + +/// Historial completo de pagos por DNI. +/// cantidad=200 cubre 2 años de cuotas + correctivos teóricos máximos (96) +/// con margen 2x. Si en algún momento un usuario supera esto, hay que pensar +/// en paginación dedicada. +/// autoDispose: se descarta al salir del historial y refetchea al volver. +final userHistorialProvider = FutureProvider.autoDispose + .family, ({String dni, bool incluirAnulados})>((ref, args) async { + final repo = ref.read(pagosRepositoryProvider); + return repo.getPagos( + dni: args.dni, + cantidad: 200, + incluirAnulados: args.incluirAnulados, + ); +}); + +/// Lista de pagos. autoDispose: al navegar fuera de la pantalla de pagos +/// los datos se descartan; al volver se cargan frescos del backend. +final pagosProvider = + StateNotifierProvider.autoDispose>>((ref) { + final user = ref.read(authStateProvider).valueOrNull; + final isAdmin = user != null && user.isStaff; + return PagosNotifier(ref.read(pagosRepositoryProvider), isAdmin); +}); + +class PagosNotifier extends StateNotifier>> { + final PagosRepository _repository; + String? _searchDni; + bool _viewingOwn; + bool _incluirAnulados = false; + + PagosNotifier(this._repository, bool isAdmin) + : _viewingOwn = !isAdmin, + super(const AsyncValue.loading()) { + if (isAdmin) { + loadPagos(); + } else { + loadMisPagos(); + } + } + + bool get incluirAnulados => _incluirAnulados; + + /// Cargar pagos como admin (todos o filtrados por DNI). + Future loadPagos() async { + _viewingOwn = false; + state = const AsyncValue.loading(); + try { + final pagos = await _repository.getPagos( + dni: _searchDni, + incluirAnulados: _incluirAnulados, + ); + if (!mounted) return; + state = AsyncValue.data(pagos); + } catch (e, st) { + if (!mounted) return; + state = AsyncValue.error(e, st); + } + } + + /// Cargar pagos propios del usuario logueado. + /// El backend siempre incluye anulados para el cliente. + Future loadMisPagos() async { + _viewingOwn = true; + state = const AsyncValue.loading(); + try { + final pagos = await _repository.getMisPagos(); + if (!mounted) return; + state = AsyncValue.data(pagos); + } catch (e, st) { + if (!mounted) return; + state = AsyncValue.error(e, st); + } + } + + Future search(String? dni) async { + _searchDni = (dni == null || dni.isEmpty) ? null : dni; + await loadPagos(); + } + + /// Toggle "Mostrar anulados". Sólo afecta la vista admin + /// (loadMisPagos siempre los incluye). + Future setIncluirAnulados(bool value) async { + if (_incluirAnulados == value) return; + _incluirAnulados = value; + if (!_viewingOwn) { + await loadPagos(); + } + } + + Future insertPago(Map datos) async { + try { + await _repository.insertPago(datos); + if (_viewingOwn) { + await loadMisPagos(); + } else { + await loadPagos(); + } + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future editPago(String id, Map datos) async { + try { + await _repository.editPago(id, datos); + // Refetch para consistencia: el backend devuelve el pago actualizado + // pero la lista puede haber cambiado de orden (fecha_pago editada). + if (_viewingOwn) { + await loadMisPagos(); + } else { + await loadPagos(); + } + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future anularPago(String id, String? motivo) async { + try { + await _repository.anularPago(id, motivo); + if (_viewingOwn) { + await loadMisPagos(); + } else { + await loadPagos(); + } + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } +} + +/// Mapa DNI → fecha del último pago (fechaPago del pago más reciente). +/// Filtra por tipo == cuotaMensual: un correctivo (devolución, descuento o +/// ajuste) no representa "haber pagado el mes" — esto matchea el filtro +/// que aplica fc_reservar_turno en la regla de los 2 meses. +/// Se invalida automáticamente cuando la lista de usuarios cambia. +final ultimoPagoMapProvider = + FutureProvider.autoDispose>((ref) async { + ref.watch(allUsuariosProvider); + final repo = ref.read(pagosRepositoryProvider); + final pagos = await repo.getPagos(cantidad: 500); + + final map = {}; + for (final p in pagos) { + if (p.tipo != PagoTipo.cuotaMensual) continue; + if (p.cliente == null) continue; + final dni = p.cliente!.dni; + if (!map.containsKey(dni)) { + // La lista viene ordenada más reciente primero + map[dni] = p.fechaPago ?? DateTime.tryParse(p.anioMesPagado); + } + } + return map; +}); + +// ── Deudores ─────────────────────────────────────────────────────────────────── + +class Deudor { + final Usuario usuario; + final int mesesAdeudados; + const Deudor({required this.usuario, required this.mesesAdeudados}); +} + +int _calcMesesAdeudados(DateTime? ultimoPago, DateTime currentMonth) { + if (ultimoPago == null) return 24; + final lastMonth = DateTime(ultimoPago.year, ultimoPago.month); + final diff = (currentMonth.year - lastMonth.year) * 12 + + currentMonth.month - + lastMonth.month; + return diff.clamp(0, 24); +} + +/// Lista de socios activos con plan que adeudan al menos un mes, ordenados +/// de mayor a menor cantidad de meses sin pagar. +final deudoresProvider = FutureProvider.autoDispose>((ref) async { + final usuarios = await ref.watch(allUsuariosProvider.future); + final ultimoPagoMap = await ref.watch(ultimoPagoMapProvider.future); + + final now = DateTime.now(); + final currentMonth = DateTime(now.year, now.month); + + final deudores = []; + for (final u in usuarios) { + if (!u.isActive || u.tipoCuota == null) continue; + final meses = _calcMesesAdeudados(ultimoPagoMap[u.dni], currentMonth); + if (meses <= 0) continue; + deudores.add(Deudor(usuario: u, mesesAdeudados: meses)); + } + + deudores.sort((a, b) => b.mesesAdeudados.compareTo(a.mesesAdeudados)); + return deudores; +}); diff --git a/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_view_mode_provider.dart b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_view_mode_provider.dart new file mode 100644 index 0000000..bfdd07e --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/providers/pagos_view_mode_provider.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; + +enum PagosViewMode { overview, list, estado } + +final pagosViewModeProvider = + StateNotifierProvider((ref) { + return PagosViewModeNotifier(); +}); + +class PagosViewModeNotifier extends StateNotifier { + PagosViewModeNotifier() : super(PagosViewMode.overview) { + _loadViewMode(); + } + + Future _loadViewMode() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(AppConstants.pagosViewModeKey); + state = switch (stored) { + 'list' => PagosViewMode.list, + 'estado' => PagosViewMode.estado, + _ => PagosViewMode.overview, + }; + } + + Future setMode(PagosViewMode mode) async { + state = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + AppConstants.pagosViewModeKey, + switch (mode) { + PagosViewMode.list => 'list', + PagosViewMode.estado => 'estado', + PagosViewMode.overview => 'overview', + }, + ); + } + + Future toggle() async { + await setMode(switch (state) { + PagosViewMode.overview => PagosViewMode.list, + PagosViewMode.list => PagosViewMode.estado, + PagosViewMode.estado => PagosViewMode.overview, + }); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/screens/metodos_pago_screen.dart b/flutter_soma_app/lib/features/pagos/presentation/screens/metodos_pago_screen.dart new file mode 100644 index 0000000..f774498 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/screens/metodos_pago_screen.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; + +class MetodosPagoScreen extends ConsumerWidget { + const MetodosPagoScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(metodosPagoProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + final theme = Theme.of(context); + + return Scaffold( + body: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + const Text( + 'Métodos de pago', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SomaHeaderHelp( + items: [ + SomaHelpItem( + icon: Icons.touch_app_outlined, + text: 'Tocá un método para activarlo, desactivarlo o ' + 'cambiarle el ícono.', + ), + SomaHelpItem( + icon: Icons.refresh, + text: 'Recarga la lista de métodos de pago.', + ), + ], + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Recargar', + onPressed: () => + ref.read(metodosPagoProvider.notifier).load(), + ), + ], + ), + ), + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => + ref.read(metodosPagoProvider.notifier).load(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (metodos) { + if (metodos.isEmpty) { + return Center( + child: Text( + 'No hay métodos de pago', + style: TextStyle( + fontSize: 15, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ); + } + + return ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 4, isWide ? 32 : 16, 80, + ), + itemCount: metodos.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _MetodoCard(metodo: metodos[index]); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _MetodoCard extends ConsumerWidget { + final MetodoPago metodo; + const _MetodoCard({required this.metodo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final railColor = metodo.activo + ? SomaColors.success.withAlpha(180) + : theme.colorScheme.surfaceContainerHighest; + + final card = InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _showEditDialog(context, ref), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail de estado + Container(width: 4, color: railColor), + + // Contenido + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 8, 11), + child: Row( + children: [ + // Ícono + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: metodo.activo + ? SomaColors.primary.withAlpha(20) + : theme.colorScheme.onSurface.withAlpha(12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _iconForMetodo(metodo.icono), + size: 20, + color: metodo.activo + ? SomaColors.primary + : theme.colorScheme.onSurface.withAlpha(80), + ), + ), + const SizedBox(width: 14), + + // Descripción + badge + Expanded( + child: Row( + children: [ + Expanded( + child: Text( + metodo.descripcion, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _EstadoBadge(activo: metodo.activo), + ], + ), + ), + + const SizedBox(width: 4), + + // Toggle activo + Switch( + value: metodo.activo, + activeTrackColor: SomaColors.success, + activeThumbColor: Colors.white, + onChanged: (value) async { + final error = + await ref.read(metodosPagoProvider.notifier).update({ + 'id': metodo.id, + 'activo': value, + }); + if (!context.mounted) return; + if (error != null) { + SomaToast.show(context, + message: error, type: ToastType.error); + } + }, + ), + + // Edit + IconButton( + icon: Icon( + Icons.edit_outlined, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + tooltip: 'Editar', + onPressed: () => _showEditDialog(context, ref), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 34, + minHeight: 34, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + + if (!metodo.activo) { + return Opacity(opacity: 0.6, child: card); + } + return card; + } + + static const _iconOptions = [ + (null, Icons.payment_outlined, 'Sin ícono'), + ('efectivo', Icons.payments_outlined, 'Efectivo'), + ('transferencia', Icons.account_balance_outlined, 'Transferencia'), + ('tarjeta', Icons.credit_card_outlined, 'Tarjeta'), + ('qr', Icons.qr_code, 'QR'), + ]; + + Future _showEditDialog(BuildContext context, WidgetRef ref) async { + final ctrl = TextEditingController(text: metodo.descripcion); + String? selectedIcon = metodo.icono; + + final result = await showDialog<({String descripcion, String? icono})>( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) { + final theme = Theme.of(ctx); + return AlertDialog( + title: const Text('Editar método de pago'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: ctrl, + decoration: const InputDecoration( + labelText: 'Descripción', + contentPadding: + EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + autofocus: true, + maxLength: 50, + ), + const SizedBox(height: 4), + Text( + 'Ícono', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _iconOptions.map((opt) { + final (key, icon, label) = opt; + final isSelected = selectedIcon == key; + return GestureDetector( + onTap: () => setLocal(() => selectedIcon = key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? SomaColors.primary.withAlpha(22) + : theme.colorScheme.surfaceContainerHighest + .withAlpha(80), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? SomaColors.primary + : theme.colorScheme.surfaceContainerHighest, + width: isSelected ? 1.5 : 0.8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: isSelected + ? SomaColors.primary + : theme.colorScheme.onSurface.withAlpha(150), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? SomaColors.primary + : theme.colorScheme.onSurface + .withAlpha(150), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + ElevatedButton( + onPressed: () { + final text = ctrl.text.trim(); + if (text.isNotEmpty) { + Navigator.of(ctx) + .pop((descripcion: text, icono: selectedIcon)); + } + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 40), + ), + child: const Text('Guardar'), + ), + ], + ); + }, + ), + ); + ctrl.dispose(); + if (result == null || !context.mounted) return; + + final changed = result.descripcion != metodo.descripcion || + result.icono != metodo.icono; + if (!changed) return; + + final error = await ref.read(metodosPagoProvider.notifier).update({ + 'id': metodo.id, + 'descripcion': result.descripcion, + 'icono': result.icono, + }); + if (!context.mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Método actualizado', type: ToastType.success); + } + } + + IconData _iconForMetodo(String? icono) { + switch (icono) { + case 'efectivo': + case 'cash': + return Icons.payments_outlined; + case 'transferencia': + case 'transfer': + return Icons.account_balance_outlined; + case 'tarjeta': + case 'card': + return Icons.credit_card_outlined; + case 'qr': + return Icons.qr_code; + default: + return Icons.payment_outlined; + } + } +} + +class _EstadoBadge extends StatelessWidget { + final bool activo; + const _EstadoBadge({required this.activo}); + + @override + Widget build(BuildContext context) { + final color = activo ? SomaColors.success : SomaColors.error; + final label = activo ? 'Activo' : 'Inactivo'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withAlpha(60), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/screens/pagos_screen.dart b/flutter_soma_app/lib/features/pagos/presentation/screens/pagos_screen.dart new file mode 100644 index 0000000..88dc272 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/screens/pagos_screen.dart @@ -0,0 +1,1136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/config/app_config_provider.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_view_mode_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/anular_pago_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_card.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_detail_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pagos_estado_view.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pagos_overview.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_filter_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pagos_import_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/utils/pagos_export.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +class PagosScreen extends ConsumerStatefulWidget { + const PagosScreen({super.key}); + + @override + ConsumerState createState() => _PagosScreenState(); +} + +class _PagosScreenState extends ConsumerState { + final _searchCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final dni = GoRouterState.of(context).uri.queryParameters['dni']; + if (dni != null && dni.isNotEmpty) { + _searchCtrl.text = dni; + setState(() {}); + } + }); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + bool get _isAdmin { + final user = ref.read(authStateProvider).valueOrNull; + return user != null && user.isStaff; + } + + void _onSearch(String value) { + setState(() {}); // redibuja para que _applyFilters use el texto actualizado + } + + Future _showCreateDialog() async { + final result = await showDialog>( + context: context, + builder: (_) => const PagoFormDialog(), + ); + if (result == null || !mounted) return; + + // Extraer claves que no van al backend antes de insertar pago + final planUpdate = + result.remove('actualizar_plan') as Map?; + final activarUsuarioId = result.remove('activar_usuario') as String?; + + final error = await ref.read(pagosProvider.notifier).insertPago(result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + return; + } + + // Providers cross-feature que dependen de los pagos pero no watchean + // pagosProvider directamente (Usuarios: último pago, deudores, historial). + ref.invalidate(ultimoPagoMapProvider); + ref.invalidate(userPagosProvider); + ref.invalidate(userHistorialProvider); + + // Activar usuario si estaba inactivo al momento de registrar el pago. + if (activarUsuarioId != null) { + final activarError = await ref + .read(usuariosProvider.notifier) + .toggleStatus(activarUsuarioId, true); + if (mounted && activarError != null) { + SomaToast.show(context, + message: 'Pago registrado, pero error al activar usuario: $activarError', + type: ToastType.info); + return; + } + } + + // Actualizar plan del usuario si se pidió. + // updateUsuario ya invalida allUsuariosProvider vía el notifier. + if (planUpdate != null) { + final planError = + await ref.read(usuariosProvider.notifier).updateUsuario({ + 'id': planUpdate['usuario_id'], + 'tipo_cuota': planUpdate['tipo_cuota_id'], + }); + if (mounted && planError != null) { + SomaToast.show(context, + message: 'Pago registrado, pero error al actualizar plan: $planError', + type: ToastType.info); + return; + } + } + + if (mounted) { + final partes = ['Pago registrado']; + if (activarUsuarioId != null) partes.add('usuario activado'); + if (planUpdate != null) partes.add('plan actualizado'); + SomaToast.show(context, + message: partes.join(', '), + type: ToastType.success); + } + } + + Future _showPagoDetail(Pago pago) async { + final puedeEdit = _puedeEditarPago(pago); + final puedeAnular = _puedeAnularPago(pago); + await showDialog( + context: context, + builder: (dialogCtx) => PagoDetailDialog( + pago: pago, + onEdit: puedeEdit + ? () { + Navigator.of(dialogCtx).pop(); + _showEditDialog(pago); + } + : null, + onAnular: puedeAnular + ? () { + Navigator.of(dialogCtx).pop(); + _showAnularDialog(pago); + } + : null, + ), + ); + } + + /// Edit y anular tienen reglas asimétricas en el backend: la ventana de + /// edición aplica a todos (incluso superadmin); la anulación bypassea + /// ownership y ventana para superadmin. + bool _puedeEditarPago(Pago pago) { + final user = ref.read(authStateProvider).valueOrNull; + if (user == null || !user.isStaff) return false; + final ventana = ref.read(appConfigProvider).valueOrNull?.pagosVentanaEdicionMinutos + ?? AppConstants.pagosVentanaEdicionMinutosDefault; + return pago.puedeEditar(user.id, user.isSuperadmin, ventana); + } + + bool _puedeAnularPago(Pago pago) { + final user = ref.read(authStateProvider).valueOrNull; + if (user == null || !user.isStaff) return false; + final ventana = ref.read(appConfigProvider).valueOrNull?.pagosVentanaEdicionMinutos + ?? AppConstants.pagosVentanaEdicionMinutosDefault; + return pago.puedeAnular(user.id, user.isSuperadmin, ventana); + } + + /// Toast + invalidación de providers derivados después de una operación de + /// pago exitosa. El refetch de la lista lo hace el notifier; acá refrescamos + /// los providers cross-feature (deuda, historial, últimos pagos). + void _handlePagoActionResult({String? err, required String msgOk}) { + if (!mounted) return; + if (err != null) { + SomaToast.show(context, message: err, type: ToastType.error); + return; + } + ref.invalidate(ultimoPagoMapProvider); + ref.invalidate(userPagosProvider); + ref.invalidate(userHistorialProvider); + SomaToast.show(context, message: msgOk, type: ToastType.success); + } + + Future _showEditDialog(Pago pago) async { + final result = await showDialog?>( + context: context, + builder: (_) => PagoFormDialog(pagoExistente: pago), + ); + if (!mounted) return; + if (result == null) return; // cancelado o sin cambios + if (result['_edit'] != true) return; // defensivo: shape inesperado + + final pagoId = result['pago_id'] as String; + final datos = result['datos'] as Map; + final err = + await ref.read(pagosProvider.notifier).editPago(pagoId, datos); + _handlePagoActionResult(err: err, msgOk: 'Pago actualizado'); + } + + Future _showAnularDialog(Pago pago) async { + // El dialog se encarga de llamar pagosNotifier.anularPago internamente + // y muestra el error inline si lo hay. Pop devuelve true sólo en éxito. + final ok = await showDialog( + context: context, + builder: (_) => AnularPagoDialog(pago: pago), + ); + if (!mounted || ok != true) return; + _handlePagoActionResult(err: null, msgOk: 'Pago anulado'); + } + + void _showExportSheet(List filteredPagos, PagosFilter filter) { + final filtroLabel = filter.selectedMonth != null + ? _formatMonthOption(filter.selectedMonth!) + : null; + + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + ListTile( + leading: const Icon(Icons.table_chart_outlined), + title: const Text('Exportar como CSV'), + subtitle: const Text('Formato reimportable'), + onTap: () { + Navigator.pop(ctx); + PagosExport.exportToCsv(context, filteredPagos, + filtroMes: filtroLabel); + }, + ), + ListTile( + leading: const Icon(Icons.picture_as_pdf_outlined), + title: const Text('Exportar como PDF'), + subtitle: const Text('Para imprimir o compartir'), + onTap: () { + Navigator.pop(ctx); + PagosExport.exportToPdf(context, filteredPagos, + filtroMes: filtroLabel); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + List _applyFilters(List pagos, PagosFilter filter) { + var filtered = pagos; + + // Búsqueda local por nombre, apellido o DNI del cliente + final query = _searchCtrl.text.trim().toLowerCase(); + if (query.isNotEmpty) { + filtered = filtered.where((p) { + final c = p.cliente; + if (c == null) return false; + return c.nombre.toLowerCase().contains(query) || + c.apellido.toLowerCase().contains(query) || + c.dni.contains(query) || + c.displayName.toLowerCase().contains(query); + }).toList(); + } + + // Filtrar por mes + if (filter.selectedMonth != null) { + filtered = filtered.where((p) { + return p.anioMesPagado.startsWith(filter.selectedMonth!); + }).toList(); + } + + // Filtrar por método + if (filter.selectedMetodo != null) { + filtered = filtered.where((p) { + return p.metodo == filter.selectedMetodo; + }).toList(); + } + + return filtered; + } + + List _getUniqueMetodos(List pagos) { + final metodos = pagos.map((p) => p.metodo).toSet().toList(); + metodos.sort(); + return metodos; + } + + List _helpItems({ + bool includeSearch = false, + bool includeExportImport = false, + }) => [ + if (includeSearch) + const SomaHelpItem( + icon: Icons.search, + text: 'Buscá pagos por nombre o DNI del cliente.', + ), + const SomaHelpItem( + icon: Icons.dashboard_outlined, + text: 'Cambiá entre resumen, lista y estado de cuotas.', + ), + const SomaHelpItem( + icon: Icons.add, + text: 'Registrá un nuevo pago.', + ), + if (includeExportImport) + const SomaHelpItem( + icon: Icons.download_outlined, + text: 'Exportá los pagos filtrados a CSV o PDF, o importalos ' + 'desde un archivo CSV.', + ), + const SomaHelpItem( + icon: Icons.settings_outlined, + text: 'Métodos de pago: administrá los métodos disponibles ' + '(efectivo, transferencia, etc).', + ), + if (includeSearch) + const SomaHelpItem( + icon: Icons.filter_alt_outlined, + text: 'Filtrá por mes, método de pago o incluí los anulados.', + ), + ]; + + Widget _selectedFilterItem(BuildContext ctx, String label) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(ctx).colorScheme.onSurface, + ), + ), + ); + } + + List _getLast12Months() { + final now = DateTime.now(); + final months = []; + for (int i = 0; i < 12; i++) { + final date = DateTime(now.year, now.month - i, 1); + final monthStr = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + months.add(monthStr); + } + return months; + } + + String _formatMonthOption(String yearMonth) { + final parts = yearMonth.split('-'); + if (parts.length != 2) return yearMonth; + final year = parts[0]; + final month = int.tryParse(parts[1]) ?? 0; + const meses = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + return '${meses[month]} $year'; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(pagosProvider); + final filter = ref.watch(pagosFilterProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + final isAdmin = _isAdmin; + final viewMode = ref.watch(pagosViewModeProvider); + + // Overview mode (solo admin) + if (isAdmin && viewMode == PagosViewMode.overview) { + return Scaffold( + body: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + const Text( + 'Pagos', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + SomaHeaderHelp(items: _helpItems()), + const Spacer(), + _PagosViewToggleButton(isWide: isWide), + const SizedBox(width: 8), + _AddPagoButton(isWide: isWide, onTap: _showCreateDialog), + if (isWide) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => context.go('/pagos/metodos'), + icon: const Icon(Icons.settings_outlined, size: 18), + label: const Text('Métodos de pago'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 42), + textStyle: const TextStyle(fontSize: 13), + ), + ), + ] else ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.settings_outlined, size: 20), + tooltip: 'Métodos de pago', + onPressed: () => context.go('/pagos/metodos'), + ), + ], + ], + ), + ), + const Expanded(child: PagosOverview()), + ], + ), + ); + } + + // Estado de cuotas mode (solo admin) + if (isAdmin && viewMode == PagosViewMode.estado) { + final now = DateTime.now(); + final mesActual = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + return Scaffold( + body: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + const Text( + 'Pagos', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + SomaHeaderHelp(items: _helpItems()), + const Spacer(), + _PagosViewToggleButton(isWide: isWide), + const SizedBox(width: 8), + _AddPagoButton(isWide: isWide, onTap: _showCreateDialog), + if (isWide) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => context.go('/pagos/metodos'), + icon: const Icon(Icons.settings_outlined, size: 18), + label: const Text('Métodos de pago'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 42), + textStyle: const TextStyle(fontSize: 13), + ), + ), + ] else ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.settings_outlined, size: 20), + tooltip: 'Métodos de pago', + onPressed: () => context.go('/pagos/metodos'), + ), + ], + ], + ), + ), + Expanded(child: PagosEstadoView(initialMes: mesActual)), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + if (isWide) ...[ + const Text( + 'Pagos', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + SomaHeaderHelp( + items: _helpItems( + includeSearch: isAdmin, + includeExportImport: isAdmin, + ), + ), + const SizedBox(width: 16), + ], + // Búsqueda por DNI solo para admin + if (isAdmin) + Expanded( + child: TextField( + controller: _searchCtrl, + onChanged: _onSearch, + decoration: InputDecoration( + constraints: const BoxConstraints.tightFor(height: 42), + hintText: 'Buscar por nombre o DNI...', + hintStyle: TextStyle( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(100), + ), + prefixIcon: Icon( + Icons.search, + size: 20, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + suffixIcon: _searchCtrl.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () { + _searchCtrl.clear(); + _onSearch(''); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 0, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: SomaColors.primary, + width: 1.5, + ), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + style: const TextStyle(fontSize: 14), + ), + ) + else + const Spacer(), + + // Nuevo pago + config (solo admin) + if (isAdmin) ...[ + const SizedBox(width: 8), + _PagosViewToggleButton(isWide: isWide), + const SizedBox(width: 8), + _AddPagoButton(isWide: isWide, onTap: _showCreateDialog), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.download_outlined, size: 20), + tooltip: 'Exportar pagos', + constraints: const BoxConstraints(minHeight: 42, maxHeight: 42), + onPressed: state.hasValue + ? () { + final filtered = + _applyFilters(state.requireValue, filter); + _showExportSheet(filtered, filter); + } + : null, + ), + IconButton( + icon: const Icon(Icons.upload_outlined, size: 20), + tooltip: 'Importar pagos desde CSV', + constraints: const BoxConstraints(minHeight: 42, maxHeight: 42), + onPressed: () => showDialog( + context: context, + builder: (_) => const PagosImportDialog(), + ), + ), + const SizedBox(width: 4), + if (isWide) + OutlinedButton.icon( + onPressed: () => context.go('/pagos/metodos'), + icon: const Icon(Icons.settings_outlined, size: 18), + label: const Text('Métodos de pago'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 42), + textStyle: const TextStyle(fontSize: 13), + ), + ) + else + IconButton( + icon: const Icon(Icons.settings_outlined, size: 20), + tooltip: 'Métodos de pago', + onPressed: () => context.go('/pagos/metodos'), + ), + ], + ], + ), + ), + + // Filtros (solo admin) + if (isAdmin) + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + 12, + isWide ? 32 : 16, + 8, + ), + child: state.maybeWhen( + data: (pagos) { + final uniqueMetodos = _getUniqueMetodos(pagos); + final monthOptions = _getLast12Months(); + + return Row( + children: [ + // Filtros agrupados en una pill contenedora + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + width: 0.8, + ), + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Filtro por mes + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton( + value: filter.selectedMonth, + hint: Row( + children: [ + Icon( + Icons.calendar_month_outlined, + size: 15, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(120), + ), + const SizedBox(width: 6), + Text( + 'Mes', + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + selectedItemBuilder: (ctx) => [ + _selectedFilterItem(ctx, 'Mes'), + ...monthOptions.map( + (m) => _selectedFilterItem(ctx, _formatMonthOption(m)), + ), + ], + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text( + 'Todos los meses', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ...monthOptions.map((month) { + return DropdownMenuItem( + value: month, + child: Text( + _formatMonthOption(month), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + }), + ], + onChanged: (value) { + ref.read(pagosFilterProvider.notifier).setMonth(value); + }, + ), + ), + + // Separador vertical + VerticalDivider( + width: 1, + thickness: 0.8, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + + // Filtro por método + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton( + value: filter.selectedMetodo, + hint: Row( + children: [ + Icon( + Icons.payment_outlined, + size: 15, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(120), + ), + const SizedBox(width: 6), + Text( + 'Método', + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + selectedItemBuilder: (ctx) => [ + _selectedFilterItem(ctx, 'Método'), + ...uniqueMetodos.map( + (m) => _selectedFilterItem(ctx, m), + ), + ], + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text( + 'Todos los métodos', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ...uniqueMetodos.map((metodo) { + return DropdownMenuItem( + value: metodo, + child: Text( + metodo, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + }), + ], + onChanged: (value) { + ref + .read(pagosFilterProvider.notifier) + .setMetodo(value); + }, + ), + ), + + // Separador vertical + VerticalDivider( + width: 1, + thickness: 0.8, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + + // Toggle "Mostrar anulados" + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: _AnuladosToggle(), + ), + ], + ), + ), + ), + + // Botón limpiar (solo si hay filtros activos) + if (filter.hasActiveFilters) ...[ + const SizedBox(width: 8), + InkWell( + onTap: () { + ref.read(pagosFilterProvider.notifier).clearFilters(); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: SomaColors.primary.withAlpha(14), + border: Border.all( + color: SomaColors.primary.withAlpha(50), + width: 0.8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.close, + size: 14, + color: SomaColors.primaryText, + ), + const SizedBox(width: 5), + Text( + 'Limpiar', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primaryText, + ), + ), + ], + ), + ), + ), + ], + ], + ); + }, + orElse: () => const SizedBox.shrink(), + ), + ), + + // Lista + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(100), + ), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () { + if (isAdmin) { + ref.read(pagosProvider.notifier).loadPagos(); + } else { + ref.read(pagosProvider.notifier).loadMisPagos(); + } + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (pagos) { + // Aplicar filtros + final filteredPagos = _applyFilters(pagos, filter); + + if (filteredPagos.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.payment_outlined, + size: 56, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(60), + ), + const SizedBox(height: 12), + Text( + _searchCtrl.text.isNotEmpty || filter.hasActiveFilters + ? 'No se encontraron pagos' + : 'No hay pagos registrados', + style: TextStyle( + fontSize: 15, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + color: SomaColors.primary, + onRefresh: () async { + if (isAdmin) { + await ref.read(pagosProvider.notifier).loadPagos(); + } else { + await ref.read(pagosProvider.notifier).loadMisPagos(); + } + }, + child: ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + 4, + isWide ? 32 : 16, + 80, + ), + itemCount: filteredPagos.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final pago = filteredPagos[index]; + final puedeEdit = _puedeEditarPago(pago); + final puedeAnular = _puedeAnularPago(pago); + return PagoCard( + pago: pago, + showCliente: isAdmin, + onTap: () => _showPagoDetail(pago), + onEdit: + puedeEdit ? () => _showEditDialog(pago) : null, + onAnular: puedeAnular + ? () => _showAnularDialog(pago) + : null, + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _PagosViewToggleButton extends ConsumerWidget { + final bool isWide; + + const _PagosViewToggleButton({required this.isWide}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final viewMode = ref.watch(pagosViewModeProvider); + final notifier = ref.read(pagosViewModeProvider.notifier); + final theme = Theme.of(context); + + if (isWide) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: PagosViewMode.overview, + icon: Icon(Icons.dashboard_outlined, size: 18), + tooltip: 'Resumen', + ), + ButtonSegment( + value: PagosViewMode.list, + icon: Icon(Icons.view_list, size: 18), + tooltip: 'Lista', + ), + ButtonSegment( + value: PagosViewMode.estado, + icon: Icon(Icons.assignment_outlined, size: 18), + tooltip: 'Estado de cuotas', + ), + ], + selected: {viewMode}, + onSelectionChanged: (s) => notifier.setMode(s.first), + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + showSelectedIcon: false, + ); + } + + // Narrow: cicla entre modos con un ícono contextual + final (icon, tooltip) = switch (viewMode) { + PagosViewMode.overview => (Icons.view_list, 'Ver lista'), + PagosViewMode.list => (Icons.assignment_outlined, 'Ver estado de cuotas'), + PagosViewMode.estado => (Icons.dashboard_outlined, 'Ver resumen'), + }; + + return IconButton( + onPressed: () => notifier.toggle(), + tooltip: tooltip, + icon: Icon(icon, size: 20), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + foregroundColor: theme.colorScheme.onSurface, + minimumSize: const Size(42, 42), + ), + ); + } +} + +class _AddPagoButton extends StatelessWidget { + final bool isWide; + final VoidCallback onTap; + + const _AddPagoButton({required this.isWide, required this.onTap}); + + @override + Widget build(BuildContext context) { + if (isWide) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.add, size: 20), + label: const Text('Nuevo'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + ); + } + + return SizedBox( + height: 42, + width: 42, + child: IconButton.filled( + onPressed: onTap, + icon: const Icon(Icons.add, size: 22), + style: IconButton.styleFrom( + backgroundColor: SomaColors.primary, + foregroundColor: SomaColors.onPrimary, + ), + ), + ); + } +} + +/// Toggle "Mostrar anulados" para la vista admin "list". Mantiene state +/// local sincronizado con el notifier (única fuente de cambio del flag). +/// Cuando se prende, el notifier recarga con p_incluir_anulados=true. +class _AnuladosToggle extends ConsumerStatefulWidget { + @override + ConsumerState<_AnuladosToggle> createState() => _AnuladosToggleState(); +} + +class _AnuladosToggleState extends ConsumerState<_AnuladosToggle> { + bool _value = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: () { + final next = !_value; + setState(() => _value = next); + ref.read(pagosProvider.notifier).setIncluirAnulados(next); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _value ? SomaColors.error.withAlpha(18) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 14, + color: _value ? SomaColors.error : cs.onSurface.withAlpha(120), + ), + const SizedBox(width: 6), + Text( + 'Anulados', + style: TextStyle( + fontSize: 13, + fontWeight: _value ? FontWeight.w600 : FontWeight.w400, + color: _value ? SomaColors.error : cs.onSurface.withAlpha(160), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/anular_pago_dialog.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/anular_pago_dialog.dart new file mode 100644 index 0000000..7b7d277 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/anular_pago_dialog.dart @@ -0,0 +1,452 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; + +/// Dialog de anulación de un pago. Devuelve `true` en éxito y `null` / +/// `false` al cancelar. Errores del backend se muestran inline; el dialog +/// no se cierra hasta éxito o cancelación explícita. +/// +/// Presets de motivo: orientados a errores de carga. NO incluye +/// "Cliente devolvió plata" — devolución no es anulación +/// (ver plan: "Anular vs correctivos"). +class AnularPagoDialog extends ConsumerStatefulWidget { + final Pago pago; + const AnularPagoDialog({super.key, required this.pago}); + + @override + ConsumerState createState() => _AnularPagoDialogState(); +} + +class _AnularPagoDialogState extends ConsumerState { + static const _presets = [ + 'Cobro duplicado', + 'Cliente equivocado', + 'Error de monto', + 'Error de mes', + ]; + + final _motivoCtrl = TextEditingController(); + String? _selectedPreset; + bool _submitting = false; + String? _error; + + @override + void dispose() { + _motivoCtrl.dispose(); + super.dispose(); + } + + void _onPresetTap(String preset) { + setState(() { + _selectedPreset = preset; + _motivoCtrl.text = preset; + _motivoCtrl.selection = TextSelection.collapsed( + offset: preset.length, + ); + }); + } + + void _onMotivoChanged(String value) { + // Si el texto deja de coincidir con el preset seleccionado, deselecciono. + if (_selectedPreset != null && value.trim() != _selectedPreset) { + setState(() => _selectedPreset = null); + } + } + + Future _submit() async { + if (_submitting) return; + setState(() { + _submitting = true; + _error = null; + }); + + final motivo = _motivoCtrl.text.trim(); + final err = await ref + .read(pagosProvider.notifier) + .anularPago(widget.pago.id, motivo.isEmpty ? null : motivo); + + if (!mounted) return; + if (err == null) { + Navigator.of(context).pop(true); + } else { + setState(() { + _submitting = false; + _error = err; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final pago = widget.pago; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 480, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header(pago: pago, submitting: _submitting), + + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PagoResumenCard(pago: pago), + const SizedBox(height: 14), + _Warning(), + const SizedBox(height: 18), + Text( + 'Motivo (opcional)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.onSurface.withAlpha(160), + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: _presets.map((p) { + final selected = _selectedPreset == p; + return FilterChip( + label: Text(p), + selected: selected, + onSelected: _submitting + ? null + : (_) => _onPresetTap(p), + selectedColor: SomaColors.primary.withAlpha(40), + checkmarkColor: SomaColors.primaryText, + labelStyle: TextStyle( + fontSize: 12, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400, + color: selected + ? SomaColors.primaryText + : cs.onSurface, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: selected + ? SomaColors.primary + : cs.surfaceContainerHighest, + width: 0.8, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + ); + }).toList(), + ), + const SizedBox(height: 10), + SomaTextField( + controller: _motivoCtrl, + labelText: null, + hintText: + 'Escribí un motivo o seleccioná uno arriba', + maxLines: 2, + enabled: !_submitting, + onChanged: _onMotivoChanged, + ), + if (_error != null) ...[ + const SizedBox(height: 14), + _ErrorBanner(message: _error!), + ], + ], + ), + ), + ), + + const Divider(height: 1), + + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(false), + child: Text( + 'Cancelar', + style: TextStyle( + color: cs.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.cancel_outlined, size: 18), + label: Text(_submitting ? 'Anulando…' : 'Anular pago'), + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 42), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + final Pago pago; + final bool submitting; + const _Header({required this.pago, required this.submitting}); + + String get _initials { + final c = pago.cliente; + if (c == null) return '?'; + final n = c.nombre.isNotEmpty ? c.nombre[0] : ''; + final a = c.apellido.isNotEmpty ? c.apellido[0] : ''; + final combo = (n + a).toUpperCase(); + return combo.isEmpty ? '?' : combo; + } + + String get _displayName => + pago.cliente?.displayName ?? 'Pago'; + + String get _subtitle => + pago.cliente != null ? 'DNI ${pago.cliente!.dni}' : ''; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + return Container( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 18), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(30), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + _initials, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: SomaColors.error, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Anular pago', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + _subtitle.isEmpty ? _displayName : '$_displayName · $_subtitle', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(130), + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: submitting + ? null + : () => Navigator.of(context).pop(false), + icon: const Icon(Icons.close, size: 20), + style: IconButton.styleFrom( + backgroundColor: cs.surfaceContainerHighest, + ), + ), + ], + ), + ); + } +} + +class _PagoResumenCard extends StatelessWidget { + final Pago pago; + const _PagoResumenCard({required this.pago}); + + String _formatMonto(double n) => + n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.surfaceContainerHighest, width: 0.8), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pago.mesPagadoDisplay, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + '${pago.metodo} · Cargado ${pago.fechaPagoDisplay}', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(140), + ), + ), + ], + ), + ), + Text( + '\$${_formatMonto(pago.montoTotal)}', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: SomaColors.success, + ), + ), + ], + ), + ); + } +} + +class _Warning extends StatelessWidget { + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.errorContainer.withAlpha(80), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: SomaColors.error.withAlpha(60), + width: 0.8, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: SomaColors.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Esta acción no elimina el pago. Queda registrado como ' + 'anulado con tu nombre y, si dejás motivo, también con esa nota.', + style: TextStyle( + fontSize: 12, + color: cs.onSurface, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + final String message; + const _ErrorBanner({required this.message}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(28), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: SomaColors.error.withAlpha(120), + width: 0.8, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline, + size: 16, + color: SomaColors.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle( + fontSize: 12, + color: SomaColors.error, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_card.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_card.dart new file mode 100644 index 0000000..bbe9175 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_card.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; + +class PagoCard extends StatelessWidget { + final Pago pago; + final bool showCliente; + final VoidCallback? onTap; + final VoidCallback? onHistorial; + // Callbacks de acción admin. Si ambos null y pago no anulado → no se + // muestra el menú 3-puntos (vista cliente o admin sin permiso). + final VoidCallback? onEdit; + final VoidCallback? onAnular; + + const PagoCard({ + super.key, + required this.pago, + this.showCliente = true, + this.onTap, + this.onHistorial, + this.onEdit, + this.onAnular, + }); + + bool get _showMenu => !pago.isAnulado && (onEdit != null || onAnular != null); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final anulado = pago.isAnulado; + final editado = pago.isEditado && !anulado; + final railColor = anulado + ? SomaColors.error.withAlpha(180) + : SomaColors.success.withAlpha(180); + final montoColor = anulado + ? SomaColors.error.withAlpha(160) + : SomaColors.success; + final montoDecoration = anulado ? TextDecoration.lineThrough : null; + final mainTextColor = anulado + ? cs.onSurface.withAlpha(140) + : cs.onSurface; + + return GestureDetector( + onSecondaryTap: onHistorial, + onLongPress: onHistorial, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: railColor), + + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 8, 11), + child: Row( + children: [ + Opacity( + opacity: anulado ? 0.55 : 1, + child: _MonthStamp(anioMes: pago.anioMesPagado), + ), + const SizedBox(width: 14), + + // Info central + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + showCliente && pago.cliente != null + ? pago.cliente!.displayName + : pago.mesPagadoDisplay, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: mainTextColor, + decoration: montoDecoration, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + if (anulado) + const _AnuladoBadge() + else + _MetodoBadge(metodo: pago.metodo), + if (!anulado && + showCliente && + pago.cliente != null) ...[ + const SizedBox(width: 6), + Text( + pago.mesPagadoDisplay, + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(110), + ), + ), + ], + if (!anulado && + pago.detalle?['tipo_cuota'] != null) ...[ + const SizedBox(width: 6), + Flexible( + child: Text( + pago.detalle!['tipo_cuota'].toString(), + style: const TextStyle( + fontSize: 11, + color: SomaColors.primaryText, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + if (anulado) ...[ + const SizedBox(height: 3), + Text( + _anuladoSubline(pago), + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(130), + fontStyle: FontStyle.italic, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + const SizedBox(width: 8), + + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '\$${_formatMonto(pago.montoTotal)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: montoColor, + decoration: montoDecoration, + ), + ), + const SizedBox(height: 3), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (editado) ...[ + Tooltip( + message: _editadoTooltip(pago), + child: Icon( + Icons.edit_outlined, + size: 12, + color: cs.onSurface.withAlpha(120), + ), + ), + const SizedBox(width: 4), + ], + Text( + pago.fechaPagoDisplay, + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(110), + ), + ), + ], + ), + ], + ), + + if (_showMenu) ...[ + const SizedBox(width: 4), + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 18, + color: cs.onSurface.withAlpha(140), + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 44, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 8, + tooltip: 'Acciones', + itemBuilder: (_) => [ + if (onEdit != null) + PopupMenuItem( + value: 'edit', + height: 44, + child: Row( + children: [ + Icon( + Icons.edit_outlined, + size: 18, + color: cs.onSurface.withAlpha(180), + ), + const SizedBox(width: 10), + const Text('Editar'), + ], + ), + ), + if (onAnular != null) + PopupMenuItem( + value: 'anular', + height: 44, + child: Row( + children: [ + Icon( + Icons.cancel_outlined, + size: 18, + color: SomaColors.error, + ), + const SizedBox(width: 10), + Text( + 'Anular', + style: TextStyle( + color: SomaColors.error, + ), + ), + ], + ), + ), + ], + onSelected: (val) { + if (val == 'edit') onEdit?.call(); + if (val == 'anular') onAnular?.call(); + }, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + static String _formatMonto(double n) => + n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2); + + static String _anuladoSubline(Pago p) { + final motivo = (p.motivoAnulacion?.trim().isNotEmpty ?? false) + ? p.motivoAnulacion!.trim() + : 'Sin motivo'; + final autor = p.anuladoPorNombre ?? 'admin'; + final hace = p.anuladoAt != null ? _timeagoEs(p.anuladoAt!) : ''; + return hace.isEmpty + ? '$motivo · por $autor' + : '$motivo · por $autor · $hace'; + } + + static String _editadoTooltip(Pago p) { + final autor = p.updatedByNombre ?? 'admin'; + final cuando = p.updatedAt; + if (cuando == null) return 'Editado por $autor'; + final f = + '${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}'; + return 'Editado por $autor el $f'; + } + + static String _timeagoEs(DateTime when) { + final diff = DateTime.now().difference(when); + if (diff.inSeconds < 60) return 'hace unos segundos'; + if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min'; + if (diff.inHours < 24) return 'hace ${diff.inHours} h'; + if (diff.inDays < 30) { + final d = diff.inDays; + return d == 1 ? 'hace 1 día' : 'hace $d días'; + } + if (diff.inDays < 365) { + final m = (diff.inDays / 30).floor(); + return m == 1 ? 'hace 1 mes' : 'hace $m meses'; + } + final y = (diff.inDays / 365).floor(); + return y == 1 ? 'hace 1 año' : 'hace $y años'; + } +} + +/// Badge "ANULADO" en lugar del método cuando el pago está soft-deleted. +class _AnuladoBadge extends StatelessWidget { + const _AnuladoBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(28), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'ANULADO', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: SomaColors.error, + letterSpacing: 0.5, + ), + ), + ); + } +} + +/// Stamp de mes estilo mini-calendario. +class _MonthStamp extends StatelessWidget { + final String anioMes; // 'YYYY-MM-DD' o 'YYYY-MM' + + const _MonthStamp({required this.anioMes}); + + static const _meses = [ + '', + 'ENE', + 'FEB', + 'MAR', + 'ABR', + 'MAY', + 'JUN', + 'JUL', + 'AGO', + 'SEP', + 'OCT', + 'NOV', + 'DIC', + ]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final date = DateTime.tryParse(anioMes); + final mes = date != null ? _meses[date.month] : '??'; + final anio = date != null ? date.year.toString().substring(2) : ''; + + return Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(18), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: SomaColors.primary.withAlpha(45), width: 0.5), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.fromLTRB(5, 5, 5, 3), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + mes, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + color: SomaColors.onPrimary, + letterSpacing: 0.4, + height: 1, + ), + ), + ), + Text( + anio, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + height: 1.1, + ), + ), + ], + ), + ); + } +} + +/// Badge del método de pago. +class _MetodoBadge extends StatelessWidget { + final String metodo; + + const _MetodoBadge({required this.metodo}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + metodo, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_detail_dialog.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_detail_dialog.dart new file mode 100644 index 0000000..1e20e69 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_detail_dialog.dart @@ -0,0 +1,525 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; + +class PagoDetailDialog extends ConsumerWidget { + final Pago pago; + // Callbacks opcionales para acciones admin. Si ambos null, no se muestra + // el footer de acciones (vista cliente, o admin sin permiso sobre este pago). + // El call-site decide cerrar el detail dialog antes de abrir el siguiente. + final VoidCallback? onEdit; + final VoidCallback? onAnular; + + const PagoDetailDialog({ + super.key, + required this.pago, + this.onEdit, + this.onAnular, + }); + + bool get _showActions => + !pago.isAnulado && (onEdit != null || onAnular != null); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final user = ref.watch(authStateProvider).valueOrNull; + final isAdmin = user?.isStaff ?? false; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + constraints: BoxConstraints( + maxWidth: 500, + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: pago.isAnulado + ? SomaColors.error.withAlpha(30) + : SomaColors.primary.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + pago.isAnulado + ? Icons.cancel_outlined + : Icons.receipt_long, + color: pago.isAnulado + ? SomaColors.error + : SomaColors.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pago.isAnulado ? 'Pago anulado' : 'Detalle de Pago', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + pago.mesPagadoDisplay, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + + // Content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Banner de auditoría arriba de todo si está anulado o editado. + if (pago.isAnulado) + _AnuladoBanner(pago: pago) + else if (pago.isEditado) + _EditadoBanner(pago: pago), + if (pago.isAnulado || pago.isEditado) + const SizedBox(height: 16), + + // Cliente (solo si es admin) + if (isAdmin && pago.cliente != null) ...[ + _DetailRow( + icon: Icons.person_outline, + label: 'Cliente', + value: pago.cliente!.displayName, + valueStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + _DetailRow( + icon: Icons.badge_outlined, + label: 'DNI', + value: pago.cliente!.dni, + ), + const SizedBox(height: 16), + ], + + // Monto (destacado) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + decoration: BoxDecoration( + color: (pago.isAnulado ? SomaColors.error : SomaColors.success) + .withAlpha(14), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: (pago.isAnulado + ? SomaColors.error + : SomaColors.success) + .withAlpha(50), + width: 0.8, + ), + ), + child: Column( + children: [ + Text( + pago.isAnulado ? 'MONTO ANULADO' : 'MONTO TOTAL', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: (pago.isAnulado + ? SomaColors.error + : SomaColors.success) + .withAlpha(180), + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Text( + '\$${_formatMonto(pago.montoTotal)}', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: pago.isAnulado + ? SomaColors.error + : SomaColors.success, + decoration: pago.isAnulado + ? TextDecoration.lineThrough + : null, + height: 1, + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + _SectionTitle(label: 'Detalle'), + const SizedBox(height: 14), + + _DetailRow( + icon: Icons.payment_outlined, + label: 'Método de Pago', + value: pago.metodo, + valueStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + _DetailRow( + icon: Icons.calendar_today_outlined, + label: 'Fecha de Pago', + value: pago.fechaPagoDisplay, + ), + const SizedBox(height: 16), + _DetailRow( + icon: Icons.event_note_outlined, + label: 'Mes Pagado', + value: pago.mesPagadoDisplay, + ), + + if (pago.detalle != null && pago.detalle!.isNotEmpty) ...[ + const SizedBox(height: 24), + _SectionTitle(label: 'Información Adicional'), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withAlpha(80), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.8, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: pago.detalle!.entries.map((entry) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + '${entry.key}:', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ), + Expanded( + flex: 3, + child: Text( + entry.value.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ], + ), + ), + ), + + if (_showActions) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (onAnular != null) + OutlinedButton.icon( + onPressed: onAnular, + icon: const Icon(Icons.cancel_outlined, size: 16), + label: const Text('Anular'), + style: OutlinedButton.styleFrom( + foregroundColor: SomaColors.error, + side: BorderSide( + color: SomaColors.error.withAlpha(140), + width: 1, + ), + minimumSize: const Size(0, 40), + ), + ), + if (onAnular != null && onEdit != null) + const SizedBox(width: 10), + if (onEdit != null) + ElevatedButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('Editar'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 40), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + String _formatMonto(double n) => + n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2); +} + +class _AnuladoBanner extends StatelessWidget { + final Pago pago; + const _AnuladoBanner({required this.pago}); + + String get _fecha { + final a = pago.anuladoAt; + if (a == null) return ''; + return '${a.day.toString().padLeft(2, '0')}/${a.month.toString().padLeft(2, '0')}/${a.year}'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final autor = pago.anuladoPorNombre ?? 'admin'; + final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false) + ? pago.motivoAnulacion!.trim() + : null; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(20), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: SomaColors.error.withAlpha(80), width: 0.8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.cancel_outlined, + size: 18, + color: SomaColors.error, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fecha.isEmpty + ? 'Anulado por $autor' + : 'Anulado por $autor el $_fecha', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: SomaColors.error, + ), + ), + if (motivo != null) ...[ + const SizedBox(height: 4), + Text( + 'Motivo: $motivo', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(180), + height: 1.35, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _EditadoBanner extends StatelessWidget { + final Pago pago; + const _EditadoBanner({required this.pago}); + + String get _fecha { + final u = pago.updatedAt; + if (u == null) return ''; + return '${u.day.toString().padLeft(2, '0')}/${u.month.toString().padLeft(2, '0')}/${u.year}'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final autor = pago.updatedByNombre ?? 'admin'; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: cs.tertiaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon( + Icons.edit_outlined, + size: 16, + color: cs.onTertiaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _fecha.isEmpty + ? 'Última edición: $autor' + : 'Última edición: $autor el $_fecha', + style: TextStyle( + fontSize: 12, + color: cs.onTertiaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String label; + const _SectionTitle({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + label.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(130), + letterSpacing: 0.6, + ), + ), + ], + ); + } +} + +class _DetailRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final TextStyle? valueStyle; + + const _DetailRow({ + required this.icon, + required this.label, + required this.value, + this.valueStyle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + const SizedBox(height: 2), + Text( + value, + style: valueStyle ?? + TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_form_dialog.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_form_dialog.dart new file mode 100644 index 0000000..bd462ef --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pago_form_dialog.dart @@ -0,0 +1,1428 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_config_provider.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +/// Dialog para registrar o editar un pago. +/// +/// Modo crear (default): `pagoExistente` null. Devuelve un `Map` +/// con los datos para `insertPago`. +/// +/// Modo editar: `pagoExistente` no null. Precarga campos editables, bloquea +/// cliente y plan, muestra ventana de edición. Devuelve `null` si no hubo +/// cambios; sino devuelve un mapa con shape: +/// `{ '_edit': true, 'pago_id': uuid, 'datos': delta }` +/// donde `delta` contiene SÓLO los campos modificados respecto al original. +class PagoFormDialog extends ConsumerStatefulWidget { + final String? prefilledDni; + final Pago? pagoExistente; + + const PagoFormDialog({ + super.key, + this.prefilledDni, + this.pagoExistente, + }); + + @override + ConsumerState createState() => _PagoFormDialogState(); +} + +class _PagoFormDialogState extends ConsumerState { + final _formKey = GlobalKey(); + final _montoCtrl = TextEditingController(); + int? _metodoId; + DateTime _mesPagado = DateTime(DateTime.now().year, DateTime.now().month, 1); + Usuario? _selectedUsuario; + TipoCuota? _tipoCuotaUsuario; + DateTime _fechaPago = DateTime.now(); + bool _recargoAplicado = false; + bool _initialized = false; + bool _metodoIdResolved = false; // En edit, asegura cruce metodo por descripción una sola vez. + TipoCuota? _planOverride; // Plan seleccionado manualmente + bool _hasPlanOverride = false; // Distinguir "no tocó dropdown" de "eligió Sin plan" + bool _guardarPlan = false; // Checkbox "guardar plan para este usuario" + Set _mesesPagados = {}; // "YYYY-MM" de meses ya pagados por el usuario + + // Snapshot del estado al abrir el dialog en modo edit, para calcular + // delta al hacer submit. Se setea en didChangeDependencies. + int? _originalMetodoId; + DateTime? _originalMes; + DateTime? _originalFecha; + double? _originalMonto; + + bool get _isEdit => widget.pagoExistente != null; + + @override + void dispose() { + _montoCtrl.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialized) return; + + if (_isEdit) { + _initialized = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _initEditMode(); + }); + return; + } + + if (widget.prefilledDni != null) { + _initialized = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + final usuarios = ref.read(allUsuariosProvider).valueOrNull ?? []; + final usuario = usuarios + .where((u) => u.dni == widget.prefilledDni) + .firstOrNull; + if (usuario != null) { + _onUsuarioSelected(usuario); + } + }); + } + } + + void _initEditMode() { + final pago = widget.pagoExistente!; + // Resolver usuario por DNI del cliente del pago. + final usuarios = ref.read(allUsuariosProvider).valueOrNull ?? []; + final usuario = pago.cliente != null + ? usuarios.where((u) => u.dni == pago.cliente!.dni).firstOrNull + : null; + + // Mes pagado desde el string "YYYY-MM-DD". + final mes = DateTime.tryParse(pago.anioMesPagado); + final mesPagado = mes != null + ? DateTime(mes.year, mes.month, 1) + : _mesPagado; + + // Plan asociado (readonly en edit): cruzar tipo_cuota_id por nombre + // dentro del detalle. El backend mete `cuota_nombre` cuando se inserta + // con tipo_cuota_id; usamos eso para mostrarlo informativo. + final tiposCuota = ref.read(tiposCuotaProvider).valueOrNull ?? []; + TipoCuota? plan; + final detalle = pago.detalle; + if (detalle != null) { + final nombre = detalle['cuota_nombre'] ?? detalle['tipo_cuota']; + if (nombre is String) { + plan = tiposCuota.where((t) => t.nombre == nombre).firstOrNull; + } + } + + if (!mounted) return; + setState(() { + _selectedUsuario = usuario; + _tipoCuotaUsuario = plan; + _mesPagado = mesPagado; + _fechaPago = pago.fechaPago ?? DateTime.now(); + _montoCtrl.text = pago.montoTotal.toStringAsFixed( + pago.montoTotal.truncateToDouble() == pago.montoTotal ? 0 : 2, + ); + // Snapshot para comparar en submit. + _originalMes = mesPagado; + _originalFecha = pago.fechaPago; + _originalMonto = pago.montoTotal; + }); + } + + void _onUsuarioSelected(Usuario u) { + setState(() => _selectedUsuario = u); + _prefillMonto(); + _loadMesesPagados(u.dni); + } + + void _onUsuarioCleared() { + setState(() { + _selectedUsuario = null; + _tipoCuotaUsuario = null; + _recargoAplicado = false; + _planOverride = null; + _hasPlanOverride = false; + _guardarPlan = false; + _mesesPagados = {}; + _montoCtrl.clear(); + }); + } + + Future _loadMesesPagados(String dni) async { + try { + final repo = ref.read(pagosRepositoryProvider); + final pagos = await repo.getPagos(dni: dni, cantidad: 50); + if (mounted) { + setState(() { + _mesesPagados = pagos + .map((p) { + final date = DateTime.tryParse(p.anioMesPagado); + if (date == null) return null; + return '${date.year}-${date.month.toString().padLeft(2, '0')}'; + }) + .whereType() + .toSet(); + }); + _prefillMonto(); + } + } catch (_) { + // Silencioso — el picker de meses simplemente no muestra info de pagos + } + } + + void _prefillMonto() { + if (_selectedUsuario == null) { + setState(() { + _tipoCuotaUsuario = null; + _recargoAplicado = false; + }); + return; + } + + // Usar plan override si el usuario explícitamente cambió, sino el plan original + final tiposCuota = ref.read(tiposCuotaProvider).valueOrNull ?? []; + final tc = _hasPlanOverride + ? _planOverride + : (_selectedUsuario!.tipoCuota != null + ? tiposCuota + .where((t) => t.id == _selectedUsuario!.tipoCuota) + .firstOrNull + : null); + + if (tc == null) { + setState(() { + _tipoCuotaUsuario = null; + _recargoAplicado = false; + _montoCtrl.clear(); + }); + return; + } + + double monto = tc.precio; + bool recargo = false; + + final mesKey = + '${_mesPagado.year}-${_mesPagado.month.toString().padLeft(2, '0')}'; + final yaPageado = _mesesPagados.contains(mesKey); + final fechaVencimiento = DateTime( + _mesPagado.year, _mesPagado.month, tc.diaDePago, + ); + if (!yaPageado && + tc.recargo != null && + tc.recargo! > 0 && + _fechaPago.isAfter(fechaVencimiento)) { + monto += tc.recargo!; + recargo = true; + } + + setState(() { + _tipoCuotaUsuario = tc; + _recargoAplicado = recargo; + _montoCtrl.text = monto.toStringAsFixed( + monto.truncateToDouble() == monto ? 0 : 2); + }); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + if (_metodoId == null || _selectedUsuario == null) return; + + if (_isEdit) { + _submitEdit(); + return; + } + + final data = { + 'dni': _selectedUsuario!.dni, + 'metodo_id': _metodoId, + 'anio_mes_pagado': + '${_mesPagado.year}-${_mesPagado.month.toString().padLeft(2, '0')}-01', + 'monto_total': double.tryParse(_montoCtrl.text.trim()) ?? 0, + 'fecha_pago': _fechaPago.toIso8601String().split('T').first, + if (_tipoCuotaUsuario != null) 'tipo_cuota_id': _tipoCuotaUsuario!.id, + if (_guardarPlan && _planOverride != null) 'set_default_tipo_cuota': true, + if (!_selectedUsuario!.isActive) 'activar_usuario': _selectedUsuario!.id, + }; + + Navigator.of(context).pop(data); + } + + /// Calcula delta vs pagoExistente y devuelve el sobre con shape de edición. + /// Si no hubo cambios, popea con null (el call-site no hace request). + void _submitEdit() { + final delta = {}; + + if (_metodoId != _originalMetodoId) { + delta['metodo_id'] = _metodoId; + } + if (_originalMes == null || + _mesPagado.year != _originalMes!.year || + _mesPagado.month != _originalMes!.month) { + delta['anio_mes_pagado'] = + '${_mesPagado.year}-${_mesPagado.month.toString().padLeft(2, '0')}-01'; + } + if (_originalFecha == null || + _fechaPago.year != _originalFecha!.year || + _fechaPago.month != _originalFecha!.month || + _fechaPago.day != _originalFecha!.day) { + delta['fecha_pago'] = _fechaPago.toIso8601String().split('T').first; + } + final nuevoMonto = double.tryParse(_montoCtrl.text.trim()) ?? 0; + if (_originalMonto == null || nuevoMonto != _originalMonto) { + delta['monto_total'] = nuevoMonto; + } + + if (delta.isEmpty) { + Navigator.of(context).pop(null); + return; + } + + Navigator.of(context).pop({ + '_edit': true, + 'pago_id': widget.pagoExistente!.id, + 'datos': delta, + }); + } + + Future _pickFechaPago() async { + final picked = await showDatePicker( + context: context, + initialDate: _fechaPago, + firstDate: DateTime.now().subtract(const Duration(days: 90)), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() => _fechaPago = picked); + _prefillMonto(); + } + } + + Future _pickMonth() async { + final result = await showDialog( + context: context, + builder: (_) => _MonthYearPickerDialog( + initialDate: _mesPagado, + mesesPagados: _mesesPagados, + ), + ); + if (result == null) return; + final key = + '${result.year}-${result.month.toString().padLeft(2, '0')}'; + if (_mesesPagados.contains(key) && mounted) { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Mes ya pagado'), + content: Text( + 'Este usuario ya tiene un pago registrado para ' + '${_monthName(result.month)} ${result.year}. ¿Deseas continuar?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Continuar'), + ), + ], + ), + ); + if (confirm != true) return; + } + setState(() => _mesPagado = result); + _prefillMonto(); + } + + static String _monthName(int month) { + const meses = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + return meses[month]; + } + + @override + Widget build(BuildContext context) { + final metodosAsync = ref.watch(metodosPagoProvider); + final usuariosAsync = ref.watch(allUsuariosProvider); + ref.watch(tiposCuotaProvider); + // Recalcular monto cuando los tipos de cuota terminan de cargar + ref.listen(tiposCuotaProvider, (prev, next) { + if (_selectedUsuario != null && _tipoCuotaUsuario == null && next.hasValue) { + _prefillMonto(); + } + }); + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + final theme = Theme.of(context); + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 460) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Text( + _isEdit ? 'Editar pago' : 'Registrar Pago', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + if (_isEdit) _EditBanner(pago: widget.pagoExistente!), + + // Form + Flexible( + child: SingleChildScrollView( + clipBehavior: Clip.none, + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Selector / info de usuario + if (_isEdit) + _ClienteReadOnly(usuario: _selectedUsuario) + else + _UsuarioAutocomplete( + usuarios: usuariosAsync.valueOrNull ?? const [], + selected: _selectedUsuario, + onSelected: _onUsuarioSelected, + onCleared: _onUsuarioCleared, + ), + // Selector de plan — sólo en modo crear. + // En edit, fc_editar_pago no toca usuarios.tipo_cuota + // y el tipo_cuota del pago no es editable (anular + crear). + if (!_isEdit && _selectedUsuario != null) ...[ + const SizedBox(height: 12), + Text( + 'Plan', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + _PlanSelector( + tiposCuota: + ref.watch(tiposCuotaProvider).valueOrNull ?? + const [], + selected: _tipoCuotaUsuario, + originalPlanId: _selectedUsuario!.tipoCuota, + onChanged: (tc) { + setState(() { + _planOverride = tc; + _hasPlanOverride = true; + _guardarPlan = tc != null && + tc.id != _selectedUsuario?.tipoCuota; + }); + _prefillMonto(); + }, + theme: theme, + ), + if (_hasPlanOverride && _planOverride != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + SizedBox( + height: 28, + width: 28, + child: Checkbox( + value: _guardarPlan, + onChanged: (v) => setState( + () => _guardarPlan = v ?? false), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 4), + Expanded( + child: GestureDetector( + onTap: () => setState( + () => _guardarPlan = !_guardarPlan), + child: Text( + 'Guardar como plan del usuario', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(153), + ), + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + + // Mes a pagar + Text( + 'Mes a pagar', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + InkWell( + onTap: _pickMonth, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 52, + padding: + const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + ), + child: Row( + children: [ + Icon( + Icons.calendar_month_outlined, + size: 22, + color: theme.colorScheme.onSurface + .withAlpha(153), + ), + const SizedBox(width: 12), + Text( + _mesPagadoLabel, + style: TextStyle( + fontSize: 16, + color: theme.colorScheme.onSurface, + ), + ), + const Spacer(), + Icon( + Icons.unfold_more, + size: 20, + color: theme.colorScheme.onSurface + .withAlpha(100), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Fecha de pago + Text( + 'Fecha de pago', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + InkWell( + onTap: _pickFechaPago, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 52, + padding: + const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + ), + child: Row( + children: [ + Icon( + Icons.today_outlined, + size: 22, + color: theme.colorScheme.onSurface + .withAlpha(153), + ), + const SizedBox(width: 12), + Text( + _fechaPagoLabel, + style: TextStyle( + fontSize: 16, + color: theme.colorScheme.onSurface, + ), + ), + const Spacer(), + Icon( + Icons.unfold_more, + size: 20, + color: theme.colorScheme.onSurface + .withAlpha(100), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + SomaTextField( + controller: _montoCtrl, + labelText: 'Monto total *', + prefixIcon: Icons.attach_money, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d{0,8}\.?\d{0,2}'), + ), + ], + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Monto requerido'; + } + final parsed = double.tryParse(v.trim()); + if (parsed == null || parsed < 0) { + return 'Monto inválido'; + } + return null; + }, + ), + // Aviso de recargo + if (_recargoAplicado && _tipoCuotaUsuario != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.info_outline, + size: 14, color: theme.colorScheme.tertiary), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Incluye recargo de \$${_fmtNum(_tipoCuotaUsuario!.recargo!)} (vto. día ${_tipoCuotaUsuario!.diaDePago})', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.tertiary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Método de pago + metodosAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ), + ), + error: (e, _) => Text( + 'Error cargando métodos de pago', + style: TextStyle( + color: SomaColors.error, + fontSize: 13, + ), + ), + data: (metodos) { + final activos = + metodos.where((m) => m.activo).toList(); + // En edit: resolver el ID original por descripción + // y precargar el dropdown. En create: default al primero activo. + if (_isEdit && !_metodoIdResolved) { + final desc = widget.pagoExistente!.metodo; + final match = metodos + .where((m) => m.descripcion == desc) + .firstOrNull; + if (match != null) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (mounted) { + setState(() { + _metodoId = match.id; + _originalMetodoId = match.id; + _metodoIdResolved = true; + }); + } + }); + } else { + _metodoIdResolved = true; + } + } else if (!_isEdit && + _metodoId == null && + activos.isNotEmpty) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (mounted && _metodoId == null) { + setState( + () => _metodoId = activos.first.id); + } + }); + } + return DropdownButtonFormField( + initialValue: _metodoId, + items: activos + .map((m) => DropdownMenuItem( + value: m.id, + child: Text(m.descripcion), + )) + .toList(), + onChanged: (v) => + setState(() => _metodoId = v), + decoration: const InputDecoration( + labelText: 'Método de pago *', + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + validator: (v) => + v == null ? 'Seleccioná un método' : null, + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: + theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: Text(_isEdit ? 'Guardar cambios' : 'Registrar'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String get _mesPagadoLabel { + const meses = [ + '', + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', + ]; + return '${meses[_mesPagado.month]} ${_mesPagado.year}'; + } + + String get _fechaPagoLabel { + return '${_fechaPago.day.toString().padLeft(2, '0')}/${_fechaPago.month.toString().padLeft(2, '0')}/${_fechaPago.year}'; + } + + String _fmtNum(double n) => + n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2); +} + +// ─── Selector de Mes / Año ───────────────────────────────────────── + +class _MonthYearPickerDialog extends StatefulWidget { + final DateTime initialDate; + final Set mesesPagados; // "YYYY-MM" + const _MonthYearPickerDialog({ + required this.initialDate, + this.mesesPagados = const {}, + }); + + @override + State<_MonthYearPickerDialog> createState() => + _MonthYearPickerDialogState(); +} + +class _MonthYearPickerDialogState extends State<_MonthYearPickerDialog> { + late int _year; + + @override + void initState() { + super.initState(); + _year = widget.initialDate.year; + } + + static const _meses = [ + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + + bool _isPagado(int mes) { + final key = '$_year-${mes.toString().padLeft(2, '0')}'; + return widget.mesesPagados.contains(key); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.calendar_month_outlined, + color: SomaColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Seleccionar mes', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + + // Año + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => setState(() => _year--), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + Text( + '$_year', + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.w700), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () => setState(() => _year++), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + + // Grid de meses 3x4 + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 2.2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (i) { + final mes = i + 1; + final isSelected = mes == widget.initialDate.month && + _year == widget.initialDate.year; + final isPagado = _isPagado(mes); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + Navigator.of(context).pop(DateTime(_year, mes, 1)), + borderRadius: BorderRadius.circular(10), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: isSelected + ? SomaColors.primary.withAlpha(30) + : isPagado + ? SomaColors.success.withAlpha(15) + : theme.colorScheme.surfaceContainerHighest + .withAlpha(80), + border: isSelected + ? Border.all( + color: SomaColors.primary, width: 1.5) + : isPagado + ? Border.all( + color: + SomaColors.success.withAlpha(100), + width: 1) + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _meses[i], + style: TextStyle( + fontSize: 13, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected + ? SomaColors.primaryText + : theme.colorScheme.onSurface, + ), + ), + if (isPagado) ...[ + const SizedBox(width: 5), + Icon( + Icons.check_circle, + size: 14, + color: SomaColors.success, + ), + ], + ], + ), + ), + ), + ); + }), + ), + ), + + // Leyenda + if (widget.mesesPagados.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, + size: 12, color: SomaColors.success), + const SizedBox(width: 4), + Text( + 'Mes ya pagado', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ─── Selector de plan ─────────────────────────────────────────────── + +class _PlanSelector extends StatelessWidget { + final List tiposCuota; + final TipoCuota? selected; + final String? originalPlanId; + final ValueChanged onChanged; + final ThemeData theme; + + const _PlanSelector({ + required this.tiposCuota, + required this.selected, + required this.originalPlanId, + required this.onChanged, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + ), + child: DropdownButton( + isExpanded: true, + value: selected?.id, + underline: const SizedBox(), + hint: Text( + 'Sin plan — seleccionar uno', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text( + 'Sin plan', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ), + ...tiposCuota.map((tc) { + final isOriginal = tc.id == originalPlanId; + return DropdownMenuItem( + value: tc.id, + child: Row( + children: [ + Expanded( + child: Text( + tc.nombre + (isOriginal ? ' (actual)' : ''), + style: TextStyle( + fontSize: 14, + fontWeight: + isOriginal ? FontWeight.w600 : FontWeight.w400, + color: theme.colorScheme.onSurface, + ), + ), + ), + Text( + '${tc.precioDisplay} • ${tc.diasDisplay}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ), + ); + }), + ], + onChanged: (value) { + if (value == null) { + onChanged(null); + } else { + final tc = tiposCuota.where((t) => t.id == value).firstOrNull; + onChanged(tc); + } + }, + ), + ); + } +} + +// ─── Autocomplete de usuario ──────────────────────────────────────── + +class _UsuarioAutocomplete extends StatelessWidget { + final List usuarios; + final Usuario? selected; + final ValueChanged onSelected; + final VoidCallback onCleared; + + const _UsuarioAutocomplete({ + required this.usuarios, + required this.selected, + required this.onSelected, + required this.onCleared, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (selected != null) { + return _buildSelectedChip(theme); + } + + return Autocomplete( + optionsBuilder: (textEditingValue) { + final query = textEditingValue.text.trim().toLowerCase(); + if (query.isEmpty) return const Iterable.empty(); + + return usuarios.where((u) { + return u.dni.contains(query) || + u.nombre.toLowerCase().contains(query) || + (u.apellido?.toLowerCase().contains(query) ?? false); + }).take(6); + }, + displayStringForOption: (u) => '${u.displayName} - ${u.dni}', + onSelected: onSelected, + fieldViewBuilder: + (context, textController, focusNode, onFieldSubmitted) { + return TextFormField( + controller: textController, + focusNode: focusNode, + decoration: InputDecoration( + labelText: 'Cliente *', + hintText: 'Buscar por nombre o DNI...', + prefixIcon: Icon( + Icons.person_search_outlined, + size: 22, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 16, + ), + validator: (_) { + if (selected == null) return 'Seleccioná un cliente'; + return null; + }, + ); + }, + optionsViewBuilder: (context, onAutoSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.surface, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 260, + maxWidth: 412, + ), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (ctx, i) { + final u = options.elementAt(i); + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: + SomaColors.primary.withAlpha(30), + child: Text( + u.initials, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + title: Text( + u.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'DNI: ${u.dni}', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(120), + ), + ), + onTap: () => onAutoSelected(u), + ); + }, + ), + ), + ), + ); + }, + ); + } + + Widget _buildSelectedChip(ThemeData theme) { + final u = selected!; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + border: Border.all( + color: SomaColors.primary.withAlpha(60), + width: 1, + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text( + u.initials, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + u.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'DNI: ${u.dni}', + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ), + ), + IconButton( + icon: Icon( + Icons.close, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + onPressed: onCleared, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + ), + ], + ), + ); + } +} + +// ─── Banner informativo en modo edición ───────────────────────────── + +class _EditBanner extends ConsumerWidget { + final Pago pago; + const _EditBanner({required this.pago}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final clienteNombre = pago.cliente?.displayName ?? 'cliente'; + final ventana = ref.watch(appConfigProvider).valueOrNull?.pagosVentanaEdicionMinutos + ?? AppConstants.pagosVentanaEdicionMinutosDefault; + final mensaje = _mensaje(clienteNombre, ventana); + + return Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.tertiaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.timelapse, + size: 16, + color: cs.onTertiaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + mensaje, + style: TextStyle( + fontSize: 12, + color: cs.onTertiaryContainer, + height: 1.35, + ), + ), + ), + ], + ), + ), + ); + } + + String _mensaje(String clienteNombre, int ventana) { + final created = pago.createdAt; + if (created == null) { + return 'Editando pago de $clienteNombre · ventana de $ventana min.'; + } + final diff = DateTime.now().difference(created); + final hace = diff.inMinutes; + final restante = ventana - hace; + final hacePart = hace < 1 + ? 'Cargado hace menos de 1 min' + : 'Cargado hace $hace min'; + if (restante <= 0) { + return 'Editando pago de $clienteNombre · $hacePart · ventana vencida ' + '(el backend va a rechazar al guardar).'; + } + return 'Editando pago de $clienteNombre · $hacePart · ' + 'quedan ~$restante min para editar (al abrir).'; + } +} + +// ─── Cliente readonly en modo edición ─────────────────────────────── + +class _ClienteReadOnly extends StatelessWidget { + final Usuario? usuario; + const _ClienteReadOnly({required this.usuario}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final u = usuario; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withAlpha(80), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.surfaceContainerHighest, width: 0.8), + ), + child: Row( + children: [ + Icon( + Icons.person_outline, + size: 18, + color: cs.onSurface.withAlpha(140), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + u?.displayName ?? 'Cargando cliente…', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + if (u != null) ...[ + const SizedBox(height: 2), + Text( + 'DNI ${u.dni}', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(140), + ), + ), + ], + ], + ), + ), + Icon( + Icons.lock_outline, + size: 14, + color: cs.onSurface.withAlpha(110), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_deudores_view.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_deudores_view.dart new file mode 100644 index 0000000..328f339 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_deudores_view.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/services/whatsapp_service.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +class PagosDeudoresView extends ConsumerWidget { + const PagosDeudoresView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final deudoresAsync = ref.watch(deudoresProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + final theme = Theme.of(context); + + return deudoresAsync.when( + loading: () => + const Center(child: CircularProgressIndicator(color: SomaColors.primary)), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(153)), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => ref.invalidate(deudoresProvider), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (deudores) { + if (deudores.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, + size: 60, + color: SomaColors.success.withAlpha(160)), + const SizedBox(height: 16), + const Text( + 'Todos al día', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + 'No hay socios con cuotas pendientes', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ); + } + + final unMes = deudores.where((d) => d.mesesAdeudados == 1).length; + final masDe1 = deudores.where((d) => d.mesesAdeudados > 1).length; + + return Column( + children: [ + // Resumen + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 4, isWide ? 32 : 16, 8, + ), + child: Row( + children: [ + _SummaryChip( + label: '${deudores.length} deudor${deudores.length == 1 ? '' : 'es'}', + color: theme.colorScheme.onSurface, + ), + if (unMes > 0) ...[ + const SizedBox(width: 8), + _SummaryChip( + label: '$unMes × 1 mes', + color: Colors.orange, + ), + ], + if (masDe1 > 0) ...[ + const SizedBox(width: 8), + _SummaryChip( + label: '$masDe1 × 2+ meses', + color: SomaColors.error, + ), + ], + ], + ), + ), + + // Lista + Expanded( + child: ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 0, isWide ? 32 : 16, 80, + ), + itemCount: deudores.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, i) => _DeudorCard(deudor: deudores[i]), + ), + ), + ], + ); + }, + ); + } +} + +// ── Chip de resumen ──────────────────────────────────────────────────────────── + +class _SummaryChip extends StatelessWidget { + final String label; + final Color color; + const _SummaryChip({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withAlpha(16), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withAlpha(50), width: 0.8), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color.withAlpha(200), + ), + ), + ); + } +} + +// ── Tarjeta de deudor ────────────────────────────────────────────────────────── + +class _DeudorCard extends ConsumerWidget { + final Deudor deudor; + const _DeudorCard({required this.deudor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final u = deudor.usuario; + final meses = deudor.mesesAdeudados; + final theme = Theme.of(context); + final railColor = + meses == 1 ? Colors.orange : SomaColors.error; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail de severidad + Container(width: 4, color: railColor.withAlpha(180)), + + // Contenido + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 18, + backgroundColor: railColor.withAlpha(30), + child: Text( + u.initials, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: railColor, + ), + ), + ), + const SizedBox(width: 12), + + // Nombre + DNI + badge + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + u.displayName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Row( + children: [ + Text( + u.dni, + style: TextStyle( + fontSize: 12, + color: + theme.colorScheme.onSurface.withAlpha(120), + ), + ), + const SizedBox(width: 8), + _MesesBadge(meses: meses), + ], + ), + ], + ), + ), + + // Acciones + IconButton( + icon: Icon( + Icons.history, + size: 18, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + tooltip: 'Ver historial', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _verHistorial(context), + ), + IconButton( + icon: const Icon( + Icons.chat_outlined, + size: 18, + color: Color(0xFF25D366), + ), + tooltip: 'Enviar WhatsApp', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _abrirWhatsApp(context), + ), + IconButton( + icon: const Icon( + Icons.payment_outlined, + size: 18, + color: SomaColors.primary, + ), + tooltip: 'Registrar pago', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _registrarPago(context, ref), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _verHistorial(BuildContext context) { + showDialog( + context: context, + builder: (_) => UsuarioHistorialDialog( + dni: deudor.usuario.dni, + nombre: deudor.usuario.displayName, + initials: deudor.usuario.initials, + ), + ); + } + + Future _abrirWhatsApp(BuildContext context) async { + final meses = deudor.mesesAdeudados; + final nombre = deudor.usuario.nombre; + final mesesStr = meses == 1 ? '1 mes' : '$meses meses'; + + final ok = await WhatsAppService.abrirChat( + telefono: deudor.usuario.telefono, + mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. ' + 'Tenés $mesesStr de cuota pendiente. ' + 'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!', + ); + + if (!context.mounted) return; + if (!ok) { + SomaToast.show( + context, + message: WhatsAppService.normalizarNumeroAr(deudor.usuario.telefono) == null + ? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío' + : 'No se pudo abrir WhatsApp', + type: ToastType.error, + ); + } + } + + Future _registrarPago(BuildContext context, WidgetRef ref) async { + final result = await showDialog>( + context: context, + builder: (_) => PagoFormDialog(prefilledDni: deudor.usuario.dni), + ); + if (result == null || !context.mounted) return; + + final planUpdate = + result.remove('actualizar_plan') as Map?; + + final error = await ref.read(pagosProvider.notifier).insertPago(result); + if (!context.mounted) return; + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + return; + } + + if (planUpdate != null) { + final planError = + await ref.read(usuariosProvider.notifier).updateUsuario({ + 'id': planUpdate['usuario_id'], + 'tipo_cuota': planUpdate['tipo_cuota_id'], + }); + if (context.mounted && planError != null) { + SomaToast.show( + context, + message: 'Pago registrado, pero error al actualizar plan: $planError', + type: ToastType.info, + ); + return; + } + } + + if (context.mounted) { + SomaToast.show( + context, + message: + planUpdate != null ? 'Pago registrado y plan actualizado' : 'Pago registrado', + type: ToastType.success, + ); + } + } +} + +// ── Badge de meses ───────────────────────────────────────────────────────────── + +class _MesesBadge extends StatelessWidget { + final int meses; + const _MesesBadge({required this.meses}); + + @override + Widget build(BuildContext context) { + final color = meses == 1 ? Colors.orange : SomaColors.error; + final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withAlpha(55), width: 0.5), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_estado_view.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_estado_view.dart new file mode 100644 index 0000000..e57bfb1 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_estado_view.dart @@ -0,0 +1,823 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/services/whatsapp_service.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_estado_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_detail_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +const _amberColor = Color(0xFFFF8F00); + +class PagosEstadoView extends ConsumerStatefulWidget { + const PagosEstadoView({super.key, required this.initialMes}); + + final String initialMes; + + @override + ConsumerState createState() => _PagosEstadoViewState(); +} + +class _PagosEstadoViewState extends ConsumerState { + late String _selectedMes; + + @override + void initState() { + super.initState(); + _selectedMes = widget.initialMes; + } + + List _getLast12Months() { + final now = DateTime.now(); + return List.generate(12, (i) { + final date = DateTime(now.year, now.month - i, 1); + return '${date.year}-${date.month.toString().padLeft(2, '0')}'; + }); + } + + String _formatMonth(String yearMonth) { + final parts = yearMonth.split('-'); + if (parts.length != 2) return yearMonth; + final month = int.tryParse(parts[1]) ?? 0; + const meses = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + return '${meses[month]} ${parts[0]}'; + } + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width >= 800; + final hPad = isWide ? 32.0 : 16.0; + final months = _getLast12Months(); + + return Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 8), + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + width: 0.8, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton( + value: _selectedMes, + selectedItemBuilder: (ctx) => months + .map( + (m) => Align( + alignment: Alignment.centerLeft, + child: Text( + _formatMonth(m), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(ctx).colorScheme.onSurface, + ), + ), + ), + ) + .toList(), + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + items: months + .map( + (m) => DropdownMenuItem( + value: m, + child: Text( + _formatMonth(m), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _selectedMes = v); + }, + ), + ), + ), + ], + ), + ), + + Expanded( + child: _EstadoContent(mes: _selectedMes, hPad: hPad), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _EstadoContent extends ConsumerWidget { + const _EstadoContent({required this.mes, required this.hPad}); + + final String mes; + final double hPad; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pagosLoading = ref.watch(pagosProvider).isLoading; + final estadoAsync = ref.watch(pagosEstadoProvider(mes)); + final deudoresAsync = ref.watch(deudoresProvider); + + if (pagosLoading || estadoAsync.isLoading) { + return const Center(child: CircularProgressIndicator(color: SomaColors.primary)); + } + + if (estadoAsync.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, + color: Theme.of(context).colorScheme.onSurface.withAlpha(100)), + const SizedBox(height: 12), + Text( + estadoAsync.error.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withAlpha(153)), + ), + ], + ), + ); + } + + final estado = estadoAsync.valueOrNull; + if (estado == null) return const SizedBox.shrink(); + + if (estado.total == 0) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.group_outlined, size: 56, + color: Theme.of(context).colorScheme.onSurface.withAlpha(60)), + const SizedBox(height: 12), + Text( + 'No hay socios con plan activo', + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface.withAlpha(130)), + ), + ], + ), + ); + } + + // Build meses map from deudoresProvider to show exact count for +1 month debtors + final mesesMap = {}; + if (deudoresAsync.hasValue) { + for (final d in deudoresAsync.requireValue) { + mesesMap[d.usuario.id] = d.mesesAdeudados; + } + } + + return ListView( + padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 80), + children: [ + _SummaryCard(estado: estado), + + if (estado.masDe1MesSinPagar.isNotEmpty) ...[ + const SizedBox(height: 16), + _Section( + title: 'Más de un mes sin pagar', + count: estado.masDe1MesSinPagar.length, + accentColor: SomaColors.error, + initiallyExpanded: true, + children: estado.masDe1MesSinPagar + .map((u) => _NoPageCard( + usuario: u, + meses: mesesMap[u.id] ?? 2, + accentColor: SomaColors.error, + )) + .toList(), + ), + ], + + if (estado.unMesSinPagar.isNotEmpty) ...[ + const SizedBox(height: 4), + _Section( + title: 'Sin pagar este mes', + count: estado.unMesSinPagar.length, + accentColor: _amberColor, + initiallyExpanded: true, + children: estado.unMesSinPagar + .map((u) => _NoPageCard( + usuario: u, + meses: 1, + accentColor: _amberColor, + )) + .toList(), + ), + ], + + if (estado.pagaron.isNotEmpty) ...[ + const SizedBox(height: 4), + _Section( + title: 'Pagaron', + count: estado.pagaron.length, + accentColor: SomaColors.success, + initiallyExpanded: false, + children: estado.pagaron + .map((e) => _PagaronCard(pagoConUsuario: e)) + .toList(), + ), + ], + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _Section extends StatefulWidget { + final String title; + final int count; + final Color accentColor; + final bool initiallyExpanded; + final List children; + + const _Section({ + required this.title, + required this.count, + required this.accentColor, + required this.initiallyExpanded, + required this.children, + }); + + @override + State<_Section> createState() => _SectionState(); +} + +class _SectionState extends State<_Section> { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: widget.accentColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + widget.title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: cs.onSurface.withAlpha(153), + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: widget.accentColor.withAlpha(25), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${widget.count}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: widget.accentColor, + ), + ), + ), + const Spacer(), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: cs.onSurface.withAlpha(120), + ), + ], + ), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 6), + ...widget.children.map( + (c) => Padding(padding: const EdgeInsets.only(bottom: 8), child: c), + ), + ], + const SizedBox(height: 4), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({required this.estado}); + + final PagosEstadoMes estado; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.surfaceContainerHighest, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${estado.pagaron.length}', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: SomaColors.success, + height: 1, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 4, left: 6), + child: Text( + 'de ${estado.total} socios pagaron', + style: TextStyle(fontSize: 15, color: cs.onSurface.withAlpha(180)), + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: estado.progressValue, + minHeight: 8, + backgroundColor: cs.surfaceContainerHighest, + valueColor: const AlwaysStoppedAnimation(SomaColors.success), + ), + ), + if (estado.unMesSinPagar.isNotEmpty || + estado.masDe1MesSinPagar.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + if (estado.unMesSinPagar.isNotEmpty) ...[ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: _amberColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + '${estado.unMesSinPagar.length} ${estado.unMesSinPagar.length == 1 ? 'debe' : 'deben'} este mes', + style: + TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)), + ), + ], + if (estado.unMesSinPagar.isNotEmpty && + estado.masDe1MesSinPagar.isNotEmpty) + Text( + ' · ', + style: + TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(80)), + ), + if (estado.masDe1MesSinPagar.isNotEmpty) ...[ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: SomaColors.error, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + '${estado.masDe1MesSinPagar.length} ${estado.masDe1MesSinPagar.length == 1 ? 'moroso' : 'morosos'}', + style: + TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)), + ), + ], + ], + ), + ], + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _NoPageCard extends ConsumerWidget { + final Usuario usuario; + final int meses; + final Color accentColor; + + const _NoPageCard({ + required this.usuario, + required this.meses, + required this.accentColor, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: accentColor.withAlpha(180)), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: accentColor.withAlpha(30), + child: Text( + usuario.initials, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: accentColor, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + usuario.displayName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Row( + children: [ + Text( + usuario.dni, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + const SizedBox(width: 8), + _MesesBadge(meses: meses), + ], + ), + ], + ), + ), + IconButton( + icon: Icon(Icons.history, size: 18, + color: theme.colorScheme.onSurface.withAlpha(130)), + tooltip: 'Ver historial', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _verHistorial(context), + ), + IconButton( + icon: const Icon(Icons.chat_outlined, size: 18, + color: Color(0xFF25D366)), + tooltip: 'Enviar WhatsApp', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _abrirWhatsApp(context), + ), + IconButton( + icon: const Icon(Icons.payment_outlined, size: 18, + color: SomaColors.primary), + tooltip: 'Registrar pago', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => _registrarPago(context, ref), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _verHistorial(BuildContext context) { + showDialog( + context: context, + builder: (_) => UsuarioHistorialDialog( + dni: usuario.dni, + nombre: usuario.displayName, + initials: usuario.initials, + ), + ); + } + + Future _abrirWhatsApp(BuildContext context) async { + final mesesStr = meses == 1 ? '1 mes' : '$meses meses'; + final ok = await WhatsAppService.abrirChat( + telefono: usuario.telefono, + mensaje: 'Hola ${usuario.nombre}, te contactamos desde el gimnasio SOMA. ' + 'Tenés $mesesStr de cuota pendiente. ' + 'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!', + ); + if (!context.mounted) return; + if (!ok) { + SomaToast.show( + context, + message: WhatsAppService.normalizarNumeroAr(usuario.telefono) == null + ? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío' + : 'No se pudo abrir WhatsApp', + type: ToastType.error, + ); + } + } + + Future _registrarPago(BuildContext context, WidgetRef ref) async { + final result = await showDialog>( + context: context, + builder: (_) => PagoFormDialog(prefilledDni: usuario.dni), + ); + if (result == null || !context.mounted) return; + + final planUpdate = result.remove('actualizar_plan') as Map?; + final error = await ref.read(pagosProvider.notifier).insertPago(result); + if (!context.mounted) return; + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + return; + } + + if (planUpdate != null) { + final planError = await ref.read(usuariosProvider.notifier).updateUsuario({ + 'id': planUpdate['usuario_id'], + 'tipo_cuota': planUpdate['tipo_cuota_id'], + }); + if (context.mounted && planError != null) { + SomaToast.show(context, + message: 'Pago registrado, pero error al actualizar plan: $planError', + type: ToastType.info); + return; + } + } + + if (context.mounted) { + SomaToast.show( + context, + message: planUpdate != null + ? 'Pago registrado y plan actualizado' + : 'Pago registrado', + type: ToastType.success, + ); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _PagaronCard extends StatelessWidget { + final dynamic pagoConUsuario; + + const _PagaronCard({required this.pagoConUsuario}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final u = pagoConUsuario.usuario as Usuario; + final pago = pagoConUsuario.pago as Pago; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: SomaColors.success.withAlpha(180)), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: SomaColors.success.withAlpha(30), + child: Text( + u.initials, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.success, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + u.displayName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Row( + children: [ + Text( + u.dni, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + const SizedBox(width: 8), + _MontoChip(monto: pago.montoTotal), + ], + ), + ], + ), + ), + IconButton( + icon: Icon(Icons.history, size: 18, + color: theme.colorScheme.onSurface.withAlpha(130)), + tooltip: 'Ver historial', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => showDialog( + context: context, + builder: (_) => UsuarioHistorialDialog( + dni: u.dni, + nombre: u.displayName, + initials: u.initials, + ), + ), + ), + IconButton( + icon: Icon(Icons.receipt_outlined, size: 18, + color: theme.colorScheme.onSurface.withAlpha(130)), + tooltip: 'Ver pago', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + onPressed: () => showDialog( + context: context, + builder: (_) => PagoDetailDialog(pago: pago), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _MontoChip extends StatelessWidget { + final double monto; + const _MontoChip({required this.monto}); + + String get _label { + if (monto >= 1000) { + final k = monto / 1000; + return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.success.withAlpha(18), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: SomaColors.success.withAlpha(55), width: 0.5), + ), + child: Text( + _label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: SomaColors.success, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _MesesBadge extends StatelessWidget { + final int meses; + const _MesesBadge({required this.meses}); + + @override + Widget build(BuildContext context) { + final color = meses == 1 ? _amberColor : SomaColors.error; + final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withAlpha(55), width: 0.5), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_import_dialog.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_import_dialog.dart new file mode 100644 index 0000000..e183968 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_import_dialog.dart @@ -0,0 +1,397 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/pagos/utils/pagos_import.dart'; + +class PagosImportDialog extends ConsumerStatefulWidget { + const PagosImportDialog({super.key}); + + @override + ConsumerState createState() => _PagosImportDialogState(); +} + +class _PagosImportDialogState extends ConsumerState { + _Step _step = _Step.idle; + PagosImportResult? _result; + int _imported = 0; + int _failed = 0; + String? _currentError; + + Future _pickAndParse() async { + setState(() => _step = _Step.picking); + + final picked = await FilePicker.platform.pickFiles( + dialogTitle: 'Seleccionar CSV de pagos', + type: FileType.custom, + allowedExtensions: ['csv'], + withData: true, + ); + + if (picked == null || picked.files.isEmpty) { + setState(() => _step = _Step.idle); + return; + } + + final bytes = picked.files.first.bytes; + if (bytes == null) { + setState(() { + _step = _Step.idle; + _currentError = 'No se pudo leer el archivo'; + }); + return; + } + + final result = parsePagosCsv(bytes); + setState(() { + _result = result; + _step = result.parseErrors.isNotEmpty ? _Step.idle : _Step.preview; + _currentError = result.parseErrors.isNotEmpty ? result.parseErrors.first : null; + }); + } + + Future _runImport() async { + final result = _result!; + final metodosAsync = ref.read(metodosPagoProvider); + final metodos = metodosAsync.valueOrNull ?? []; + + // Construir mapa nombre (lowercase) → id + final metodoMap = { + for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id, + }; + + setState(() { + _step = _Step.importing; + _imported = 0; + _failed = 0; + }); + + for (final row in result.valid) { + final metodoId = _resolveMetodo(row.metodoNombre, metodoMap); + if (metodoId == null) { + setState(() => _failed++); + continue; + } + + final datos = { + 'dni': row.dni, + 'metodo_id': metodoId, + 'anio_mes_pagado': row.anioMesPagado, + 'monto_total': row.montoTotal, + if (row.fechaPago != null) 'fecha_pago': row.fechaPago, + }; + + final error = await ref.read(pagosProvider.notifier).insertPago(datos); + if (mounted) { + setState(() { + if (error == null) { + _imported++; + } else { + _failed++; + } + }); + } + } + + if (mounted) setState(() => _step = _Step.done); + } + + int? _resolveMetodo(String nombre, Map metodoMap) { + // Exact match (case-insensitive) + return metodoMap[nombre.toLowerCase().trim()]; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AlertDialog( + title: const Text('Importar pagos desde CSV'), + content: SizedBox( + width: 480, + child: _buildContent(theme), + ), + actions: _buildActions(theme), + ); + } + + Widget _buildContent(ThemeData theme) { + switch (_step) { + case _Step.idle: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Seleccioná un archivo CSV exportado desde esta app. ' + 'Las columnas requeridas son: dni, anio_mes_pagado, ' + 'monto_total, metodo.', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(180), + ), + ), + if (_currentError != null) ...[ + const SizedBox(height: 12), + _ErrorChip(message: _currentError!), + ], + ], + ); + + case _Step.picking: + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: CircularProgressIndicator(color: SomaColors.primary), + ), + ); + + case _Step.preview: + final valid = _result!.valid; + final invalid = _result!.invalid; + final metodos = ref.watch(metodosPagoProvider).valueOrNull ?? []; + final metodoMap = { + for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id, + }; + final sinMetodo = valid + .where((r) => _resolveMetodo(r.metodoNombre, metodoMap) == null) + .toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SummaryRow( + icon: Icons.check_circle_outline, + color: SomaColors.success, + label: '${valid.length} filas válidas', + ), + if (sinMetodo.isNotEmpty) ...[ + const SizedBox(height: 4), + _SummaryRow( + icon: Icons.warning_amber_outlined, + color: Colors.orange, + label: '${sinMetodo.length} con método de pago no reconocido ' + '(se saltarán)', + ), + ], + if (invalid.isNotEmpty) ...[ + const SizedBox(height: 4), + _SummaryRow( + icon: Icons.error_outline, + color: SomaColors.error, + label: '${invalid.length} filas con errores (se saltarán)', + ), + ], + if (invalid.isNotEmpty || sinMetodo.isNotEmpty) ...[ + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: ListView( + shrinkWrap: true, + children: [ + ...sinMetodo.map((r) => _ErrorRow( + rowNum: r.rowNumber, + msg: 'Método no reconocido: "${r.metodoNombre}"', + )), + ...invalid.map((r) => _ErrorRow( + rowNum: r.rowNumber, + msg: r.validationError ?? 'Error desconocido', + )), + ], + ), + ), + ], + ], + ); + + case _Step.importing: + final total = _result!.valid.length; + final done = _imported + _failed; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + LinearProgressIndicator( + value: total > 0 ? done / total : null, + color: SomaColors.primary, + ), + const SizedBox(height: 12), + Text( + 'Importando $done / $total...', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withAlpha(160), + ), + ), + const SizedBox(height: 8), + ], + ); + + case _Step.done: + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + if (_imported > 0) + _SummaryRow( + icon: Icons.check_circle_outline, + color: SomaColors.success, + label: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'} correctamente', + ), + if (_failed > 0) ...[ + const SizedBox(height: 4), + _SummaryRow( + icon: Icons.error_outline, + color: SomaColors.error, + label: '$_failed fila${_failed == 1 ? '' : 's'} con error', + ), + ], + const SizedBox(height: 8), + ], + ); + } + } + + List _buildActions(ThemeData theme) { + switch (_step) { + case _Step.idle: + return [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), + ), + FilledButton.icon( + onPressed: _pickAndParse, + icon: const Icon(Icons.folder_open_outlined, size: 18), + label: const Text('Seleccionar archivo'), + ), + ]; + + case _Step.picking: + case _Step.importing: + return const []; + + case _Step.preview: + final importable = _result!.valid.isNotEmpty; + return [ + TextButton( + onPressed: () { + setState(() { + _step = _Step.idle; + _result = null; + }); + }, + child: const Text('Cambiar archivo'), + ), + FilledButton( + onPressed: importable ? _runImport : null, + child: Text( + 'Importar ${_result!.valid.length} pago${_result!.valid.length == 1 ? '' : 's'}', + ), + ), + ]; + + case _Step.done: + return [ + FilledButton( + onPressed: () { + Navigator.pop(context); + if (_imported > 0) { + SomaToast.show( + context, + message: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'}', + type: ToastType.success, + ); + } + }, + child: const Text('Cerrar'), + ), + ]; + } + } +} + +// ── Helpers visuales ────────────────────────────────────────────────────────── + +enum _Step { idle, picking, preview, importing, done } + +class _SummaryRow extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + + const _SummaryRow({ + required this.icon, + required this.color, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle(fontSize: 13, color: color), + ), + ), + ], + ); + } +} + +class _ErrorRow extends StatelessWidget { + final int rowNum; + final String msg; + + const _ErrorRow({required this.rowNum, required this.msg}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + 'Fila $rowNum: $msg', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withAlpha(160), + ), + ), + ); + } +} + +class _ErrorChip extends StatelessWidget { + final String message; + const _ErrorChip({required this.message}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(14), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: SomaColors.error.withAlpha(50), width: 0.8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 14, color: SomaColors.error), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle(fontSize: 12, color: SomaColors.error), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_overview.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_overview.dart new file mode 100644 index 0000000..14b95db --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/pagos_overview.dart @@ -0,0 +1,1359 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; + +class PagosOverview extends ConsumerWidget { + const PagosOverview({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pagosAsync = ref.watch(pagosProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + + return pagosAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Text( + e.toString().replaceFirst('Exception: ', ''), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withAlpha(153), + ), + ), + ), + data: (pagos) => _OverviewContent(pagos: pagos, isWide: isWide), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Glass card — idéntico al de usuarios_overview +// ───────────────────────────────────────────────────────────────────────────── + +class _GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsets padding; + final double radius; + + const _GlassCard({ + required this.child, + this.padding = const EdgeInsets.all(16), + this.radius = 20, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + color: Colors.white.withAlpha(18), + border: Border.all( + color: Colors.white.withAlpha(38), + width: 0.8, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(55), + blurRadius: 24, + spreadRadius: -4, + offset: const Offset(0, 6), + ), + ], + ), + child: child, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Content +// ───────────────────────────────────────────────────────────────────────────── + +class _OverviewContent extends StatelessWidget { + final List pagos; + final bool isWide; + + const _OverviewContent({required this.pagos, required this.isWide}); + + static const _chartColors = [ + SomaColors.primary, + SomaColors.success, + Color(0xFF42A5F5), + Color(0xFFFF7043), + Color(0xFFAB47BC), + Color(0xFF26C6DA), + ]; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final mesActual = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + final mesAnterior = now.month == 1 + ? '${now.year - 1}-12' + : '${now.year}-${(now.month - 1).toString().padLeft(2, '0')}'; + + final pagosEsteMes = pagos + .where((p) => p.anioMesPagado.startsWith(mesActual)) + .toList(); + final pagosMesAnterior = pagos + .where((p) => p.anioMesPagado.startsWith(mesAnterior)) + .toList(); + + final recaudadoEsteMes = pagosEsteMes.fold( + 0.0, + (sum, p) => sum + p.montoTotal, + ); + final recaudadoMesAnterior = pagosMesAnterior.fold( + 0.0, + (sum, p) => sum + p.montoTotal, + ); + + final recaudadoAnual = pagos + .where((p) => p.anioMesPagado.startsWith('${now.year}')) + .fold(0.0, (sum, p) => sum + p.montoTotal); + + final clientesUnicosEsteMes = pagosEsteMes + .map((p) => p.cliente?.dni) + .whereType() + .toSet() + .length; + + final ticketMaxEsteMes = pagosEsteMes.isEmpty + ? 0.0 + : pagosEsteMes.map((p) => p.montoTotal).reduce(math.max); + + final ticketPromedioEsteMes = pagosEsteMes.isEmpty + ? 0.0 + : recaudadoEsteMes / pagosEsteMes.length; + + final ultimos6Meses = _buildUltimos6Meses(pagos, now); + + final promedioMensual6m = ultimos6Meses.isEmpty + ? 0.0 + : ultimos6Meses.fold(0.0, (sum, m) => sum + m.total) / 6; + + // Delta porcentual este mes vs anterior + final delta = recaudadoMesAnterior > 0 + ? ((recaudadoEsteMes - recaudadoMesAnterior) / + recaudadoMesAnterior * + 100) + .toStringAsFixed(0) + : null; + final deltaPositivo = recaudadoEsteMes >= recaudadoMesAnterior; + + // Métodos de pago + final metodosMap = {}; + for (final p in pagos) { + final prev = metodosMap[p.metodo]; + metodosMap[p.metodo] = ( + count: (prev?.count ?? 0) + 1, + total: (prev?.total ?? 0) + p.montoTotal, + ); + } + final metodos = metodosMap.entries.toList() + ..sort((a, b) => b.value.total.compareTo(a.value.total)); + + // Últimos 5 pagos + final ultimos5 = [...pagos] + ..sort((a, b) { + if (a.fechaPago == null && b.fechaPago == null) return 0; + if (a.fechaPago == null) return 1; + if (b.fechaPago == null) return -1; + return b.fechaPago!.compareTo(a.fechaPago!); + }); + final recientes = ultimos5.take(5).toList(); + + final hPad = isWide ? 32.0 : 16.0; + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── KPIs primarios (4 glass cards con icono) ───────────────────── + _PrimaryStatGrid( + recaudadoEsteMes: recaudadoEsteMes, + cantidadEsteMes: pagosEsteMes.length, + recaudadoAnual: recaudadoAnual, + clientesUnicosEsteMes: clientesUnicosEsteMes, + isWide: isWide, + ), + const SizedBox(height: 12), + + // ── KPIs secundarios (4 chips compactos) ───────────────────────── + _SecondaryKPIRow( + recaudadoMesAnterior: recaudadoMesAnterior, + delta: delta, + deltaPositivo: deltaPositivo, + ticketMaxEsteMes: ticketMaxEsteMes, + ticketPromedioEsteMes: ticketPromedioEsteMes, + promedioMensual6m: promedioMensual6m, + isWide: isWide, + ), + const SizedBox(height: 12), + + // ── Pulso de ingresos ───────────────────────────────────────────── + _IngresoPulse(meses: ultimos6Meses), + const SizedBox(height: 12), + + // ── Flujo de caja ───────────────────────────────────────────────── + _FlujoCajaCard(meses: ultimos6Meses), + const SizedBox(height: 12), + + // ── Métodos de pago ─────────────────────────────────────────────── + if (metodos.isNotEmpty) ...[ + isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _MetodosDonutCard( + metodos: metodos, + colors: _chartColors, + totalPagos: pagos.length, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetodosListCard( + metodos: metodos, + colors: _chartColors, + ), + ), + ], + ) + : Column( + children: [ + _MetodosDonutCard( + metodos: metodos, + colors: _chartColors, + totalPagos: pagos.length, + ), + const SizedBox(height: 12), + _MetodosListCard(metodos: metodos, colors: _chartColors), + ], + ), + const SizedBox(height: 12), + ], + + // ── Últimos pagos ───────────────────────────────────────────────── + if (recientes.isNotEmpty) ...[ + _SectionLabel('Últimos pagos'), + const SizedBox(height: 10), + ...recientes.map((p) => _RecentPagoRow(pago: p)), + ], + ], + ), + ); + } + + List<({String mes, String label, double total, int count})> + _buildUltimos6Meses(List pagos, DateTime now) { + const mesesAbrev = [ + '', + 'Ene', + 'Feb', + 'Mar', + 'Abr', + 'May', + 'Jun', + 'Jul', + 'Ago', + 'Sep', + 'Oct', + 'Nov', + 'Dic', + ]; + + final result = <({String mes, String label, double total, int count})>[]; + for (int i = 5; i >= 0; i--) { + final date = DateTime(now.year, now.month - i, 1); + final mesKey = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + final pagosMes = pagos.where((p) => p.anioMesPagado.startsWith(mesKey)); + result.add(( + mes: mesKey, + label: mesesAbrev[date.month], + total: pagosMes.fold(0.0, (sum, p) => sum + p.montoTotal), + count: pagosMes.length, + )); + } + return result; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// KPIs primarios — 4 glass cards con icono (estilo usuarios_overview) +// ───────────────────────────────────────────────────────────────────────────── + +class _PrimaryStatGrid extends StatelessWidget { + final double recaudadoEsteMes; + final int cantidadEsteMes; + final double recaudadoAnual; + final int clientesUnicosEsteMes; + final bool isWide; + + const _PrimaryStatGrid({ + required this.recaudadoEsteMes, + required this.cantidadEsteMes, + required this.recaudadoAnual, + required this.clientesUnicosEsteMes, + required this.isWide, + }); + + static String _fmt(double monto) { + if (monto >= 1000000) return '\$${(monto / 1000000).toStringAsFixed(1)}M'; + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final items = [ + ( + Icons.payments_outlined, + 'Recaudado este mes', + _fmt(recaudadoEsteMes), + SomaColors.primary, + ), + ( + Icons.receipt_long_outlined, + 'Pagos este mes', + '$cantidadEsteMes', + SomaColors.success, + ), + ( + Icons.savings_outlined, + 'Recaudado este año', + _fmt(recaudadoAnual), + const Color(0xFF42A5F5), + ), + ( + Icons.people_outline, + 'Clientes (este mes)', + '$clientesUnicosEsteMes', + const Color(0xFFAB47BC), + ), + ]; + + if (isWide) { + return Row( + children: [ + for (int i = 0; i < items.length; i++) ...[ + Expanded( + child: _PrimaryKPICard( + icon: items[i].$1, + label: items[i].$2, + value: items[i].$3, + color: items[i].$4, + ), + ), + if (i < items.length - 1) const SizedBox(width: 10), + ], + ], + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded( + child: _PrimaryKPICard( + icon: items[0].$1, + label: items[0].$2, + value: items[0].$3, + color: items[0].$4, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _PrimaryKPICard( + icon: items[1].$1, + label: items[1].$2, + value: items[1].$3, + color: items[1].$4, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PrimaryKPICard( + icon: items[2].$1, + label: items[2].$2, + value: items[2].$3, + color: items[2].$4, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _PrimaryKPICard( + icon: items[3].$1, + label: items[3].$2, + value: items[3].$3, + color: items[3].$4, + ), + ), + ], + ), + ], + ); + } +} + +class _PrimaryKPICard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const _PrimaryKPICard({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _GlassCard( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(height: 10), + Text( + value, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: color, + height: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// KPIs secundarios — 4 chips compactos en glass +// ───────────────────────────────────────────────────────────────────────────── + +class _SecondaryKPIRow extends StatelessWidget { + final double recaudadoMesAnterior; + final String? delta; + final bool deltaPositivo; + final double ticketMaxEsteMes; + final double ticketPromedioEsteMes; + final double promedioMensual6m; + final bool isWide; + + const _SecondaryKPIRow({ + required this.recaudadoMesAnterior, + required this.delta, + required this.deltaPositivo, + required this.ticketMaxEsteMes, + required this.ticketPromedioEsteMes, + required this.promedioMensual6m, + required this.isWide, + }); + + static String _fmt(double monto) { + if (monto >= 1000000) return '\$${(monto / 1000000).toStringAsFixed(1)}M'; + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final chips = [ + _SecondaryChip( + label: 'Mes anterior', + value: _fmt(recaudadoMesAnterior), + sublabel: delta != null + ? (deltaPositivo ? '+$delta%' : '$delta%') + : null, + sublabelColor: delta != null + ? (deltaPositivo ? SomaColors.success : SomaColors.error) + : null, + ), + _SecondaryChip( + label: 'Ticket promedio mes', + value: _fmt(ticketPromedioEsteMes), + ), + _SecondaryChip(label: 'Ticket máximo mes', value: _fmt(ticketMaxEsteMes)), + _SecondaryChip( + label: 'Promedio mensual (6m)', + value: _fmt(promedioMensual6m), + ), + ]; + + if (isWide) { + return Row( + children: [ + for (int i = 0; i < chips.length; i++) ...[ + Expanded(child: chips[i]), + if (i < chips.length - 1) const SizedBox(width: 10), + ], + ], + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded(child: chips[0]), + const SizedBox(width: 10), + Expanded(child: chips[1]), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: chips[2]), + const SizedBox(width: 10), + Expanded(child: chips[3]), + ], + ), + ], + ); + } +} + +class _SecondaryChip extends StatelessWidget { + final String label; + final String value; + final String? sublabel; + final Color? sublabelColor; + + const _SecondaryChip({ + required this.label, + required this.value, + this.sublabel, + this.sublabelColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _GlassCard( + radius: 14, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + height: 1.1, + ), + ), + if (sublabel != null) ...[ + const SizedBox(width: 6), + Text( + sublabel!, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: sublabelColor ?? SomaColors.primary, + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pulso de ingresos — strip proporcional de los últimos 6 meses +// ───────────────────────────────────────────────────────────────────────────── + +class _IngresoPulse extends StatelessWidget { + final List<({String mes, String label, double total, int count})> meses; + + const _IngresoPulse({required this.meses}); + + static String _fmt(double monto) { + if (monto >= 1000000) return '\$${(monto / 1000000).toStringAsFixed(1)}M'; + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final totalGlobal = meses.fold(0.0, (sum, m) => sum + m.total); + final maxMes = meses.isEmpty + ? 0.0 + : meses.map((m) => m.total).reduce(math.max); + + // Colores de menor a mayor intensidad: el mes actual (último) es primary + final colors = [ + SomaColors.primary.withAlpha(60), + SomaColors.primary.withAlpha(80), + SomaColors.primary.withAlpha(110), + SomaColors.primary.withAlpha(140), + SomaColors.primary.withAlpha(180), + SomaColors.primary, + ]; + + final mesActual = meses.isNotEmpty ? meses.last : null; + final mesMejor = meses.isEmpty + ? null + : meses.reduce((a, b) => a.total >= b.total ? a : b); + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Pulso de ingresos', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + const Spacer(), + if (mesMejor != null && mesMejor.total > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 3, + ), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(25), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: SomaColors.primary.withAlpha(50), + width: 0.5, + ), + ), + child: Text( + 'Mejor: ${mesMejor.label} ${_fmt(mesMejor.total)}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + + // Strip proporcional — cada mes ocupa ancho proporcional a su total + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 20, + child: totalGlobal == 0 + ? Container(color: theme.colorScheme.surfaceContainerHighest) + : Row( + children: [ + for (int i = 0; i < meses.length; i++) ...[ + if (i > 0) const SizedBox(width: 2), + Expanded( + flex: meses[i].total > 0 + ? (meses[i].total / maxMes * 100).round().clamp( + 5, + 100, + ) + : 3, + child: Container( + color: meses[i].total > 0 + ? colors[i % colors.length] + : theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + + // Leyenda + Wrap( + spacing: 12, + runSpacing: 6, + children: meses.map((m) { + final isActual = m == meses.last; + final idx = meses.indexOf(m); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: m.total > 0 + ? colors[idx % colors.length] + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 5), + Text( + isActual ? '${m.label} (actual)' : m.label, + style: TextStyle( + fontSize: 11, + fontWeight: isActual ? FontWeight.w700 : FontWeight.w400, + color: isActual + ? theme.colorScheme.onSurface.withAlpha(200) + : theme.colorScheme.onSurface.withAlpha(140), + ), + ), + if (m.total > 0) ...[ + const SizedBox(width: 4), + Text( + _fmt(m.total), + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ], + ], + ); + }).toList(), + ), + + // Progreso del mes actual vs el mejor + if (mesActual != null && maxMes > 0 && meses.length > 1) ...[ + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Este mes vs. mejor mes', + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + const Spacer(), + Text( + '${(mesActual.total / maxMes * 100).round()}%', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(180), + ), + ), + ], + ), + const SizedBox(height: 5), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (mesActual.total / maxMes).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: const AlwaysStoppedAnimation( + SomaColors.primary, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flujo de caja — bar chart con glass +// ───────────────────────────────────────────────────────────────────────────── + +class _FlujoCajaCard extends StatelessWidget { + final List<({String mes, String label, double total, int count})> meses; + + const _FlujoCajaCard({required this.meses}); + + static String _fmt(double monto) { + if (monto >= 1000000) return '\$${(monto / 1000000).toStringAsFixed(1)}M'; + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hayDatos = meses.any((m) => m.total > 0); + final maxY = hayDatos + ? meses.map((m) => m.total).reduce(math.max) * 1.25 + : 100.0; + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Flujo de caja', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + const Spacer(), + Text( + 'Últimos 6 meses', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 150, + child: hayDatos + ? BarChart( + BarChartData( + maxY: maxY, + barGroups: meses.asMap().entries.map((e) { + return BarChartGroupData( + x: e.key, + barRods: [ + BarChartRodData( + toY: e.value.total, + color: e.value.total > 0 + ? SomaColors.primary + : theme.colorScheme.surfaceContainerHighest, + width: 28, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(5), + ), + ), + ], + ); + }).toList(), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: theme.colorScheme.onSurface.withAlpha(25), + strokeWidth: 0.8, + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= meses.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + meses[idx].label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface + .withAlpha(140), + ), + ), + ); + }, + ), + ), + ), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => + theme.colorScheme.inverseSurface, + tooltipRoundedRadius: 6, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + if (rod.toY == 0) return null; + return BarTooltipItem( + _fmt(rod.toY), + TextStyle( + color: theme.colorScheme.onInverseSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ); + }, + ), + ), + ), + ) + : Center( + child: Text( + 'Sin datos', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Métodos donut — con glass +// ───────────────────────────────────────────────────────────────────────────── + +class _MetodosDonutCard extends StatelessWidget { + final List> metodos; + final List colors; + final int totalPagos; + + const _MetodosDonutCard({ + required this.metodos, + required this.colors, + required this.totalPagos, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final leadColor = + metodos.isNotEmpty ? colors[0] : SomaColors.primary; + + final sections = metodos.asMap().entries.map((e) { + final color = colors[e.key % colors.length]; + return PieChartSectionData( + color: color, + value: e.value.value.count.toDouble(), + title: '', + radius: 30, + ); + }).toList(); + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Título + total + Row( + children: [ + Expanded( + child: Text( + 'Métodos de pago', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + ), + Text( + '$totalPagos', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: leadColor, + height: 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Donut — mismo patrón que usuarios_overview + SizedBox( + height: 110, + child: Center( + child: AspectRatio( + aspectRatio: 1, + child: PieChart( + PieChartData( + sections: sections, + centerSpaceRadius: 32, + sectionsSpace: 3, + startDegreeOffset: -90, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + + // Leyenda debajo + ...metodos.asMap().entries.map((e) { + final color = colors[e.key % colors.length]; + final pct = totalPagos > 0 + ? (e.value.value.count / totalPagos * 100).round() + : 0; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + e.value.key, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct%', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + const SizedBox(width: 8), + Text( + '${e.value.value.count}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Métodos lista — con glass +// ───────────────────────────────────────────────────────────────────────────── + +class _MetodosListCard extends StatelessWidget { + final List> metodos; + final List colors; + + const _MetodosListCard({required this.metodos, required this.colors}); + + static String _fmt(double monto) { + if (monto >= 1000000) return '\$${(monto / 1000000).toStringAsFixed(1)}M'; + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final maxTotal = metodos.map((e) => e.value.total).reduce(math.max); + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recaudado por método', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + const SizedBox(height: 14), + ...metodos.asMap().entries.map((e) { + final color = colors[e.key % colors.length]; + final barWidth = maxTotal > 0 + ? (e.value.value.total / maxTotal) + : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + e.value.key, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(180), + ), + ), + ), + Text( + _fmt(e.value.value.total), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + const SizedBox(height: 5), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: barWidth.toDouble(), + minHeight: 5, + backgroundColor: theme.colorScheme.onSurface.withAlpha( + 20, + ), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section label +// ───────────────────────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fila de pago reciente — con glass +// ───────────────────────────────────────────────────────────────────────────── + +class _RecentPagoRow extends StatelessWidget { + final Pago pago; + + const _RecentPagoRow({required this.pago}); + + String _initials() { + final c = pago.cliente; + if (c == null) return '?'; + if (c.nombre.isNotEmpty && c.apellido.isNotEmpty) { + return '${c.nombre[0]}${c.apellido[0]}'.toUpperCase(); + } + if (c.nombre.isNotEmpty) return c.nombre[0].toUpperCase(); + return '?'; + } + + static String _fmt(double monto) { + if (monto >= 1000) { + return '\$${(monto / 1000).toStringAsFixed(monto >= 10000 ? 0 : 1)}k'; + } + return '\$${monto.toStringAsFixed(0)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _GlassCard( + radius: 14, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 18, + backgroundColor: SomaColors.primary.withAlpha(35), + child: Text( + _initials(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 10), + // Nombre + mes pagado + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pago.cliente?.displayName ?? 'Sin nombre', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + pago.mesPagadoDisplay, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + ], + ), + ), + // Método + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withAlpha(15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + pago.metodo, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurface.withAlpha(140), + ), + ), + ), + const SizedBox(width: 10), + // Monto + Text( + _fmt(pago.montoTotal), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: SomaColors.success, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/presentation/widgets/usuario_historial_dialog.dart b/flutter_soma_app/lib/features/pagos/presentation/widgets/usuario_historial_dialog.dart new file mode 100644 index 0000000..81a36f8 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/presentation/widgets/usuario_historial_dialog.dart @@ -0,0 +1,821 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; + +/// Muestra el historial de pagos de un usuario en dos layers: +/// 1. Grilla calendar de meses (verde = pagó, gris = no estuvo). +/// Pinta sólo en base a pagos NO anulados (un pago anulado no +/// cuenta el mes como pagado). +/// 2. Lista cronológica de pagos individuales debajo, con toggle +/// "Mostrar anulados" para que Juani vea exactamente qué se cargó, +/// qué se editó y qué se anuló (con motivo y autor). +/// +/// El fetch al backend pide siempre incluirAnulados=true (un único request); +/// el toggle de la sección filtra localmente. +class UsuarioHistorialDialog extends ConsumerStatefulWidget { + const UsuarioHistorialDialog({ + super.key, + required this.dni, + required this.nombre, + required this.initials, + }); + + final String dni; + final String nombre; + final String initials; + + @override + ConsumerState createState() => + _UsuarioHistorialDialogState(); +} + +class _UsuarioHistorialDialogState + extends ConsumerState { + bool _incluirAnulados = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final historialAsync = ref.watch( + userHistorialProvider((dni: widget.dni, incluirAnulados: true)), + ); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + constraints: BoxConstraints( + maxWidth: 560, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(30), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + widget.initials, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: SomaColors.primary, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.nombre, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + Text( + 'DNI ${widget.dni}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + style: IconButton.styleFrom( + backgroundColor: cs.surfaceContainerHighest, + ), + ), + ], + ), + ), + + // Contenido scrollable + Flexible( + fit: FlexFit.loose, + child: historialAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(40), + child: Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'Error al cargar historial', + style: TextStyle(color: cs.onSurface.withAlpha(130)), + ), + ), + ), + data: (pagosAll) { + // pagosAll viene del backend con anulados incluidos. + // Para la grilla calendar: sólo los efectivos (no anulados). + // Para la lista cronológica: filtra según toggle local. + final pagosEfectivos = + pagosAll.where((p) => !p.isAnulado).toList(); + final pagosLista = _incluirAnulados + ? pagosAll + : pagosEfectivos; + + final paidMonths = {}; + for (final p in pagosEfectivos) { + if (p.anioMesPagado.length >= 7) { + paidMonths.add(p.anioMesPagado.substring(0, 7)); + } + } + + final totalEfectivo = pagosEfectivos.fold( + 0, + (sum, p) => sum + p.montoTotal, + ); + final anuladosCount = + pagosAll.where((p) => p.isAnulado).length; + + final now = DateTime.now(); + final currentMonth = + '${now.year}-${now.month.toString().padLeft(2, '0')}'; + + final months = _buildMonthRange(pagosEfectivos, currentMonth); + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stats + _StatsRow( + pagoCount: pagosEfectivos.length, + totalMonto: totalEfectivo, + firstMonth: months.isNotEmpty ? months.first : null, + anuladosCount: anuladosCount, + ), + + const SizedBox(height: 20), + + // Section: Historial calendar + _sectionLabel(context, 'Historial'), + const SizedBox(height: 12), + + if (months.isEmpty) + Center( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'Sin pagos registrados', + style: TextStyle( + color: cs.onSurface.withAlpha(130), + ), + ), + ), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: months + .map( + (m) => _MonthTile( + yearMonth: m, + paid: paidMonths.contains(m), + isCurrent: m == currentMonth, + ), + ) + .toList(), + ), + + const SizedBox(height: 14), + + // Leyenda + Row( + children: [ + _LegendDot( + color: SomaColors.success, + label: 'Pagó', + ), + const SizedBox(width: 16), + _LegendDot( + color: cs.onSurface.withAlpha(40), + label: 'No estuvo', + ), + ], + ), + + const SizedBox(height: 24), + + // Section: Detalle de pagos + toggle anulados + Row( + children: [ + Expanded( + child: + _sectionLabel(context, 'Detalle de pagos'), + ), + FilterChip( + label: const Text('Mostrar anulados'), + selected: _incluirAnulados, + onSelected: (v) => + setState(() => _incluirAnulados = v), + avatar: anuladosCount > 0 + ? CircleAvatar( + radius: 9, + backgroundColor: _incluirAnulados + ? SomaColors.error + : SomaColors.error.withAlpha(120), + child: Text( + '$anuladosCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ) + : null, + labelStyle: TextStyle( + fontSize: 11, + color: cs.onSurface, + ), + selectedColor: + SomaColors.error.withAlpha(30), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: _incluirAnulados + ? SomaColors.error.withAlpha(160) + : cs.surfaceContainerHighest, + width: 0.8, + ), + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + + const SizedBox(height: 10), + + if (pagosLista.isEmpty) + Center( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'Sin pagos para mostrar', + style: TextStyle( + color: cs.onSurface.withAlpha(130), + ), + ), + ), + ) + else + Column( + children: pagosLista + .map((p) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _PagoListItem(pago: p), + )) + .toList(), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + /// Genera la lista de meses desde el primero pagado hasta el mes actual. + /// Recibe sólo los pagos efectivos (anulados ya filtrados). + List _buildMonthRange(List pagos, String currentMonth) { + if (pagos.isEmpty) return []; + + String? firstMonth; + for (final p in pagos) { + if (p.anioMesPagado.length < 7) continue; + final m = p.anioMesPagado.substring(0, 7); + if (firstMonth == null || m.compareTo(firstMonth) < 0) { + firstMonth = m; + } + } + if (firstMonth == null) return []; + + final result = []; + final startParts = firstMonth.split('-'); + var cursor = DateTime(int.parse(startParts[0]), int.parse(startParts[1])); + final endParts = currentMonth.split('-'); + final end = DateTime(int.parse(endParts[0]), int.parse(endParts[1])); + + while (!cursor.isAfter(end)) { + result.add('${cursor.year}-${cursor.month.toString().padLeft(2, '0')}'); + cursor = DateTime(cursor.year, cursor.month + 1); + } + return result; + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +Widget _sectionLabel(BuildContext context, String text) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ); +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _StatsRow extends StatelessWidget { + const _StatsRow({ + required this.pagoCount, + required this.totalMonto, + required this.firstMonth, + required this.anuladosCount, + }); + + final int pagoCount; + final double totalMonto; + final String? firstMonth; + final int anuladosCount; + + String _formatMonto(double n) { + if (n >= 1000000) return '\$${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) { + final k = n / 1000; + return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k'; + } + return '\$${n.toStringAsFixed(0)}'; + } + + String? _formatFirstMonth(String? m) { + if (m == null) return null; + final parts = m.split('-'); + if (parts.length != 2) return m; + const abrev = [ + '', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', + 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', + ]; + final month = int.tryParse(parts[1]) ?? 0; + final year = parts[0].substring(2); + return "${abrev[month]} '$year"; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.surfaceContainerHighest, width: 0.8), + ), + child: Row( + children: [ + _StatCell( + value: '$pagoCount', + label: pagoCount == 1 ? 'pago' : 'pagos', + ), + _StatDivider(), + _StatCell( + value: _formatMonto(totalMonto), + label: 'total acumulado', + ), + if (firstMonth != null) ...[ + _StatDivider(), + _StatCell( + value: _formatFirstMonth(firstMonth) ?? firstMonth!, + label: 'primer pago', + ), + ], + if (anuladosCount > 0) ...[ + _StatDivider(), + _StatCell( + value: '$anuladosCount', + label: anuladosCount == 1 ? 'anulado' : 'anulados', + valueColor: SomaColors.error, + ), + ], + ], + ), + ); + } +} + +class _StatCell extends StatelessWidget { + const _StatCell({ + required this.value, + required this.label, + this.valueColor, + }); + final String value; + final String label; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Expanded( + child: Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: valueColor ?? cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 10, + color: cs.onSurface.withAlpha(120), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _StatDivider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: VerticalDivider( + width: 1, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _MonthTile extends StatelessWidget { + const _MonthTile({ + required this.yearMonth, + required this.paid, + required this.isCurrent, + }); + + final String yearMonth; // "YYYY-MM" + final bool paid; + final bool isCurrent; + + static const _mesAbrev = [ + '', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', + 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final parts = yearMonth.split('-'); + final month = int.tryParse(parts[1]) ?? 0; + final year = parts[0].substring(2); + + final Color bg; + final Color textColor; + final Color borderColor; + final Widget icon; + + if (paid) { + bg = SomaColors.success.withAlpha(18); + textColor = SomaColors.success; + borderColor = SomaColors.success.withAlpha(90); + icon = Icon(Icons.check_rounded, size: 14, color: SomaColors.success); + } else if (isCurrent) { + bg = SomaColors.primary.withAlpha(12); + textColor = cs.onSurface; + borderColor = SomaColors.primary.withAlpha(120); + icon = Icon( + Icons.radio_button_unchecked, + size: 12, + color: cs.onSurface.withAlpha(80), + ); + } else { + bg = cs.surfaceContainerHighest.withAlpha(80); + textColor = cs.onSurface.withAlpha(100); + borderColor = cs.surfaceContainerHighest; + icon = SizedBox( + height: 14, + child: Center( + child: Container( + width: 12, + height: 1.5, + color: cs.onSurface.withAlpha(40), + ), + ), + ); + } + + return Container( + width: 52, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor, width: 0.8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _mesAbrev[month], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + const SizedBox(height: 2), + Text( + "'$year", + style: TextStyle( + fontSize: 9, + color: textColor.withAlpha(180), + ), + ), + const SizedBox(height: 4), + icon, + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.label}); + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// Item compacto de la lista cronológica de pagos. Si el pago está anulado +/// muestra sub-línea VISIBLE con motivo + autor + tiempo. Si está editado +/// muestra mini ícono lápiz con tooltip (info secundaria). +class _PagoListItem extends StatelessWidget { + final Pago pago; + const _PagoListItem({required this.pago}); + + static const _mesAbrev = [ + '', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', + 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', + ]; + + String _mesAbreviado() { + final d = DateTime.tryParse(pago.anioMesPagado); + if (d == null) return pago.anioMesPagado; + return "${_mesAbrev[d.month]} '${d.year.toString().substring(2)}"; + } + + String _formatMonto(double n) => + n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2); + + String _cargadoLabel() { + final f = pago.fechaPago; + if (f == null) return 'Cargado —'; + return 'Cargado ${f.day.toString().padLeft(2, '0')}/${f.month.toString().padLeft(2, '0')}'; + } + + String _anuladoSubline() { + final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false) + ? pago.motivoAnulacion!.trim() + : 'Sin motivo'; + final autor = pago.anuladoPorNombre ?? 'admin'; + final hace = pago.anuladoAt != null ? _timeagoEs(pago.anuladoAt!) : ''; + return hace.isEmpty + ? '$motivo · por $autor' + : '$motivo · por $autor · $hace'; + } + + String _editadoTooltip() { + final autor = pago.updatedByNombre ?? 'admin'; + final cuando = pago.updatedAt; + if (cuando == null) return 'Editado por $autor'; + final f = + '${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}'; + return 'Editado por $autor el $f'; + } + + static String _timeagoEs(DateTime when) { + final diff = DateTime.now().difference(when); + if (diff.inSeconds < 60) return 'hace unos segundos'; + if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min'; + if (diff.inHours < 24) return 'hace ${diff.inHours} h'; + if (diff.inDays < 30) { + final d = diff.inDays; + return d == 1 ? 'hace 1 día' : 'hace $d días'; + } + if (diff.inDays < 365) { + final m = (diff.inDays / 30).floor(); + return m == 1 ? 'hace 1 mes' : 'hace $m meses'; + } + final y = (diff.inDays / 365).floor(); + return y == 1 ? 'hace 1 año' : 'hace $y años'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final anulado = pago.isAnulado; + final editado = pago.isEditado && !anulado; + final mainTextColor = + anulado ? cs.onSurface.withAlpha(140) : cs.onSurface; + final montoColor = + anulado ? SomaColors.error.withAlpha(160) : SomaColors.success; + final decoration = anulado ? TextDecoration.lineThrough : null; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.surfaceContainerHighest, width: 0.6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 60, + child: Text( + _mesAbreviado(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: mainTextColor, + decoration: decoration, + ), + ), + ), + SizedBox( + width: 70, + child: Text( + '\$${_formatMonto(pago.montoTotal)}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: montoColor, + decoration: decoration, + ), + ), + ), + Expanded( + child: Text( + pago.metodo, + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(140), + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (editado) ...[ + Tooltip( + message: _editadoTooltip(), + child: Icon( + Icons.edit_outlined, + size: 12, + color: cs.onSurface.withAlpha(140), + ), + ), + const SizedBox(width: 6), + ], + Text( + _cargadoLabel(), + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(120), + ), + ), + ], + ), + if (anulado) ...[ + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: SomaColors.error.withAlpha(28), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'ANULADO', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: SomaColors.error, + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _anuladoSubline(), + style: TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + color: cs.onSurface.withAlpha(130), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/pagos/utils/pagos_export.dart b/flutter_soma_app/lib/features/pagos/utils/pagos_export.dart new file mode 100644 index 0000000..1795d67 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/utils/pagos_export.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class PagosExport { + // ── CSV ─────────────────────────────────────────────────────────────────── + + static Future exportToCsv( + BuildContext context, + List pagos, { + String? filtroMes, + }) async { + final csvBytes = _buildCsvBytes(pagos); + + final stamp = DateTime.now(); + final defaultName = + 'pagos_${stamp.year}${stamp.month.toString().padLeft(2, '0')}${stamp.day.toString().padLeft(2, '0')}.csv'; + + final outputPath = await FilePicker.platform.saveFile( + dialogTitle: 'Guardar pagos como CSV', + fileName: defaultName, + type: FileType.custom, + allowedExtensions: ['csv'], + ); + + if (outputPath == null) return; // usuario canceló + + await File(outputPath).writeAsBytes(csvBytes); + + if (context.mounted) { + SomaToast.show(context, message: 'CSV guardado correctamente', type: ToastType.success); + } + } + + static List _buildCsvBytes(List pagos) { + final buf = StringBuffer(); + buf.writeln('dni,nombre_apellido,anio_mes_pagado,monto_total,metodo,fecha_pago'); + for (final p in pagos) { + final c = p.cliente; + final dni = _csvCell(c?.dni ?? ''); + final nombre = _csvCell('${c?.nombre ?? ''} ${c?.apellido ?? ''}'.trim()); + // anio_mes: usar solo YYYY-MM para reimportar + final mes = p.anioMesPagado.length >= 7 ? p.anioMesPagado.substring(0, 7) : p.anioMesPagado; + final monto = p.montoTotal.toStringAsFixed(2); + final metodo = _csvCell(p.metodo); + final fecha = p.fechaPago != null + ? '${p.fechaPago!.year}-' + '${p.fechaPago!.month.toString().padLeft(2, '0')}-' + '${p.fechaPago!.day.toString().padLeft(2, '0')}' + : ''; + buf.writeln('$dni,$nombre,$mes,$monto,$metodo,$fecha'); + } + // BOM para compatibilidad con Excel (UTF-8) + return [0xEF, 0xBB, 0xBF, ...utf8.encode(buf.toString())]; + } + + // Envuelve la celda en comillas si contiene coma, comilla o salto de línea. + static String _csvCell(String value) { + if (value.contains(',') || value.contains('"') || value.contains('\n')) { + return '"${value.replaceAll('"', '""')}"'; + } + return value; + } + + // ── PDF ─────────────────────────────────────────────────────────────────── + + static Future exportToPdf( + BuildContext context, + List pagos, { + String? filtroMes, + }) async { + final doc = _buildPdfDocument(pagos, filtroMes: filtroMes); + + await Printing.layoutPdf( + onLayout: (_) => doc.save(), + name: filtroMes != null ? 'Pagos $filtroMes' : 'Pagos', + ); + } + + static pw.Document _buildPdfDocument(List pagos, {String? filtroMes}) { + final doc = pw.Document(); + + final totalMonto = pagos.fold(0, (sum, p) => sum + p.montoTotal); + + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 36), + header: (_) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'SOMA – Listado de Pagos', + style: pw.TextStyle( + fontSize: 16, + fontWeight: pw.FontWeight.bold, + ), + ), + if (filtroMes != null) + pw.Text( + filtroMes, + style: const pw.TextStyle(fontSize: 11), + ), + ], + ), + pw.SizedBox(height: 4), + pw.Divider(thickness: 0.5), + ], + ), + footer: (ctx) => pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Total: \$${totalMonto.toStringAsFixed(2)} · ${pagos.length} pago${pagos.length == 1 ? '' : 's'}', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + ), + pw.Text( + 'Pág. ${ctx.pageNumber} / ${ctx.pagesCount}', + style: const pw.TextStyle(fontSize: 9), + ), + ], + ), + build: (ctx) => [ + pw.TableHelper.fromTextArray( + headers: ['DNI', 'Socio', 'Mes pagado', 'Método', 'Monto'], + headerStyle: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + ), + cellStyle: const pw.TextStyle(fontSize: 9), + headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200), + cellAlignments: { + 0: pw.Alignment.centerLeft, + 1: pw.Alignment.centerLeft, + 2: pw.Alignment.centerLeft, + 3: pw.Alignment.centerLeft, + 4: pw.Alignment.centerRight, + }, + columnWidths: { + 0: const pw.FixedColumnWidth(72), + 1: const pw.FlexColumnWidth(2.5), + 2: const pw.FlexColumnWidth(1.8), + 3: const pw.FlexColumnWidth(1.8), + 4: const pw.FixedColumnWidth(68), + }, + data: pagos.map((p) { + final c = p.cliente; + return [ + c?.dni ?? '', + c != null ? '${c.apellido}, ${c.nombre}'.trim() : '', + p.mesPagadoDisplay, + p.metodo, + '\$${p.montoTotal.toStringAsFixed(2)}', + ]; + }).toList(), + ), + ], + ), + ); + + return doc; + } +} diff --git a/flutter_soma_app/lib/features/pagos/utils/pagos_import.dart b/flutter_soma_app/lib/features/pagos/utils/pagos_import.dart new file mode 100644 index 0000000..985b321 --- /dev/null +++ b/flutter_soma_app/lib/features/pagos/utils/pagos_import.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +/// Fila parseada de un CSV de pagos. +class PagosImportRow { + final int rowNumber; + final String dni; + final String anioMesPagado; // formato YYYY-MM-01 + final double montoTotal; + final String metodoNombre; + final String? fechaPago; // YYYY-MM-DD, opcional + final String? validationError; + + const PagosImportRow({ + required this.rowNumber, + required this.dni, + required this.anioMesPagado, + required this.montoTotal, + required this.metodoNombre, + this.fechaPago, + this.validationError, + }); + + bool get isValid => validationError == null; +} + +/// Resultado del parseo de un CSV exportado por la app. +class PagosImportResult { + final List rows; // incluye válidas e inválidas + final List parseErrors; // errores que impidieron leer el archivo + + const PagosImportResult({required this.rows, this.parseErrors = const []}); + + List get valid => rows.where((r) => r.isValid).toList(); + List get invalid => rows.where((r) => !r.isValid).toList(); +} + +/// Parsea el contenido de un CSV exportado con [PagosExport.exportToCsv]. +/// Columnas esperadas: dni, nombre_apellido, anio_mes_pagado, monto_total, metodo, fecha_pago +PagosImportResult parsePagosCsv(List bytes) { + // Quitar BOM UTF-8 si está presente + final content = bytes.length >= 3 && + bytes[0] == 0xEF && + bytes[1] == 0xBB && + bytes[2] == 0xBF + ? utf8.decode(bytes.sublist(3)) + : utf8.decode(bytes); + + final lines = content + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .split('\n') + .where((l) => l.trim().isNotEmpty) + .toList(); + + if (lines.isEmpty) { + return const PagosImportResult( + rows: [], + parseErrors: ['El archivo está vacío'], + ); + } + + // Verificar encabezado + final headerCells = _splitCsvLine(lines[0]); + const expectedHeaders = [ + 'dni', + 'nombre_apellido', + 'anio_mes_pagado', + 'monto_total', + 'metodo', + 'fecha_pago', + ]; + final missingHeaders = expectedHeaders + .where((h) => !headerCells.map((c) => c.toLowerCase()).contains(h)) + .toList(); + if (missingHeaders.isNotEmpty) { + return PagosImportResult( + rows: const [], + parseErrors: [ + 'Formato de archivo incorrecto. Columnas faltantes: ${missingHeaders.join(', ')}', + ], + ); + } + + final headerIndex = { + for (var i = 0; i < headerCells.length; i++) headerCells[i].toLowerCase(): i + }; + + final rows = []; + for (var i = 1; i < lines.length; i++) { + final cells = _splitCsvLine(lines[i]); + if (cells.length < 4) continue; + + int col(String name) => headerIndex[name] ?? -1; + String get(String name) { + final idx = col(name); + return (idx >= 0 && idx < cells.length) ? cells[idx].trim() : ''; + } + + final rowNum = i; + final dni = get('dni'); + final mesRaw = get('anio_mes_pagado'); // YYYY-MM o YYYY-MM-DD + final montoStr = get('monto_total'); + final metodo = get('metodo'); + final fechaRaw = get('fecha_pago'); + + // Validaciones + String? error; + if (dni.isEmpty) { + error = 'DNI vacío'; + } else if (mesRaw.isEmpty || !RegExp(r'^\d{4}-\d{2}').hasMatch(mesRaw)) { + error = 'Mes inválido: "$mesRaw"'; + } else if (double.tryParse(montoStr) == null || + (double.tryParse(montoStr) ?? 0) <= 0) { + error = 'Monto inválido: "$montoStr"'; + } else if (metodo.isEmpty) { + error = 'Método vacío'; + } + + // Normalizar anio_mes_pagado a YYYY-MM-01 + final anioMes = mesRaw.length >= 7 + ? '${mesRaw.substring(0, 7)}-01' + : mesRaw; + + // Normalizar fecha_pago (aceptar YYYY-MM-DD, dejar null si vacío/inválido) + String? fechaFinal; + if (fechaRaw.isNotEmpty && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(fechaRaw)) { + fechaFinal = fechaRaw; + } + + rows.add(PagosImportRow( + rowNumber: rowNum, + dni: dni, + anioMesPagado: anioMes, + montoTotal: double.tryParse(montoStr) ?? 0, + metodoNombre: metodo, + fechaPago: fechaFinal, + validationError: error, + )); + } + + return PagosImportResult(rows: rows); +} + +/// Divide una línea CSV respetando celdas entre comillas. +List _splitCsvLine(String line) { + final result = []; + final buf = StringBuffer(); + var inQuotes = false; + + for (var i = 0; i < line.length; i++) { + final ch = line[i]; + if (ch == '"') { + if (inQuotes && i + 1 < line.length && line[i + 1] == '"') { + buf.write('"'); + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch == ',' && !inQuotes) { + result.add(buf.toString()); + buf.clear(); + } else { + buf.write(ch); + } + } + result.add(buf.toString()); + return result; +} diff --git a/flutter_soma_app/lib/features/perfil/presentation/screens/cambiar_contrasena_screen.dart b/flutter_soma_app/lib/features/perfil/presentation/screens/cambiar_contrasena_screen.dart new file mode 100644 index 0000000..7febf94 --- /dev/null +++ b/flutter_soma_app/lib/features/perfil/presentation/screens/cambiar_contrasena_screen.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +/// Autoservicio: el propio usuario logueado cambia su contraseña, +/// confirmando primero la actual. Accesible desde Perfil para admin/superadmin. +class CambiarContrasenaScreen extends ConsumerStatefulWidget { + const CambiarContrasenaScreen({super.key}); + + @override + ConsumerState createState() => + _CambiarContrasenaScreenState(); +} + +class _CambiarContrasenaScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + final _actualCtrl = TextEditingController(); + final _nuevaCtrl = TextEditingController(); + final _repetirCtrl = TextEditingController(); + + bool _obscureActual = true; + bool _obscureNueva = true; + bool _obscureRepetir = true; + bool _isLoading = false; + + @override + void dispose() { + _actualCtrl.dispose(); + _nuevaCtrl.dispose(); + _repetirCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + try { + await ref.read(authRepositoryProvider).cambiarPropiaContrasena( + passwordActual: _actualCtrl.text, + passwordNueva: _nuevaCtrl.text, + ); + if (!mounted) return; + SomaToast.show(context, + message: 'Contraseña actualizada', type: ToastType.success); + context.pop(); + } catch (e) { + if (!mounted) return; + SomaToast.show( + context, + message: e.toString().replaceFirst('Exception: ', ''), + type: ToastType.error, + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Widget _passwordField({ + required TextEditingController controller, + required String label, + required bool obscure, + required VoidCallback onToggle, + String? Function(String?)? validator, + }) { + return SomaTextField( + controller: controller, + labelText: label, + prefixIcon: Icons.lock_outline, + obscureText: obscure, + suffixIcon: IconButton( + icon: Icon( + obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: Theme.of(context).colorScheme.onSurface.withAlpha(130), + size: 20, + ), + onPressed: onToggle, + ), + validator: validator, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Cambiar contraseña')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _passwordField( + controller: _actualCtrl, + label: 'Contraseña actual', + obscure: _obscureActual, + onToggle: () => + setState(() => _obscureActual = !_obscureActual), + validator: (v) => (v == null || v.isEmpty) + ? 'Ingresá tu contraseña actual' + : null, + ), + const SizedBox(height: 16), + _passwordField( + controller: _nuevaCtrl, + label: 'Contraseña nueva', + obscure: _obscureNueva, + onToggle: () => + setState(() => _obscureNueva = !_obscureNueva), + validator: (v) { + if (v == null || v.isEmpty) { + return 'Ingresá la contraseña nueva'; + } + if (v.length < 8) return 'Mínimo 8 caracteres'; + return null; + }, + ), + const SizedBox(height: 16), + _passwordField( + controller: _repetirCtrl, + label: 'Repetir contraseña nueva', + obscure: _obscureRepetir, + onToggle: () => + setState(() => _obscureRepetir = !_obscureRepetir), + validator: (v) { + if (v != _nuevaCtrl.text) { + return 'Las contraseñas no coinciden'; + } + return null; + }, + ), + const SizedBox(height: 28), + SomaPrimaryButton( + text: 'Guardar', + onPressed: _submit, + isLoading: _isLoading, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/perfil/presentation/screens/perfil_screen.dart b/flutter_soma_app/lib/features/perfil/presentation/screens/perfil_screen.dart new file mode 100644 index 0000000..a39a937 --- /dev/null +++ b/flutter_soma_app/lib/features/perfil/presentation/screens/perfil_screen.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; + +class PerfilScreen extends ConsumerWidget { + const PerfilScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authStateProvider).valueOrNull; + final isWide = MediaQuery.of(context).size.width >= 800; + final theme = Theme.of(context); + + if (user == null) return const SizedBox.shrink(); + + String initials() { + if (user.nombre.isNotEmpty && user.apellido.isNotEmpty) { + return '${user.nombre[0]}${user.apellido[0]}'.toUpperCase(); + } + if (user.nombre.isNotEmpty) return user.nombre[0].toUpperCase(); + if (user.dni.length >= 2) return user.dni.substring(0, 2); + return '?'; + } + + String rolDisplay() { + switch (user.role) { + case 'superadmin': + return 'Super Admin'; + case 'admin': + return 'Administrador'; + case 'profesor': + return 'Profesor'; + case 'cliente': + return 'Cliente'; + default: + return user.role; + } + } + + return Scaffold( + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 32, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + children: [ + // Header + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Mi Perfil', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + ), + const SizedBox(height: 24), + + // Avatar con ring amarillo + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: SomaColors.primary, + width: 2.5, + ), + ), + child: CircleAvatar( + radius: 40, + backgroundColor: SomaColors.primary.withAlpha(35), + child: Text( + initials(), + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + const SizedBox(height: 14), + Text( + user.displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(18), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: SomaColors.primary.withAlpha(60), + width: 0.5, + ), + ), + child: Text( + rolDisplay(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primaryText, + ), + ), + ), + const SizedBox(height: 28), + + // Info cards + _InfoTile( + icon: Icons.badge_outlined, + label: 'DNI', + value: user.dni, + ), + if (user.mail != null && user.mail!.isNotEmpty) + _InfoTile( + icon: Icons.email_outlined, + label: 'Email', + value: user.mail!, + ), + if (user.telefono != null && user.telefono!.isNotEmpty) + _InfoTile( + icon: Icons.phone_outlined, + label: 'Teléfono', + value: user.telefono!, + ), + + const SizedBox(height: 24), + + // Cambiar contraseña (staff) + if (user.isStaff) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: ListTile( + leading: Icon( + Icons.lock_outline, + size: 20, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + title: const Text( + 'Cambiar contraseña', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + trailing: Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + contentPadding: EdgeInsets.zero, + onTap: () => context.go('/perfil/cambiar-contrasena'), + ), + ), + ), + + // Logs (admin) + if (user.isStaff) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: ListTile( + leading: Icon( + Icons.terminal, + size: 20, + color: + theme.colorScheme.onSurface.withAlpha(153), + ), + title: const Text( + 'Ver logs', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + trailing: Icon( + Icons.chevron_right, + size: 20, + color: + theme.colorScheme.onSurface.withAlpha(100), + ), + contentPadding: EdgeInsets.zero, + onTap: () => context.go('/logs'), + ), + ), + ), + + const SizedBox(height: 16), + + // Logout + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => + ref.read(authStateProvider.notifier).logout(), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Cerrar sesión'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + foregroundColor: SomaColors.error, + side: const BorderSide( + color: SomaColors.error, width: 0.8), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _InfoTile({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(16), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 18, + color: SomaColors.primary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart b/flutter_soma_app/lib/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart new file mode 100644 index 0000000..d85db6d --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart @@ -0,0 +1,83 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart'; + +class TiposCuotaRepositoryImpl implements TiposCuotaRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + @override + Future> getTiposCuota() async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetTiposCuota, + params: {'p_token': token}, + ); + + if (response is List) { + return response + .map((e) => TipoCuota.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future insertTipoCuota(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcInsertTipoCuota, + params: {'p_token': token, 'p_datos': datos}, + ); + } + + @override + Future updateTipoCuota(String id, Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcUpdateTipoCuota, + params: {'p_token': token, 'p_id': id, 'p_datos': datos}, + ); + } + + @override + Future deleteTipoCuota(String id) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcDeleteTipoCuota, + params: {'p_token': token, 'p_id': id}, + ); + return response == true; + } + + @override + Future>> getActividadesTipoCuota( + String tipoCuotaId) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetActividadesTipoCuota, + params: { + 'p_token': token, + 'p_tipo_cuota_id': tipoCuotaId, + }, + ); + + if (response is List) { + return response + .map((e) => Map.from(e as Map)) + .toList(); + } + return []; + } +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/domain/entities/tipo_cuota.dart b/flutter_soma_app/lib/features/tipos_cuota/domain/entities/tipo_cuota.dart new file mode 100644 index 0000000..dc1c6e7 --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/domain/entities/tipo_cuota.dart @@ -0,0 +1,57 @@ +class TipoCuota { + final String id; + final String nombre; + final String? descripcion; + final int diasSemana; + final double precio; + final bool paraSocios; + final int diaDePago; + final double? recargo; + final List actividadesIds; + + const TipoCuota({ + required this.id, + required this.nombre, + this.descripcion, + required this.diasSemana, + required this.precio, + required this.paraSocios, + this.diaDePago = 10, + this.recargo, + this.actividadesIds = const [], + }); + + factory TipoCuota.fromMap(Map map) { + return TipoCuota( + id: map['id'] as String? ?? '', + nombre: map['nombre'] as String? ?? '', + descripcion: map['descripcion'] as String?, + diasSemana: (map['dias_semana'] as num?)?.toInt() ?? 0, + precio: (map['precio'] as num?)?.toDouble() ?? 0, + paraSocios: map['parasocios'] as bool? ?? false, + diaDePago: (map['dia_de_pago'] as num?)?.toInt() ?? 10, + recargo: (map['recargo'] as num?)?.toDouble(), + actividadesIds: (map['actividades_ids'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() ?? + [], + ); + } + + Map toMap() { + return { + 'nombre': nombre, + 'descripcion': descripcion, + 'dias_semana': diasSemana, + 'precio': precio, + 'para_socios': paraSocios, + 'dia_de_pago': diaDePago, + if (recargo != null) 'recargo': recargo, + if (actividadesIds.isNotEmpty) 'actividades_ids': actividadesIds, + }; + } + + String get precioDisplay => '\$${precio.toStringAsFixed(precio.truncateToDouble() == precio ? 0 : 2)}'; + + String get diasDisplay => '$diasSemana día${diasSemana != 1 ? 's' : ''}/sem'; +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart b/flutter_soma_app/lib/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart new file mode 100644 index 0000000..883f5af --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart @@ -0,0 +1,11 @@ +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; + +abstract class TiposCuotaRepository { + Future> getTiposCuota(); + Future insertTipoCuota(Map datos); + Future updateTipoCuota(String id, Map datos); + Future deleteTipoCuota(String id); + + /// Obtener actividades asociadas a un tipo de cuota. + Future>> getActividadesTipoCuota(String tipoCuotaId); +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart b/flutter_soma_app/lib/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart new file mode 100644 index 0000000..6990bbc --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart @@ -0,0 +1,71 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart'; + +final tiposCuotaRepositoryProvider = Provider((ref) { + return TiposCuotaRepositoryImpl(); +}); + +/// Actividades asociadas a un tipo de cuota (por id). +final actividadesTipoCuotaProvider = + FutureProvider.family>, String>((ref, id) async { + final repo = ref.read(tiposCuotaRepositoryProvider); + return repo.getActividadesTipoCuota(id); +}); + +final tiposCuotaProvider = + StateNotifierProvider>>( + (ref) { + return TiposCuotaNotifier(ref.read(tiposCuotaRepositoryProvider)); +}); + +class TiposCuotaNotifier extends StateNotifier>> { + final TiposCuotaRepository _repository; + + TiposCuotaNotifier(this._repository) + : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + try { + final data = await _repository.getTiposCuota(); + state = AsyncValue.data(data); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future insertTipoCuota(Map datos) async { + try { + await _repository.insertTipoCuota(datos); + await load(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future updateTipoCuota( + String id, Map datos) async { + try { + await _repository.updateTipoCuota(id, datos); + await load(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future deleteTipoCuota(String id) async { + try { + await _repository.deleteTipoCuota(id); + await load(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/presentation/screens/tipos_cuota_screen.dart b/flutter_soma_app/lib/features/tipos_cuota/presentation/screens/tipos_cuota_screen.dart new file mode 100644 index 0000000..27bb4cd --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/presentation/screens/tipos_cuota_screen.dart @@ -0,0 +1,616 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart'; + +class TiposCuotaScreen extends ConsumerStatefulWidget { + const TiposCuotaScreen({super.key}); + + @override + ConsumerState createState() => _TiposCuotaScreenState(); +} + +class _TiposCuotaScreenState extends ConsumerState { + Future _showCreateDialog() async { + final result = await showDialog>( + context: context, + builder: (_) => const TipoCuotaFormDialog(), + ); + if (result == null || !mounted) return; + + final error = + await ref.read(tiposCuotaProvider.notifier).insertTipoCuota(result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Plan creado', type: ToastType.success); + } + } + + Future _showEditDialog(TipoCuota tc) async { + final result = await showDialog>( + context: context, + builder: (_) => TipoCuotaFormDialog(tipoCuota: tc), + ); + if (result == null || !mounted) return; + + final error = await ref + .read(tiposCuotaProvider.notifier) + .updateTipoCuota(tc.id, result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Plan actualizado', type: ToastType.success); + } + } + + Future _deleteTipoCuota(TipoCuota tc) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Eliminar plan'), + content: Text( + '¿Estás seguro de que querés eliminar "${tc.nombre}"?\n' + 'Los usuarios con este plan quedarán sin cuota asignada.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Eliminar'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + + final error = + await ref.read(tiposCuotaProvider.notifier).deleteTipoCuota(tc.id); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show(context, + message: 'Plan eliminado', type: ToastType.success); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(tiposCuotaProvider); + final isWide = MediaQuery.of(context).size.width >= 800; + + return Scaffold( + body: Column( + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 12, + ), + child: Row( + children: [ + const Text( + 'Planes', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SomaHeaderHelp( + items: [ + SomaHelpItem( + icon: Icons.add, + text: 'Creá un plan con precio, días de uso y día de ' + 'pago. Los usuarios se asignan a un plan desde ' + 'Usuarios.', + ), + ], + ), + const Spacer(), + _AddButton(isWide: isWide, onTap: _showCreateDialog), + ], + ), + ), + + // Lista + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(100)), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => + ref.read(tiposCuotaProvider.notifier).load(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (planes) { + if (planes.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.card_membership_outlined, + size: 56, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(60)), + const SizedBox(height: 12), + Text( + 'No hay planes de cuota', + style: TextStyle( + fontSize: 15, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + color: SomaColors.primary, + onRefresh: () => + ref.read(tiposCuotaProvider.notifier).load(), + child: ListView.separated( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, 4, isWide ? 32 : 16, 80, + ), + itemCount: planes.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final tc = planes[index]; + return _TipoCuotaCard( + tipoCuota: tc, + onEdit: () => _showEditDialog(tc), + onDelete: () => _deleteTipoCuota(tc), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _TipoCuotaCard extends ConsumerStatefulWidget { + final TipoCuota tipoCuota; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _TipoCuotaCard({ + required this.tipoCuota, + required this.onEdit, + required this.onDelete, + }); + + @override + ConsumerState<_TipoCuotaCard> createState() => _TipoCuotaCardState(); +} + +class _TipoCuotaCardState extends ConsumerState<_TipoCuotaCard> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final tc = widget.tipoCuota; + final theme = Theme.of(context); + + return InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(12), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail de plan — amarillo + Container(width: 4, color: SomaColors.primary), + + // Contenido + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Column( + children: [ + Row( + children: [ + // Precio destacado en stamp + Container( + constraints: const BoxConstraints( + minWidth: 56, + minHeight: 48, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(18), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: SomaColors.primary.withAlpha(40), + width: 0.5, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tc.precioDisplay, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + height: 1, + ), + ), + Text( + '/mes', + style: TextStyle( + fontSize: 9, + color: theme.colorScheme.onSurface + .withAlpha(100), + ), + ), + ], + ), + ), + const SizedBox(width: 14), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + tc.nombre, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (tc.paraSocios) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: SomaColors.primary + .withAlpha(18), + borderRadius: + BorderRadius.circular(5), + border: Border.all( + color: SomaColors.primary + .withAlpha(60), + width: 0.5, + ), + ), + child: const Text( + 'Socios', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: SomaColors.primaryText, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.calendar_view_week, + size: 12, + color: theme.colorScheme.onSurface + .withAlpha(100)), + const SizedBox(width: 3), + Text( + tc.diasDisplay, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event, + size: 12, + color: theme.colorScheme.onSurface + .withAlpha(100)), + const SizedBox(width: 3), + Text( + 'Vence el ${tc.diaDePago}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ], + ), + if (tc.recargo != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_circle_outline, + size: 12, + color: SomaColors.error + .withAlpha(160)), + const SizedBox(width: 3), + Text( + 'Recargo \$${tc.recargo!.toStringAsFixed(0)}', + style: const TextStyle( + fontSize: 12, + color: SomaColors.error, + ), + ), + ], + ), + ], + ), + ], + ), + ), + + Icon( + _expanded + ? Icons.expand_less + : Icons.expand_more, + size: 20, + color: theme.colorScheme.onSurface.withAlpha(120), + ), + + // Actions + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 18, + color: + theme.colorScheme.onSurface.withAlpha(100), + ), + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Text('Editar'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, + size: 18, color: SomaColors.error), + SizedBox(width: 8), + Text('Eliminar', + style: + TextStyle(color: SomaColors.error)), + ], + ), + ), + ], + onSelected: (v) { + if (v == 'edit') widget.onEdit(); + if (v == 'delete') widget.onDelete(); + }, + ), + ], + ), + + // Actividades expandibles + if (_expanded) _buildActividades(theme), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildActividades(ThemeData theme) { + final actividadesAsync = + ref.watch(actividadesTipoCuotaProvider(widget.tipoCuota.id)); + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + height: 1, + color: theme.colorScheme.surfaceContainerHighest, + ), + const SizedBox(height: 10), + Row( + children: [ + Icon(Icons.sports_gymnastics, + size: 14, + color: theme.colorScheme.onSurface.withAlpha(100)), + const SizedBox(width: 6), + Text( + 'Actividades incluidas', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + const SizedBox(height: 8), + actividadesAsync.when( + loading: () => const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (_, _) => Text( + 'Error cargando actividades', + style: TextStyle(fontSize: 12, color: SomaColors.error), + ), + data: (actividades) { + if (actividades.isEmpty) { + return Text( + 'Sin actividades asignadas', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ); + } + return Wrap( + spacing: 6, + runSpacing: 4, + children: actividades.map((a) { + final nombre = a['nombre'] ?? ''; + final activo = a['activo'] == true; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: activo + ? SomaColors.primary.withAlpha(15) + : theme.colorScheme.onSurface.withAlpha(8), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: activo + ? SomaColors.primary.withAlpha(50) + : theme.colorScheme.onSurface.withAlpha(30), + width: 0.5, + ), + ), + child: Text( + nombre.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: activo + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(80), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ); + } +} + +class _AddButton extends StatelessWidget { + final bool isWide; + final VoidCallback onTap; + + const _AddButton({required this.isWide, required this.onTap}); + + @override + Widget build(BuildContext context) { + if (isWide) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.add, size: 20), + label: const Text('Nuevo'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + ); + } + + return SizedBox( + height: 42, + width: 42, + child: IconButton.filled( + onPressed: onTap, + icon: const Icon(Icons.add, size: 22), + style: IconButton.styleFrom( + backgroundColor: SomaColors.primary, + foregroundColor: SomaColors.onPrimary, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart b/flutter_soma_app/lib/features/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart new file mode 100644 index 0000000..e94bbef --- /dev/null +++ b/flutter_soma_app/lib/features/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; + +class TipoCuotaFormDialog extends StatefulWidget { + final TipoCuota? tipoCuota; + + const TipoCuotaFormDialog({super.key, this.tipoCuota}); + + @override + State createState() => _TipoCuotaFormDialogState(); +} + +class _TipoCuotaFormDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nombreCtrl; + late final TextEditingController _descripcionCtrl; + late final TextEditingController _diasCtrl; + late final TextEditingController _precioCtrl; + late final TextEditingController _diaPagoCtrl; + late final TextEditingController _recargoCtrl; + late bool _paraSocios; + + bool get _isEditing => widget.tipoCuota != null; + + @override + void initState() { + super.initState(); + final tc = widget.tipoCuota; + _nombreCtrl = TextEditingController(text: tc?.nombre ?? ''); + _descripcionCtrl = TextEditingController(text: tc?.descripcion ?? ''); + _diasCtrl = TextEditingController( + text: tc != null ? tc.diasSemana.toString() : ''); + _precioCtrl = TextEditingController( + text: tc != null ? tc.precio.toStringAsFixed(2) : ''); + _diaPagoCtrl = TextEditingController( + text: tc != null ? tc.diaDePago.toString() : '10'); + _recargoCtrl = TextEditingController( + text: tc?.recargo != null ? tc!.recargo!.toStringAsFixed(2) : ''); + _paraSocios = tc?.paraSocios ?? false; + } + + @override + void dispose() { + _nombreCtrl.dispose(); + _descripcionCtrl.dispose(); + _diasCtrl.dispose(); + _precioCtrl.dispose(); + _diaPagoCtrl.dispose(); + _recargoCtrl.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + + final data = { + 'nombre': _nombreCtrl.text.trim(), + 'dias_semana': int.tryParse(_diasCtrl.text.trim()) ?? 0, + 'precio': double.tryParse(_precioCtrl.text.trim()) ?? 0, + 'para_socios': _paraSocios, + 'dia_de_pago': int.tryParse(_diaPagoCtrl.text.trim()) ?? 10, + }; + + final desc = _descripcionCtrl.text.trim(); + if (desc.isNotEmpty) data['descripcion'] = desc; + + final recargo = double.tryParse(_recargoCtrl.text.trim()); + if (recargo != null) data['recargo'] = recargo; + + Navigator.of(context).pop(data); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 480) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Text( + _isEditing ? 'Editar Plan' : 'Nuevo Plan', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Form + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SomaTextField( + controller: _nombreCtrl, + labelText: 'Nombre *', + prefixIcon: Icons.label_outline, + validator: (v) => v == null || v.trim().isEmpty + ? 'Nombre requerido' + : null, + ), + const SizedBox(height: 16), + SomaTextField( + controller: _descripcionCtrl, + labelText: 'Descripción', + prefixIcon: Icons.notes, + maxLines: 2, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SomaTextField( + controller: _diasCtrl, + labelText: 'Días/semana *', + prefixIcon: Icons.calendar_view_week, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(1), + ], + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Requerido'; + } + final n = int.tryParse(v.trim()); + if (n == null || n < 1 || n > 7) { + return '1-7'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SomaTextField( + controller: _precioCtrl, + labelText: 'Precio *', + prefixIcon: Icons.attach_money, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Requerido'; + } + final n = double.tryParse(v.trim()); + if (n == null || n < 0) return 'Inválido'; + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SomaTextField( + controller: _diaPagoCtrl, + labelText: 'Día de pago', + prefixIcon: Icons.event, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: SomaTextField( + controller: _recargoCtrl, + labelText: 'Recargo', + prefixIcon: Icons.trending_up, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + ), + ), + ], + ), + const SizedBox(height: 12), + SwitchListTile( + title: const Text( + 'Para socios', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: const Text( + 'Plan exclusivo para socios', + style: TextStyle(fontSize: 12), + ), + value: _paraSocios, + activeThumbColor: SomaColors.primary, + contentPadding: EdgeInsets.zero, + onChanged: (v) => setState(() => _paraSocios = v), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: Text(_isEditing ? 'Guardar' : 'Crear'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/data/repositories/turnos_repository_impl.dart b/flutter_soma_app/lib/features/turnos/data/repositories/turnos_repository_impl.dart new file mode 100644 index 0000000..d66d4df --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/data/repositories/turnos_repository_impl.dart @@ -0,0 +1,142 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart'; + +class TurnosRepositoryImpl implements TurnosRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + String _formatDate(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + @override + Future obtenerSemana(DateTime weekStart) async { + final token = await _getToken(); + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerTurnos, + params: { + 'p_token': token, + 'p_fecha_inicio': _formatDate(weekStart), + 'p_cantidad_dias': 7, + }, + ); + if (response is Map) { + return SemanaTurnos.fromResponse( + weekStart, + response.cast(), + ); + } + return SemanaTurnos.fromResponse(weekStart, const {}); + } + + @override + Future crearTurnoManual({ + required DateTime fecha, + required int actividadId, + required String horaInicio, + required String horaFin, + required int capacidad, + }) async { + final token = await _getToken(); + // Admin workaround documentado en function_guide: fc_upsert_turno + // está marcado BACKEND y se usa intencionalmente sólo desde aquí + // para que Juani agregue un turno suelto sin tocar el schedule. + await SupabaseConfig.rpc( + AppConstants.rpcUpsertTurno, + params: { + 'p_token': token, + 'p_fecha': _formatDate(fecha), + 'p_actividad_id': actividadId, + 'p_hora_inicio': horaInicio, + 'p_hora_fin': horaFin, + 'p_capacidad_maxima': capacidad, + }, + ); + } + + @override + Future> obtenerInscriptos(String turnoId) async { + final token = await _getToken(); + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerReservasTurno, + params: {'p_token': token, 'p_turno_id': turnoId}, + ); + if (response is List) { + return response + .map((e) => InscriptoTurno.fromMap((e as Map).cast())) + .toList(); + } + return const []; + } + + @override + Future reservarAdmin({ + required String turnoId, + required String clienteId, + }) async { + final token = await _getToken(); + await SupabaseConfig.rpc( + AppConstants.rpcReservarTurnoAdmin, + params: { + 'p_token': token, + 'p_cliente_id': clienteId, + 'p_turno_id': turnoId, + }, + ); + } + + @override + Future cancelarReservaAdmin(String reservaId) async { + final token = await _getToken(); + await SupabaseConfig.rpc( + AppConstants.rpcCancelarReservaAdmin, + params: {'p_token': token, 'p_reserva_id': reservaId}, + ); + } + + @override + Future obtenerEstadoCupo({ + required String clienteId, + required DateTime fecha, + }) async { + final token = await _getToken(); + final response = await SupabaseConfig.rpc( + AppConstants.rpcObtenerEstadoCupo, + params: { + 'p_token': token, + 'p_cliente_id': clienteId, + 'p_fecha': _formatDate(fecha), + }, + ); + if (response is Map) { + return EstadoCupo.fromMap(response.cast()); + } + return const EstadoCupo( + usados: 0, + disponibles: 0, + limiteTotal: 0, + tienePlan: false, + ); + } + + @override + Future> limpiarTurnosAntiguos({ + int diasAntiguedad = 30, + }) async { + final token = await _getToken(); + final response = await SupabaseConfig.rpc( + AppConstants.rpcLimpiarTurnosAntiguos, + params: {'p_token': token, 'p_dias_antiguedad': diasAntiguedad}, + ); + if (response is Map) return response.cast(); + return {'status': 'error', 'mensaje': 'Respuesta inesperada'}; + } +} diff --git a/flutter_soma_app/lib/features/turnos/domain/entities/turno.dart b/flutter_soma_app/lib/features/turnos/domain/entities/turno.dart new file mode 100644 index 0000000..e441253 --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/domain/entities/turno.dart @@ -0,0 +1,195 @@ +enum DiaEstado { cerrado, normal, horarioDiferente } + +DiaEstado _parseEstado(String? raw) { + switch (raw) { + case 'cerrado': + return DiaEstado.cerrado; + case 'horario_diferente': + return DiaEstado.horarioDiferente; + default: + return DiaEstado.normal; + } +} + +class TurnoActividad { + final int id; + final String nombre; + final bool libre; + + const TurnoActividad({ + required this.id, + required this.nombre, + required this.libre, + }); + + factory TurnoActividad.fromMap(Map map) { + return TurnoActividad( + id: (map['id'] as num?)?.toInt() ?? 0, + nombre: map['nombre'] as String? ?? '', + libre: map['libre'] as bool? ?? false, + ); + } +} + +class Turno { + final String id; + final String horaInicio; + final String horaFin; + final int capacidadMaxima; + final int ocupacion; + final TurnoActividad actividad; + + const Turno({ + required this.id, + required this.horaInicio, + required this.horaFin, + required this.capacidadMaxima, + required this.ocupacion, + required this.actividad, + }); + + factory Turno.fromMap(Map map) { + return Turno( + id: map['id'] as String? ?? '', + horaInicio: map['hora_inicio'] as String? ?? '', + horaFin: map['hora_fin'] as String? ?? '', + capacidadMaxima: (map['capacidad_maxima'] as num?)?.toInt() ?? 0, + ocupacion: (map['ocupacion'] as num?)?.toInt() ?? 0, + actividad: TurnoActividad.fromMap( + (map['actividad'] as Map?)?.cast() ?? const {}, + ), + ); + } + + int get disponible { + final libre = capacidadMaxima - ocupacion; + return libre < 0 ? 0 : libre; + } + + bool get estaLleno => capacidadMaxima > 0 && disponible == 0; +} + +class DiaTurnos { + final DateTime fecha; + final int diaSemana; + final DiaEstado estado; + final List turnos; + + const DiaTurnos({ + required this.fecha, + required this.diaSemana, + required this.estado, + required this.turnos, + }); + + factory DiaTurnos.fromMap(DateTime fecha, Map map) { + final lista = (map['turnos'] as List?) + ?.map((e) => Turno.fromMap((e as Map).cast())) + .toList() ?? + const []; + return DiaTurnos( + fecha: fecha, + diaSemana: (map['dia_semana'] as num?)?.toInt() ?? fecha.weekday, + estado: _parseEstado(map['estado'] as String?), + turnos: lista, + ); + } +} + +class SemanaTurnos { + final DateTime weekStart; + final Map _byKey; + + const SemanaTurnos._(this.weekStart, this._byKey); + + factory SemanaTurnos.fromResponse( + DateTime weekStart, + Map raw, + ) { + final map = {}; + raw.forEach((dateKey, value) { + if (value is! Map) return; + final fecha = DateTime.tryParse(dateKey); + if (fecha == null) return; + final f = DateTime(fecha.year, fecha.month, fecha.day); + map[_keyFor(f)] = + DiaTurnos.fromMap(f, value.cast()); + }); + return SemanaTurnos._(weekStart, map); + } + + static String _keyFor(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + DiaTurnos? diaPara(DateTime fecha) => _byKey[_keyFor(fecha)]; + + bool contieneFecha(DateTime fecha) => _byKey.containsKey(_keyFor(fecha)); +} + +class InscriptoTurno { + final String reservaId; + final String clienteId; + final String nombre; + final String? apellido; + final bool cancelada; + + const InscriptoTurno({ + required this.reservaId, + required this.clienteId, + required this.nombre, + this.apellido, + this.cancelada = false, + }); + + factory InscriptoTurno.fromMap(Map map) { + return InscriptoTurno( + reservaId: map['reserva_id'] as String? ?? '', + clienteId: map['cliente_id'] as String? ?? '', + nombre: map['nombre'] as String? ?? '', + apellido: map['apellido'] as String?, + cancelada: map['cancelada'] as bool? ?? false, + ); + } + + String get displayName { + if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) { + return '$nombre $apellido'; + } + return nombre.isNotEmpty ? nombre : '?'; + } + + String get initials { + if (nombre.isNotEmpty) { + if (apellido != null && apellido!.isNotEmpty) { + return '${nombre[0]}${apellido![0]}'.toUpperCase(); + } + return nombre[0].toUpperCase(); + } + return '?'; + } +} + +class EstadoCupo { + final int usados; + final int disponibles; + final int limiteTotal; + final bool tienePlan; + + const EstadoCupo({ + required this.usados, + required this.disponibles, + required this.limiteTotal, + required this.tienePlan, + }); + + factory EstadoCupo.fromMap(Map map) { + return EstadoCupo( + usados: (map['usados'] as num?)?.toInt() ?? 0, + disponibles: (map['disponibles'] as num?)?.toInt() ?? 0, + limiteTotal: (map['limite_total'] as num?)?.toInt() ?? 0, + tienePlan: map['tiene_plan'] as bool? ?? false, + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/domain/repositories/turnos_repository.dart b/flutter_soma_app/lib/features/turnos/domain/repositories/turnos_repository.dart new file mode 100644 index 0000000..ce26e1e --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/domain/repositories/turnos_repository.dart @@ -0,0 +1,40 @@ +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; + +abstract class TurnosRepository { + /// Carga la semana completa empezando en [weekStart] (lunes recomendado). + /// Una sola llamada trae 7 días consecutivos; la JIT del backend + /// materializa los turnos faltantes según el horario vigente. + Future obtenerSemana(DateTime weekStart); + + /// Workaround admin para crear un turno suelto fuera del schedule regular. + /// El backend lo marca como `es_especial=true`. + Future crearTurnoManual({ + required DateTime fecha, + required int actividadId, + required String horaInicio, + required String horaFin, + required int capacidad, + }); + + /// Lista las reservas del turno (devuelve también canceladas). + Future> obtenerInscriptos(String turnoId); + + /// Reserva como admin: bypassa deuda/plan/semana/fecha pasada, + /// sólo respeta capacidad del turno. + Future reservarAdmin({ + required String turnoId, + required String clienteId, + }); + + /// Cancela una reserva como admin: bypassa ownership y antelación. + Future cancelarReservaAdmin(String reservaId); + + /// Cupo semanal del cliente para la semana que contiene [fecha]. + Future obtenerEstadoCupo({ + required String clienteId, + required DateTime fecha, + }); + + /// Mantenimiento: borra turnos vacíos de más de [diasAntiguedad] días. + Future> limpiarTurnosAntiguos({int diasAntiguedad = 30}); +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/providers/turnos_provider.dart b/flutter_soma_app/lib/features/turnos/presentation/providers/turnos_provider.dart new file mode 100644 index 0000000..81fff04 --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/providers/turnos_provider.dart @@ -0,0 +1,126 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/features/turnos/data/repositories/turnos_repository_impl.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart'; + +String _errorMessage(Object e) { + if (e is PostgrestException) return e.message; + return e.toString().replaceFirst('Exception: ', ''); +} + +DateTime _normalizeWeekStart(DateTime d) { + final monday = DateTime(d.year, d.month, d.day - (d.weekday - 1)); + return monday; +} + +final turnosRepositoryProvider = Provider((ref) { + return TurnosRepositoryImpl(); +}); + +final turnosProvider = + StateNotifierProvider>((ref) { + return TurnosNotifier(ref.read(turnosRepositoryProvider)); +}); + +class TurnosNotifier extends StateNotifier> { + final TurnosRepository _repository; + DateTime? _currentWeek; + + TurnosNotifier(this._repository) : super(const AsyncValue.data(null)); + + DateTime? get semanaActual => _currentWeek; + + /// Carga la semana que contiene [referencia] (se normaliza al lunes). + /// Si ya estamos en esa semana, no recarga (a menos que [force] sea true). + Future cargarSemana( + DateTime referencia, { + bool force = false, + }) async { + final weekStart = _normalizeWeekStart(referencia); + if (!force && _currentWeek == weekStart && state.value != null) { + return null; + } + _currentWeek = weekStart; + state = const AsyncValue.loading(); + try { + final semana = await _repository.obtenerSemana(weekStart); + if (_currentWeek != weekStart) return null; // semana cambió mientras cargaba + state = AsyncValue.data(semana); + return null; + } catch (e, st) { + if (_currentWeek != weekStart) return null; + state = AsyncValue.error(e, st); + return _errorMessage(e); + } + } + + /// Refresca la semana actual en segundo plano, sin parpadeo: mantiene los + /// datos visibles mientras recarga y solo los reemplaza cuando llegan. Evita + /// el "corte" de volver a estado loading, que vaciaría la grilla a un spinner + /// (se nota, por ejemplo, al cerrar el diálogo de inscriptos). + Future refrescar() async { + final week = _currentWeek; + if (week == null) return null; + try { + final semana = await _repository.obtenerSemana(week); + if (_currentWeek != week) return null; // semana cambió mientras cargaba + state = AsyncValue.data(semana); + return null; + } catch (e) { + if (_currentWeek != week) return null; + // No pisamos los datos visibles con un error: los dejamos en pantalla y + // devolvemos el mensaje para que la pantalla muestre un toast. + return _errorMessage(e); + } + } + + Future crearTurnoManual({ + required DateTime fecha, + required int actividadId, + required String horaInicio, + required String horaFin, + required int capacidad, + }) async { + try { + await _repository.crearTurnoManual( + fecha: fecha, + actividadId: actividadId, + horaInicio: horaInicio, + horaFin: horaFin, + capacidad: capacidad, + ); + await refrescar(); + return null; + } catch (e) { + return _errorMessage(e); + } + } + + Future limpiarAntiguos({int dias = 30}) async { + try { + final result = + await _repository.limpiarTurnosAntiguos(diasAntiguedad: dias); + final eliminados = result['turnos_eliminados'] ?? 0; + return 'Se eliminaron $eliminados turnos antiguos'; + } catch (e) { + return _errorMessage(e); + } + } +} + +/// Lista de reservas de un turno (incluye canceladas; filtrar en UI). +final inscriptosTurnoProvider = + FutureProvider.autoDispose.family, String>( + (ref, turnoId) => + ref.read(turnosRepositoryProvider).obtenerInscriptos(turnoId), +); + +/// Cupo semanal del cliente para la fecha pedida (usados/disponibles/total). +final estadoCupoProvider = FutureProvider.autoDispose + .family( + (ref, params) => ref.read(turnosRepositoryProvider).obtenerEstadoCupo( + clienteId: params.clienteId, + fecha: params.fecha, + ), +); diff --git a/flutter_soma_app/lib/features/turnos/presentation/screens/turnos_screen.dart b/flutter_soma_app/lib/features/turnos/presentation/screens/turnos_screen.dart new file mode 100644 index 0000000..8a2bf96 --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/screens/turnos_screen.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/crear_turno_dialog.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/semana_turnos_grid.dart'; + +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo', +]; + +const _maxWeeksBack = 4; +const _maxWeeksForward = 8; + +class TurnosScreen extends ConsumerStatefulWidget { + const TurnosScreen({super.key}); + + @override + ConsumerState createState() => _TurnosScreenState(); +} + +class _TurnosScreenState extends ConsumerState { + late DateTime _weekStart; + List _diasVisibles = [0, 1, 2, 3, 4]; + + static DateTime _toMonday(DateTime d) => + DateTime(d.year, d.month, d.day - (d.weekday - 1)); + + DateTime get _minWeek { + final now = DateTime.now(); + return _toMonday(DateTime(now.year, now.month, now.day)) + .subtract(const Duration(days: 7 * _maxWeeksBack)); + } + + DateTime get _maxWeek { + final now = DateTime.now(); + return _toMonday(DateTime(now.year, now.month, now.day)) + .add(const Duration(days: 7 * _maxWeeksForward)); + } + + bool get _canGoPrev => _weekStart.isAfter(_minWeek); + bool get _canGoNext => _weekStart.isBefore(_maxWeek); + + @override + void initState() { + super.initState(); + _weekStart = _toMonday(DateTime.now()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _cargarSemana(); + }); + } + + Future _cargarSemana() async { + final week = _weekStart; + final error = await ref.read(turnosProvider.notifier).cargarSemana(week); + if (!mounted || _weekStart != week) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } + } + + Future _refrescar() async { + final error = await ref.read(turnosProvider.notifier).refrescar(); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } + } + + void _prevWeek() { + if (!_canGoPrev) return; + setState(() => _weekStart = _weekStart.subtract(const Duration(days: 7))); + _cargarSemana(); + } + + void _nextWeek() { + if (!_canGoNext) return; + setState(() => _weekStart = _weekStart.add(const Duration(days: 7))); + _cargarSemana(); + } + + String _weekLabel() { + final end = _weekStart.add(const Duration(days: 6)); + final sameMonth = _weekStart.month == end.month; + if (sameMonth) { + return '${_weekStart.day} – ${end.day} ${_mesesCortos[end.month]} ${end.year}'; + } + return '${_weekStart.day} ${_mesesCortos[_weekStart.month]} – ${end.day} ${_mesesCortos[end.month]} ${end.year}'; + } + + void _openDiasConfig() { + var local = List.from(_diasVisibles); + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: const Text('Días visibles'), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + content: SizedBox( + width: 260, + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(7, (i) { + final checked = local.contains(i); + return CheckboxListTile( + title: Text(_diasSemana[i]), + value: checked, + activeColor: SomaColors.primary, + checkColor: SomaColors.onPrimary, + onChanged: (local.length == 1 && checked) + ? null + : (val) { + setLocal(() { + if (val == true) { + local = ([...local, i])..sort(); + } else { + local = local.where((d) => d != i).toList(); + } + }); + setState(() => _diasVisibles = local); + }, + ); + }), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Listo'), + ), + ], + ), + ), + ); + } + + Future _handleCrearTurno(DateTime fecha) async { + final data = await showDialog>( + context: context, + builder: (_) => const CrearTurnoDialog(), + ); + if (data == null || !mounted) return; + final error = await ref.read(turnosProvider.notifier).crearTurnoManual( + fecha: fecha, + actividadId: data['actividad_id'] as int, + horaInicio: data['hora_inicio'] as String, + horaFin: data['hora_fin'] as String, + capacidad: data['capacidad'] as int, + ); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } + } + + Future _handleAsignar(Turno turno, DateTime fecha) async { + final ok = await showDialog( + context: context, + builder: (_) => AsignarUsuarioTurnoDialog(turno: turno, fecha: fecha), + ); + if (ok == true && mounted) await _refrescar(); + } + + Future _handleVerInscriptos(Turno turno) async { + var huboCambios = false; + await showDialog( + context: context, + builder: (_) => InscriptosTurnoDialog( + turno: turno, + onCambio: () => huboCambios = true, + ), + ); + // Solo refrescamos si se canceló alguna inscripción. Abrir y cerrar el + // diálogo sin tocar nada no dispara el rebuild de la grilla (que trababa + // la animación de cierre). + if (mounted && huboCambios) await _refrescar(); + } + + Future _handleVerDia(DateTime fecha, DiaTurnos? dia) async { + if (dia == null || dia.turnos.isEmpty) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => DiaInscriptosSheet( + dia: dia, + fecha: fecha, + isAdmin: _isAdmin, + ), + ); + if (mounted) await _refrescar(); + } + + bool get _isAdmin { + final user = ref.read(authStateProvider).value; + return user?.isStaff ?? false; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isWide = MediaQuery.of(context).size.width >= 800; + final hPad = isWide ? 32.0 : 16.0; + final state = ref.watch(turnosProvider); + final isAdmin = _isAdmin; + + return Scaffold( + body: Column( + children: [ + // ── Header ────────────────────────────────────────────────────────── + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 0, + ), + child: Row( + children: [ + const Text( + 'Turnos', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + SomaHeaderHelp( + items: [ + const SomaHelpItem( + icon: Icons.chevron_left, + text: 'Las flechas navegan entre semanas.', + ), + const SomaHelpItem( + icon: Icons.tune, + text: 'Días visibles: elegí qué días de la semana se ' + 'muestran en la grilla.', + ), + const SomaHelpItem( + icon: Icons.touch_app_outlined, + text: 'Tocá un turno para ver o asignar inscriptos, ' + 'o un día sin turnos para crear uno nuevo.', + ), + const SomaHelpItem( + icon: Icons.refresh, + text: 'Recarga los turnos de la semana actual.', + ), + ], + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Recargar', + onPressed: state.isLoading ? null : _refrescar, + ), + ], + ), + ), + + // ── Navegación semanal ──────────────────────────────────────────── + Padding( + padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _canGoPrev ? _prevWeek : null, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Text( + _weekLabel(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _canGoNext ? _nextWeek : null, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.tune, size: 20), + tooltip: 'Días visibles', + visualDensity: VisualDensity.compact, + onPressed: _openDiasConfig, + ), + ], + ), + ), + + // ── Contenido ───────────────────────────────────────────────────── + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(hPad, 0, hPad, hPad), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outline.withAlpha(40), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: state.when( + loading: () => Center( + key: const ValueKey('loading'), + child: CircularProgressIndicator( + color: SomaColors.primary), + ), + error: (e, _) => _ErrorView( + key: const ValueKey('error'), + message: e.toString().replaceFirst('Exception: ', ''), + onRetry: _cargarSemana, + ), + data: (semana) { + if (semana == null) { + return Center( + key: const ValueKey('null'), + child: CircularProgressIndicator( + color: SomaColors.primary), + ); + } + return SemanaTurnosGrid( + key: ValueKey(_weekStart), + semana: semana, + weekStart: _weekStart, + diasVisibles: _diasVisibles, + isAdmin: isAdmin, + onCrearTurno: _handleCrearTurno, + onAsignar: _handleAsignar, + onVerInscriptos: _handleVerInscriptos, + onTapDia: _handleVerDia, + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ── Error view ───────────────────────────────────────────────────────────────── + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + const _ErrorView({super.key, required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: cs.error.withAlpha(178)), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: cs.onSurface.withAlpha(153)), + ), + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart new file mode 100644 index 0000000..7690b8d --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart @@ -0,0 +1,707 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +String _errorMessage(Object e) { + if (e is PostgrestException) return e.message; + return e.toString().replaceFirst('Exception: ', ''); +} + +enum _Violacion { ninguna, sinPlan, actividadNoEnPlan } + +class AsignarUsuarioTurnoDialog extends ConsumerStatefulWidget { + final Turno turno; + final DateTime fecha; + + const AsignarUsuarioTurnoDialog({ + super.key, + required this.turno, + required this.fecha, + }); + + @override + ConsumerState createState() => + _AsignarUsuarioTurnoDialogState(); +} + +class _AsignarUsuarioTurnoDialogState + extends ConsumerState { + final Set _selectedIds = {}; + final Map _selectedMap = {}; + bool _loading = false; + String _searchQuery = ''; + String? _filterPlanId; // null = todos + + _Violacion _getViolacion(Usuario user, List planes) { + if (widget.turno.actividad.libre) return _Violacion.ninguna; + if (user.tipoCuota == null) return _Violacion.sinPlan; + final plan = planes.where((p) => p.id == user.tipoCuota).firstOrNull; + if (plan == null) return _Violacion.sinPlan; + if (!plan.actividadesIds.contains(widget.turno.actividad.id)) { + return _Violacion.actividadNoEnPlan; + } + return _Violacion.ninguna; + } + + void _toggle(Usuario u) { + setState(() { + if (_selectedIds.contains(u.id)) { + _selectedIds.remove(u.id); + _selectedMap.remove(u.id); + } else { + _selectedIds.add(u.id); + _selectedMap[u.id] = u; + } + }); + } + + Future _submit() async { + if (_selectedIds.isEmpty) return; + setState(() => _loading = true); + int ok = 0; + String? lastError; + for (final u in _selectedMap.values) { + try { + await ref.read(turnosRepositoryProvider).reservarAdmin( + turnoId: widget.turno.id, + clienteId: u.id, + ); + ok++; + } catch (e) { + lastError = '${u.displayName}: ${_errorMessage(e)}'; + } + } + if (!mounted) return; + setState(() => _loading = false); + if (lastError != null) { + SomaToast.show(context, message: lastError, type: ToastType.error); + } + if (ok > 0 && mounted) Navigator.of(context).pop(true); + } + + bool get _canSubmit => _selectedIds.isNotEmpty && !_loading; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final width = MediaQuery.of(context).size.width; + final usuariosAsync = ref.watch(usuariosProvider); + final planesAsync = ref.watch(tiposCuotaProvider); + final planes = planesAsync.valueOrNull ?? []; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: width >= 660 ? (width - 580) / 2 : 12, + vertical: 28, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 580, maxHeight: 760), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Header ─────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.primary.withAlpha(25), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.group_add_outlined, + size: 18, color: cs.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Asignar usuarios', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w700)), + Text( + '${widget.turno.actividad.nombre} · ' + '${widget.turno.horaInicio} – ${widget.turno.horaFin}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(140)), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // ── Contenido ──────────────────────────────────────────────────── + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Filtro por plan ────────────────────────────────────── + planesAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (allPlanes) => _PlanFilterChips( + planes: allPlanes, + selectedPlanId: _filterPlanId, + onSelected: (id) => + setState(() => _filterPlanId = id), + ), + ), + const SizedBox(height: 10), + + // ── Lista de usuarios ──────────────────────────────────── + usuariosAsync.when( + loading: () => const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (_, _) => Text( + 'Error cargando usuarios', + style: TextStyle( + color: SomaColors.error, fontSize: 13), + ), + data: (usuarios) { + final clientes = usuarios + .where( + (u) => u.isActive && u.rol == 'cliente') + .toList(); + + // Filtro por plan + final planFiltrados = _filterPlanId == null + ? clientes + : clientes + .where( + (u) => u.tipoCuota == _filterPlanId) + .toList(); + + // Filtro por búsqueda + final filtrados = _searchQuery.isEmpty + ? planFiltrados + : planFiltrados + .where((u) => u.displayName + .toLowerCase() + .contains( + _searchQuery.toLowerCase())) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: InputDecoration( + hintText: 'Buscar usuario...', + prefixIcon: + const Icon(Icons.search, size: 18), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + isDense: true, + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: BorderSide( + color: cs.outline.withAlpha(60)), + ), + ), + onChanged: (v) => + setState(() => _searchQuery = v), + ), + const SizedBox(height: 8), + if (filtrados.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 16), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, + size: 18, + color: + cs.onSurface.withAlpha(80)), + const SizedBox(width: 8), + Text( + _filterPlanId != null + ? 'Sin usuarios con este plan' + : 'Sin resultados', + style: TextStyle( + fontSize: 13, + color: cs.onSurface + .withAlpha(120)), + ), + ], + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 280), + child: ListView.builder( + shrinkWrap: true, + itemCount: filtrados.length, + itemBuilder: (_, i) { + final u = filtrados[i]; + final selected = + _selectedIds.contains(u.id); + final plan = planes + .where((p) => + p.id == u.tipoCuota) + .firstOrNull; + final violacion = + _getViolacion(u, planes); + return _UserListItem( + user: u, + plan: plan, + violacion: violacion, + selected: selected, + onToggle: () => _toggle(u), + ); + }, + ), + ), + ], + ); + }, + ), + + // ── Panel de seleccionados ─────────────────────────────── + if (_selectedMap.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(color: cs.surfaceContainerHighest), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.check_circle_outline, + size: 15, color: SomaColors.success), + const SizedBox(width: 6), + Text( + '${_selectedMap.length} seleccionado${_selectedMap.length == 1 ? '' : 's'}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.onSurface.withAlpha(160), + ), + ), + ], + ), + const SizedBox(height: 8), + ..._selectedMap.values.map((u) { + final violacion = _getViolacion(u, planes); + return _SelectedUserRow( + user: u, + fecha: widget.fecha, + violacion: violacion, + actividad: widget.turno.actividad.nombre, + onRemove: () => _toggle(u), + ); + }), + ], + const SizedBox(height: 4), + ], + ), + ), + ), + + // ── Botones ─────────────────────────────────────────────────── + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Cancelar', + style: TextStyle( + color: cs.onSurface.withAlpha(178))), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _canSubmit ? _submit : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 42)), + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: SomaColors.onPrimary)) + : Text(_selectedMap.length > 1 + ? 'Asignar (${_selectedMap.length})' + : 'Asignar'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Chips de filtro por plan ─────────────────────────────────────────────────── + +class _PlanFilterChips extends StatelessWidget { + final List planes; + final String? selectedPlanId; + final ValueChanged onSelected; + + const _PlanFilterChips({ + required this.planes, + required this.selectedPlanId, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (planes.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'Todos', + selected: selectedPlanId == null, + onTap: () => onSelected(null), + cs: cs, + ), + ...planes.map((p) => _FilterChip( + label: p.nombre, + selected: selectedPlanId == p.id, + onTap: () => + onSelected(selectedPlanId == p.id ? null : p.id), + cs: cs, + )), + ], + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + final ColorScheme cs; + + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected + ? SomaColors.primary.withAlpha(30) + : cs.surfaceContainerHighest.withAlpha(80), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected + ? SomaColors.primary.withAlpha(160) + : cs.outline.withAlpha(40), + width: selected ? 1 : 0.5, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? SomaColors.primaryText + : cs.onSurface.withAlpha(160), + ), + ), + ), + ); + } +} + +// ── Fila de usuario en la lista ─────────────────────────────────────────────── + +class _UserListItem extends StatelessWidget { + final Usuario user; + final TipoCuota? plan; + final _Violacion violacion; + final bool selected; + final VoidCallback onToggle; + + const _UserListItem({ + required this.user, + required this.plan, + required this.violacion, + required this.selected, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hasViolacion = violacion != _Violacion.ninguna; + + return InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 3), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: selected + ? SomaColors.primary.withAlpha(20) + : Colors.transparent, + border: Border.all( + color: selected + ? SomaColors.primary.withAlpha(90) + : Colors.transparent, + width: 0.8, + ), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + value: selected, + onChanged: (_) => onToggle(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + activeColor: SomaColors.primary, + checkColor: SomaColors.onPrimary, + side: BorderSide( + color: cs.outline.withAlpha(140), width: 1.2), + ), + ), + const SizedBox(width: 10), + CircleAvatar( + radius: 13, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text(user.initials, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: cs.onSurface)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayName, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + if (plan != null) + Text( + plan!.nombre, + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(120)), + overflow: TextOverflow.ellipsis, + ) + else + Text( + 'Sin plan', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(80), + fontStyle: FontStyle.italic), + ), + ], + ), + ), + if (hasViolacion) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Tooltip( + message: violacion == _Violacion.sinPlan + ? 'Sin plan asignado' + : 'Plan no incluye esta actividad', + child: Icon(Icons.warning_amber_rounded, + size: 16, color: const Color(0xFFE67700)), + ), + ), + ], + ), + ), + ); + } +} + +// ── Fila de usuario seleccionado (panel inferior) ───────────────────────────── + +class _SelectedUserRow extends ConsumerWidget { + final Usuario user; + final DateTime fecha; + final _Violacion violacion; + final String actividad; + final VoidCallback onRemove; + + const _SelectedUserRow({ + required this.user, + required this.fecha, + required this.violacion, + required this.actividad, + required this.onRemove, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final cupoAsync = + ref.watch(estadoCupoProvider((clienteId: user.id, fecha: fecha))); + final hasViolacion = violacion != _Violacion.ninguna; + final warningColor = const Color(0xFFE67700); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withAlpha(50), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.outline.withAlpha(30), width: 0.5), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text(user.initials, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: cs.onSurface)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayName, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // Cupo inline + cupoAsync.when( + loading: () => const SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + error: (_, _) => const SizedBox.shrink(), + data: (cupo) { + if (!cupo.tienePlan) { + return Row(children: [ + Icon(Icons.info_outline, + size: 12, color: cs.onSurface.withAlpha(100)), + const SizedBox(width: 4), + Text('Sin plan', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(120))), + ]); + } + final lleno = cupo.disponibles == 0; + final cupoColor = + lleno ? SomaColors.error : SomaColors.success; + return Row(children: [ + Icon(Icons.calendar_today_outlined, + size: 12, color: cs.onSurface.withAlpha(120)), + const SizedBox(width: 4), + Text( + '${cupo.usados}/${cupo.limiteTotal} días usados', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(140)), + ), + const SizedBox(width: 8), + Text( + '${cupo.disponibles} disp.', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cupoColor), + ), + ]); + }, + ), + // Violación de plan + if (hasViolacion) ...[ + const SizedBox(height: 3), + Row(children: [ + Icon(Icons.warning_amber_rounded, + size: 12, color: warningColor), + const SizedBox(width: 4), + Expanded( + child: Text( + violacion == _Violacion.sinPlan + ? 'Sin plan · se asignará igual' + : 'Plan no incluye $actividad · se asignará igual', + style: TextStyle( + fontSize: 11, color: warningColor), + overflow: TextOverflow.ellipsis, + ), + ), + ]), + ], + ], + ), + ), + IconButton( + icon: Icon(Icons.close, + size: 16, color: cs.onSurface.withAlpha(140)), + tooltip: 'Quitar de la selección', + onPressed: onRemove, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} + diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/crear_turno_dialog.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/crear_turno_dialog.dart new file mode 100644 index 0000000..e397098 --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/crear_turno_dialog.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart'; + +class CrearTurnoDialog extends ConsumerStatefulWidget { + const CrearTurnoDialog({super.key}); + + @override + ConsumerState createState() => _CrearTurnoDialogState(); +} + +class _CrearTurnoDialogState extends ConsumerState { + int? _actividadId; + TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0); + TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0); + int _capacidad = 10; + final _capacidadCtrl = TextEditingController(text: '10'); + + @override + void dispose() { + _capacidadCtrl.dispose(); + super.dispose(); + } + + Future _pickTime({required bool isStart}) async { + final initial = isStart ? _horaInicio : _horaFin; + final picked = await showTimePicker( + context: context, + initialTime: initial, + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: Theme.of(ctx).colorScheme.copyWith( + primary: SomaColors.primary, + onPrimary: SomaColors.onPrimary, + ), + ), + child: child!, + ), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _horaInicio = picked; + if (_toMin(picked) >= _toMin(_horaFin)) { + _horaFin = TimeOfDay(hour: (picked.hour + 1) % 24, minute: picked.minute); + } + } else { + _horaFin = picked; + } + }); + } + + int _toMin(TimeOfDay t) => t.hour * 60 + t.minute; + + String _fmt(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + bool get _valid => + _actividadId != null && _toMin(_horaInicio) < _toMin(_horaFin) && _capacidad > 0; + + void _submit() { + if (!_valid) return; + Navigator.of(context).pop({ + 'actividad_id': _actividadId, + 'hora_inicio': _fmt(_horaInicio), + 'hora_fin': _fmt(_horaFin), + 'capacidad': _capacidad, + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final width = MediaQuery.of(context).size.width; + final actividadesAsync = ref.watch(actividadesProvider); + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: width >= 600 ? (width - 420) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + const Text('Agregar turno', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Actividad + actividadesAsync.when( + loading: () => const Center( + child: SizedBox( + height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (_, e) => Text('Error cargando actividades', + style: TextStyle(color: SomaColors.error, fontSize: 13)), + data: (actividades) => DropdownButtonFormField( + initialValue: _actividadId, + items: actividades + .where((a) => a.activo) + .map((a) => DropdownMenuItem(value: a.id, child: Text(a.nombre))) + .toList(), + onChanged: (v) => setState(() => _actividadId = v), + decoration: const InputDecoration( + labelText: 'Actividad *', + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + ), + const SizedBox(height: 20), + // Horario + Row( + children: [ + Expanded( + child: _TimeField( + label: 'Desde', + value: _fmt(_horaInicio), + onTap: () => _pickTime(isStart: true), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Icon(Icons.arrow_forward, size: 18, + color: cs.onSurface.withAlpha(100)), + ), + Expanded( + child: _TimeField( + label: 'Hasta', + value: _fmt(_horaFin), + onTap: () => _pickTime(isStart: false), + ), + ), + ], + ), + if (_toMin(_horaInicio) >= _toMin(_horaFin)) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('La hora de fin debe ser mayor', + style: TextStyle(color: SomaColors.error, fontSize: 12)), + ), + const SizedBox(height: 20), + // Capacidad + TextField( + controller: _capacidadCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Capacidad máxima *', + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + onChanged: (v) => setState(() => _capacidad = int.tryParse(v) ?? 0), + ), + const SizedBox(height: 8), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Cancelar', + style: TextStyle(color: cs.onSurface.withAlpha(178))), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _valid ? _submit : null, + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 42)), + child: const Text('Agregar'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _TimeField extends StatelessWidget { + final String label; + final String value; + final VoidCallback onTap; + + const _TimeField({required this.label, required this.value, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5)), + const SizedBox(height: 6), + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.inputDecorationTheme.fillColor, + ), + child: Row( + children: [ + Icon(Icons.schedule, size: 20, color: theme.colorScheme.onSurface.withAlpha(130)), + const SizedBox(width: 10), + Text(value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface)), + ], + ), + ), + ), + ], + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart new file mode 100644 index 0000000..44f36a9 --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; + +const _diasNombres = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo', +]; +const _meses = [ + '', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', + 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre', +]; + +Color _barColor(int ocupacion, int capacidad, ColorScheme cs) { + if (capacidad == 0) return cs.outline; + final ratio = ocupacion / capacidad; + if (ratio < 0.5) return SomaColors.success; + if (ratio < 0.85) return const Color(0xFFFFB300); + return SomaColors.error; +} + +class DiaInscriptosSheet extends ConsumerWidget { + final DiaTurnos dia; + final DateTime fecha; + final bool isAdmin; + + const DiaInscriptosSheet({ + super.key, + required this.dia, + required this.fecha, + required this.isAdmin, + }); + + Future _cancelar( + WidgetRef ref, + BuildContext context, + InscriptoTurno inscripto, + String turnoId, + ) async { + try { + await ref.read(turnosRepositoryProvider).cancelarReservaAdmin(inscripto.reservaId); + ref.invalidate(inscriptosTurnoProvider(turnoId)); + } catch (e) { + if (context.mounted) { + SomaToast.show( + context, + message: e is PostgrestException + ? e.message + : e.toString().replaceFirst('Exception: ', ''), + type: ToastType.error, + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final turnos = [...dia.turnos] + ..sort((a, b) => a.horaInicio.compareTo(b.horaInicio)); + final nombreDia = _diasNombres[fecha.weekday - 1]; + final fechaLabel = '${fecha.day} de ${_meses[fecha.month]}'; + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.65, + minChildSize: 0.3, + maxChildSize: 0.92, + builder: (ctx, scrollController) { + return Column( + children: [ + // Drag handle + Container( + margin: const EdgeInsets.only(top: 12, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurface.withAlpha(60), + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.primary.withAlpha(25), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.calendar_today_outlined, + size: 18, + color: cs.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nombreDia, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + Text( + fechaLabel, + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(140), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Divider( + height: 1, + thickness: 0.5, + color: cs.surfaceContainerHighest, + ), + // Body + Expanded( + child: ListView.separated( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: turnos.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final turno = turnos[i]; + return _TurnoSection( + turno: turno, + isAdmin: isAdmin, + onCancelar: (inscripto) => + _cancelar(ref, context, inscripto, turno.id), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +// ── Sección de un turno con sus inscriptos ──────────────────────────────────── + +class _TurnoSection extends ConsumerWidget { + final Turno turno; + final bool isAdmin; + final void Function(InscriptoTurno) onCancelar; + + const _TurnoSection({ + required this.turno, + required this.isAdmin, + required this.onCancelar, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id)); + final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs); + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outline.withAlpha(30), width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header del turno + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: barColor), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + turno.actividad.nombre, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${turno.horaInicio} – ${turno.horaFin}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(140), + ), + ), + ], + ), + ), + if (turno.capacidadMaxima > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: barColor.withAlpha(25), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: barColor.withAlpha(80), + width: 0.7, + ), + ), + child: Text( + turno.estaLleno + ? 'LLENO' + : '${turno.ocupacion}/${turno.capacidadMaxima}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: barColor, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + Divider( + height: 1, + thickness: 0.5, + color: cs.outline.withAlpha(25), + ), + // Lista de inscriptos + inscriptosAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (_, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Text( + 'Error cargando inscriptos', + style: TextStyle(fontSize: 12, color: cs.error), + ), + ), + data: (inscriptos) { + final activos = inscriptos.where((i) => !i.cancelada).toList(); + if (activos.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.person_off_outlined, + size: 15, + color: cs.onSurface.withAlpha(60), + ), + const SizedBox(width: 8), + Text( + 'Sin inscriptos', + style: TextStyle( + fontSize: 13, + color: cs.onSurface.withAlpha(100), + ), + ), + ], + ), + ); + } + return Column( + children: [ + for (final inscripto in activos) + _InscriptoRow( + inscripto: inscripto, + isAdmin: isAdmin, + onCancelar: () => onCancelar(inscripto), + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +// ── Fila de inscripto ───────────────────────────────────────────────────────── + +class _InscriptoRow extends StatelessWidget { + final InscriptoTurno inscripto; + final bool isAdmin; + final VoidCallback onCancelar; + + const _InscriptoRow({ + required this.inscripto, + required this.isAdmin, + required this.onCancelar, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text( + inscripto.initials, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + inscripto.displayName, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (isAdmin) + IconButton( + icon: Icon( + Icons.person_remove_outlined, + size: 16, + color: cs.error.withAlpha(180), + ), + tooltip: 'Cancelar inscripción', + onPressed: onCancelar, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart new file mode 100644 index 0000000..3049d5d --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart'; + +String _errorMessage(Object e) { + if (e is PostgrestException) return e.message; + return e.toString().replaceFirst('Exception: ', ''); +} + +class InscriptosTurnoDialog extends ConsumerWidget { + final Turno turno; + + /// Se invoca cuando se canceló alguna inscripción. La pantalla lo usa para + /// refrescar los cupos al cerrar SOLO si hubo cambios; si el diálogo se abrió + /// y cerró sin tocar nada, no se refresca y se evita el rebuild de la grilla + /// (que trababa la animación de cierre). + final VoidCallback? onCambio; + + const InscriptosTurnoDialog({super.key, required this.turno, this.onCambio}); + + Future _cancelar( + WidgetRef ref, BuildContext context, InscriptoTurno inscripto) async { + try { + await ref + .read(turnosRepositoryProvider) + .cancelarReservaAdmin(inscripto.reservaId); + ref.invalidate(inscriptosTurnoProvider(turno.id)); + onCambio?.call(); + } catch (e) { + if (context.mounted) { + SomaToast.show(context, + message: _errorMessage(e), type: ToastType.error); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id)); + final width = MediaQuery.of(context).size.width; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: width >= 600 ? (width - 440) / 2 : 20, + vertical: 40, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.primary.withAlpha(25), + borderRadius: BorderRadius.circular(10), + ), + child: + Icon(Icons.group_outlined, size: 18, color: cs.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + turno.actividad.nombre, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + ), + Text( + '${turno.horaInicio} – ${turno.horaFin}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withAlpha(140)), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + Flexible( + child: inscriptosAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 40, color: cs.onSurface.withAlpha(80)), + const SizedBox(height: 12), + Text( + _errorMessage(e), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: cs.onSurface.withAlpha(140)), + ), + ], + ), + ), + data: (inscriptos) { + // Filtrar canceladas para la vista principal de inscritos + final activos = + inscriptos.where((i) => !i.cancelada).toList(); + + if (activos.isEmpty) { + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_off_outlined, + size: 48, color: cs.onSurface.withAlpha(60)), + const SizedBox(height: 12), + Text('Sin inscriptos en este turno', + style: TextStyle( + fontSize: 14, + color: cs.onSurface.withAlpha(130))), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: Row( + children: [ + Text( + '${activos.length} inscripto${activos.length == 1 ? '' : 's'}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.onSurface.withAlpha(150)), + ), + const Spacer(), + if (turno.capacidadMaxima > 0) + Text( + '${turno.disponible} cupo${turno.disponible == 1 ? '' : 's'} libre${turno.disponible == 1 ? '' : 's'}', + style: TextStyle( + fontSize: 12, + color: turno.disponible == 0 + ? SomaColors.error + : SomaColors.success), + ), + ], + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + itemCount: activos.length, + separatorBuilder: (_, i) => + const SizedBox(height: 6), + itemBuilder: (_, i) => _InscriptoRow( + inscripto: activos[i], + onCancelar: () => + _cancelar(ref, context, activos[i]), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _InscriptoRow extends StatelessWidget { + final InscriptoTurno inscripto; + final VoidCallback onCancelar; + + const _InscriptoRow({required this.inscripto, required this.onCancelar}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withAlpha(80), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outline.withAlpha(30), width: 0.5), + ), + child: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: SomaColors.primary.withAlpha(30), + child: Text(inscripto.initials, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.onSurface)), + ), + const SizedBox(width: 10), + Expanded( + child: Text(inscripto.displayName, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis), + ), + IconButton( + icon: Icon(Icons.person_remove_outlined, + size: 18, color: cs.error.withAlpha(180)), + tooltip: 'Cancelar inscripción', + onPressed: onCancelar, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/semana_turnos_grid.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/semana_turnos_grid.dart new file mode 100644 index 0000000..643272d --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/semana_turnos_grid.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; +import 'package:gimnasio_soma/features/turnos/presentation/widgets/turno_slot_tile.dart'; + +const _diasSemana = [ + 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo', +]; +const _mesesCortos = [ + '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic', +]; +const _minColumnWidth = 160.0; + +bool _isToday(DateTime d) { + final now = DateTime.now(); + return d.year == now.year && d.month == now.month && d.day == now.day; +} + +bool _isPast(DateTime d) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + return DateTime(d.year, d.month, d.day).isBefore(today); +} + +class SemanaTurnosGrid extends StatelessWidget { + final SemanaTurnos semana; + final DateTime weekStart; + final List diasVisibles; + final bool isAdmin; + final void Function(DateTime fecha) onCrearTurno; + final void Function(Turno turno, DateTime fecha) onAsignar; + final void Function(Turno turno) onVerInscriptos; + final void Function(DateTime fecha, DiaTurnos? dia)? onTapDia; + + const SemanaTurnosGrid({ + super.key, + required this.semana, + required this.weekStart, + required this.diasVisibles, + required this.isAdmin, + required this.onCrearTurno, + required this.onAsignar, + required this.onVerInscriptos, + this.onTapDia, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final count = diasVisibles.length; + + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth; + final useScroll = available < count * _minColumnWidth; + + final rowChildren = []; + for (int i = 0; i < count; i++) { + final diaIdx = diasVisibles[i]; + final fecha = weekStart.add(Duration(days: diaIdx)); + final dia = semana.diaPara(fecha); + final isPast = _isPast(fecha); + + if (i > 0) { + rowChildren.add(Container( + width: 1, + color: theme.colorScheme.surfaceContainerHighest, + )); + } + + final columna = _DiaTurnosColumna( + fecha: fecha, + nombreDia: _diasSemana[diaIdx], + dia: dia, + isPast: isPast, + isAdmin: isAdmin, + onCrear: () => onCrearTurno(fecha), + onAsignar: (t) => onAsignar(t, fecha), + onVerInscriptos: onVerInscriptos, + onTapDia: onTapDia != null ? () => onTapDia!(fecha, dia) : null, + ); + + rowChildren.add( + useScroll + ? SizedBox(width: _minColumnWidth, child: columna) + : Expanded(child: columna), + ); + } + + final row = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rowChildren, + ); + + if (!useScroll) return row; + + final totalWidth = count * _minColumnWidth + (count - 1).toDouble(); + return Scrollbar( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: totalWidth, + height: constraints.maxHeight, + child: row, + ), + ), + ); + }, + ); + } +} + +// ── Columna de un día ────────────────────────────────────────────────────────── + +class _DiaTurnosColumna extends StatelessWidget { + final DateTime fecha; + final String nombreDia; + final DiaTurnos? dia; + final bool isPast; + final bool isAdmin; + final VoidCallback onCrear; + final void Function(Turno) onAsignar; + final void Function(Turno) onVerInscriptos; + final VoidCallback? onTapDia; + + const _DiaTurnosColumna({ + required this.fecha, + required this.nombreDia, + required this.dia, + required this.isPast, + required this.isAdmin, + required this.onCrear, + required this.onAsignar, + required this.onVerInscriptos, + this.onTapDia, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DiaTurnosHeader( + nombreDia: nombreDia, + fecha: fecha, + dia: dia, + isPast: isPast, + isAdmin: isAdmin, + onCrear: onCrear, + onTap: onTapDia, + ), + Expanded( + child: _DiaTurnosBody( + dia: dia, + isPast: isPast, + isAdmin: isAdmin, + onAsignar: onAsignar, + onVerInscriptos: onVerInscriptos, + ), + ), + ], + ); + } +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +class _DiaTurnosHeader extends StatelessWidget { + final String nombreDia; + final DateTime fecha; + final DiaTurnos? dia; + final bool isPast; + final bool isAdmin; + final VoidCallback onCrear; + final VoidCallback? onTap; + + const _DiaTurnosHeader({ + required this.nombreDia, + required this.fecha, + required this.dia, + required this.isPast, + required this.isAdmin, + required this.onCrear, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final today = _isToday(fecha); + final esCerrado = dia?.estado == DiaEstado.cerrado; + final esEspecial = dia?.estado == DiaEstado.horarioDiferente; + final puedeAgregar = isAdmin && !isPast && !esCerrado; + + final content = Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 4, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nombreDia, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: today + ? SomaColors.primaryText + : cs.onSurface.withAlpha(155), + ), + ), + const SizedBox(height: 1), + Text( + '${fecha.day} ${_mesesCortos[fecha.month]}', + style: TextStyle( + fontSize: 11, + color: today + ? SomaColors.primaryText.withAlpha(180) + : cs.onSurface.withAlpha(115), + ), + ), + ], + ), + ), + if (esCerrado) + _HeaderBadge(icon: Icons.block, color: SomaColors.error) + else if (esEspecial) + _HeaderBadge(icon: Icons.event_note, color: SomaColors.primary), + if (puedeAgregar) ...[ + const SizedBox(width: 2), + SizedBox( + width: 28, + height: 28, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.add, size: 16, color: cs.onSurface.withAlpha(130)), + tooltip: 'Agregar turno', + onPressed: onCrear, + ), + ), + ] else + const SizedBox(width: 32), + ], + ), + ); + + final decoration = BoxDecoration( + color: today ? SomaColors.primary.withAlpha(22) : null, + border: Border( + bottom: BorderSide( + color: today + ? SomaColors.primary.withAlpha(120) + : cs.surfaceContainerHighest, + width: today ? 1.5 : 1, + ), + ), + ); + + if (onTap == null) { + return DecoratedBox(decoration: decoration, child: content); + } + + return DecoratedBox( + decoration: decoration, + child: InkWell( + onTap: onTap, + child: content, + ), + ); + } +} + +class _HeaderBadge extends StatelessWidget { + final IconData icon; + final Color color; + const _HeaderBadge({required this.icon, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: color.withAlpha(20), + borderRadius: BorderRadius.circular(4), + ), + child: Icon(icon, size: 12, color: color.withAlpha(200)), + ); + } +} + +// ── Body ─────────────────────────────────────────────────────────────────────── + +class _DiaTurnosBody extends StatelessWidget { + final DiaTurnos? dia; + final bool isPast; + final bool isAdmin; + final void Function(Turno) onAsignar; + final void Function(Turno) onVerInscriptos; + + const _DiaTurnosBody({ + required this.dia, + required this.isPast, + required this.isAdmin, + required this.onAsignar, + required this.onVerInscriptos, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + if (dia == null) { + return Center( + child: Text('—', + style: TextStyle(fontSize: 18, color: cs.onSurface.withAlpha(55))), + ); + } + + if (dia!.estado == DiaEstado.cerrado) { + return Container( + color: SomaColors.error.withAlpha(10), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.block, size: 22, color: SomaColors.error.withAlpha(130)), + const SizedBox(height: 6), + Text('Cerrado', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: SomaColors.error.withAlpha(160), + )), + ], + ), + ), + ); + } + + if (dia!.turnos.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_busy_outlined, + size: 22, color: cs.onSurface.withAlpha(50)), + const SizedBox(height: 6), + Text( + isPast ? 'Sin turnos' : 'Sin turnos\ngenerados', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(90)), + ), + ], + ), + ); + } + + final turnos = [...dia!.turnos] + ..sort((a, b) => a.horaInicio.compareTo(b.horaInicio)); + + return ListView.separated( + padding: const EdgeInsets.all(8), + itemCount: turnos.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final turno = turnos[index]; + final readOnly = isPast || !isAdmin; + return TurnoSlotTile( + turno: turno, + readOnly: readOnly, + onAsignar: readOnly ? null : () => onAsignar(turno), + onVerInscriptos: isAdmin ? () => onVerInscriptos(turno) : null, + ); + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/turnos/presentation/widgets/turno_slot_tile.dart b/flutter_soma_app/lib/features/turnos/presentation/widgets/turno_slot_tile.dart new file mode 100644 index 0000000..31e21be --- /dev/null +++ b/flutter_soma_app/lib/features/turnos/presentation/widgets/turno_slot_tile.dart @@ -0,0 +1,378 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart'; + +Color _barColor(int ocupacion, int capacidad, ColorScheme cs) { + if (capacidad == 0) return cs.outline; + final ratio = ocupacion / capacidad; + if (ratio < 0.5) return SomaColors.success; + if (ratio < 0.85) return const Color(0xFFFFB300); + return SomaColors.error; +} + +class TurnoSlotTile extends StatelessWidget { + final Turno turno; + final bool readOnly; + final VoidCallback? onAsignar; + final VoidCallback? onVerInscriptos; + + const TurnoSlotTile({ + super.key, + required this.turno, + this.readOnly = false, + this.onAsignar, + this.onVerInscriptos, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs); + final estaLleno = turno.estaLleno; + final canTap = !readOnly && onVerInscriptos != null; + + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 200; + + return MouseRegion( + cursor: canTap ? SystemMouseCursors.click : SystemMouseCursors.basic, + // Material propio (en vez de Ink) para que el fondo y el ripple se + // pinten en la capa de la card y queden recortados al ListView. Con + // Ink la decoración se pintaba sobre el Material ancestro y "sangraba" + // por encima de las cabeceras al scrollear. + child: Material( + color: cs.surface, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: cs.surfaceContainerHighest, + width: 0.5, + ), + ), + child: InkWell( + onTap: canTap ? onVerInscriptos : null, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 4, + decoration: BoxDecoration( + color: barColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + ), + ), + Expanded( + child: isCompact + ? _CompactContent( + turno: turno, + barColor: barColor, + estaLleno: estaLleno, + readOnly: readOnly, + onAsignar: onAsignar, + ) + : _WideContent( + turno: turno, + barColor: barColor, + estaLleno: estaLleno, + readOnly: readOnly, + onAsignar: onAsignar, + onVerInscriptos: onVerInscriptos, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _CompactContent extends StatelessWidget { + final Turno turno; + final Color barColor; + final bool estaLleno; + final bool readOnly; + final VoidCallback? onAsignar; + + const _CompactContent({ + required this.turno, + required this.barColor, + required this.estaLleno, + required this.readOnly, + this.onAsignar, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final hasMenu = !readOnly && onAsignar != null; + + return Padding( + padding: EdgeInsets.fromLTRB(10, 8, hasMenu ? 0 : 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${turno.horaInicio} – ${turno.horaFin}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: cs.onSurface.withAlpha(170), + fontFeatures: const [FontFeature.tabularFigures()], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _SlotBadge( + estaLleno: estaLleno, + ocupacion: turno.ocupacion, + maxima: turno.capacidadMaxima, + barColor: barColor, + compact: true, + ), + if (hasMenu) _SlotMenu(onAsignar: onAsignar, onVerInscriptos: null), + ], + ), + const SizedBox(height: 2), + Text( + turno.actividad.nombre, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +class _WideContent extends StatelessWidget { + final Turno turno; + final Color barColor; + final bool estaLleno; + final bool readOnly; + final VoidCallback? onAsignar; + final VoidCallback? onVerInscriptos; + + const _WideContent({ + required this.turno, + required this.barColor, + required this.estaLleno, + required this.readOnly, + this.onAsignar, + this.onVerInscriptos, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final hasMenu = !readOnly && (onAsignar != null || onVerInscriptos != null); + + return Padding( + padding: EdgeInsets.fromLTRB(12, 10, hasMenu ? 4 : 12, 10), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text( + '${turno.horaInicio} – ${turno.horaFin}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + Container( + width: 1, + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: cs.surfaceContainerHighest, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Text( + turno.actividad.nombre, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (turno.actividad.libre) ...[ + const SizedBox(width: 6), + _LibreTag(), + ], + ], + ), + const SizedBox(height: 3), + _SlotBadge( + estaLleno: estaLleno, + ocupacion: turno.ocupacion, + maxima: turno.capacidadMaxima, + barColor: barColor, + compact: false, + ), + ], + ), + ), + if (hasMenu) + _SlotMenu(onAsignar: onAsignar, onVerInscriptos: onVerInscriptos), + ], + ), + ); + } +} + +// ── Slot badge: "X / Y" o "LLENO" ───────────────────────────────────────────── + +class _SlotBadge extends StatelessWidget { + final bool estaLleno; + final int ocupacion; + final int maxima; + final Color barColor; + final bool compact; + + const _SlotBadge({ + required this.estaLleno, + required this.ocupacion, + required this.maxima, + required this.barColor, + required this.compact, + }); + + @override + Widget build(BuildContext context) { + if (maxima == 0) return const SizedBox.shrink(); + + final label = estaLleno ? 'LLENO' : '$ocupacion/$maxima'; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 5 : 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: barColor.withAlpha(25), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: barColor.withAlpha(80), width: 0.7), + ), + child: Text( + label, + style: TextStyle( + fontSize: compact ? 9 : 10, + fontWeight: FontWeight.w700, + color: barColor, + letterSpacing: 0.3, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); + } +} + +class _LibreTag extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: SomaColors.success.withAlpha(22), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Libre', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: SomaColors.success, + letterSpacing: 0.2, + ), + ), + ); + } +} + +// ── Menú ─────────────────────────────────────────────────────────────────────── + +class _SlotMenu extends StatelessWidget { + final VoidCallback? onAsignar; + final VoidCallback? onVerInscriptos; + + const _SlotMenu({this.onAsignar, this.onVerInscriptos}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final items = >[]; + + if (onAsignar != null) { + items.add(PopupMenuItem( + value: 'asignar', + height: 44, + child: Row(children: [ + Icon(Icons.person_add_outlined, size: 16, color: cs.primary), + const SizedBox(width: 10), + const Text('Asignar usuario', style: TextStyle(fontSize: 13)), + ]), + )); + } + if (onVerInscriptos != null) { + items.add(PopupMenuItem( + value: 'inscriptos', + height: 44, + child: Row(children: [ + Icon(Icons.group_outlined, size: 16, color: cs.onSurface.withAlpha(153)), + const SizedBox(width: 10), + const Text('Ver inscriptos', style: TextStyle(fontSize: 13)), + ]), + )); + } + + if (items.isEmpty) return const SizedBox(width: 36); + + return PopupMenuButton( + icon: Icon(Icons.more_vert, size: 18, color: cs.onSurface.withAlpha(100)), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 8, + itemBuilder: (_) => items, + onSelected: (val) { + if (val == 'asignar') onAsignar?.call(); + if (val == 'inscriptos') onVerInscriptos?.call(); + }, + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/data/repositories/usuarios_repository_impl.dart b/flutter_soma_app/lib/features/usuarios/data/repositories/usuarios_repository_impl.dart new file mode 100644 index 0000000..4d86415 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/data/repositories/usuarios_repository_impl.dart @@ -0,0 +1,135 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart'; + +class UsuariosRepositoryImpl implements UsuariosRepository { + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(AppConstants.tokenKey); + if (token == null) throw Exception('Sin sesión activa'); + return token; + } + + @override + Future> getUsuarios({ + int pagina = 1, + int cantidad = 50, + String? dni, + }) async { + final token = await _getToken(); + + final params = { + 'p_token': token, + 'p_pagina': pagina, + 'p_cantidad': cantidad, + }; + if (dni != null && dni.isNotEmpty) { + params['p_dni'] = dni; + } + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetUsuarios, + params: params, + ); + + if (response is List) { + return response + .map((e) => Usuario.fromMap(e as Map)) + .toList(); + } + return []; + } + + @override + Future insertUsuario(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcInsertUsuario, + params: { + 'p_datos': datos, + 'p_token': token, + }, + ); + } + + @override + Future updateUsuario(Map datos) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcUpdateUsuario, + params: { + 'p_datos': datos, + 'p_token': token, + }, + ); + } + + @override + Future toggleUsuarioStatus(String id, bool estado) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcToggleUsuarioStatus, + params: { + 'p_token': token, + 'p_id': id, + 'p_estado': estado, + }, + ); + } + + @override + Future deleteUsuario(String dni) async { + final token = await _getToken(); + + final response = await SupabaseConfig.rpc( + AppConstants.rpcDeleteUsuario, + params: { + 'p_dni': dni, + 'p_token': token, + }, + ); + return response == true; + } + + @override + Future resetearContrasena(String usuarioId, String passwordNueva) async { + final token = await _getToken(); + + await SupabaseConfig.rpc( + AppConstants.rpcResetearContrasenaUsuario, + params: { + 'p_token': token, + 'p_usuario_id': usuarioId, + 'p_password_nueva': passwordNueva, + }, + ); + } + + @override + Future>> getUsuariosTipoCuota( + {String? dni}) async { + final token = await _getToken(); + + final params = {'p_token': token}; + if (dni != null && dni.isNotEmpty) { + params['p_dni'] = dni; + } + + final response = await SupabaseConfig.rpc( + AppConstants.rpcGetUsuarioTipoCuota, + params: params, + ); + + if (response is List) { + return response + .map((e) => Map.from(e as Map)) + .toList(); + } + return []; + } +} diff --git a/flutter_soma_app/lib/features/usuarios/domain/entities/usuario.dart b/flutter_soma_app/lib/features/usuarios/domain/entities/usuario.dart new file mode 100644 index 0000000..a7c7cc5 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/domain/entities/usuario.dart @@ -0,0 +1,118 @@ +class Usuario { + final String id; + final String dni; + final String nombre; + final String? apellido; + final double? peso; + final int? altura; + final String rol; + final bool isActive; + final DateTime? fechaCreacion; + final DateTime? fechaModificacion; + final String? mail; + final String? telefono; + final double? fuerzaMax; + final String? sexo; + final String? tipoCuota; + + const Usuario({ + required this.id, + required this.dni, + required this.nombre, + this.apellido, + this.peso, + this.altura, + required this.rol, + this.isActive = true, + this.fechaCreacion, + this.fechaModificacion, + this.mail, + this.telefono, + this.fuerzaMax, + this.sexo, + this.tipoCuota, + }); + + factory Usuario.fromMap(Map map) { + return Usuario( + id: map['id'] as String, + dni: map['dni'] as String? ?? '', + nombre: map['nombre'] as String? ?? '', + apellido: map['apellido'] as String?, + peso: (map['peso'] as num?)?.toDouble(), + altura: (map['altura'] as num?)?.toInt(), + rol: map['rol'] as String? ?? 'cliente', + isActive: map['isactive'] as bool? ?? map['isActive'] as bool? ?? true, + fechaCreacion: map['fecha_creacion'] != null + ? DateTime.tryParse(map['fecha_creacion'].toString()) + : null, + fechaModificacion: map['fecha_modificacion'] != null + ? DateTime.tryParse(map['fecha_modificacion'].toString()) + : null, + mail: map['mail'] as String?, + telefono: map['telefono'] as String?, + fuerzaMax: (map['fuerza_max'] as num?)?.toDouble(), + sexo: map['sexo'] as String?, + tipoCuota: map['tipo_cuota'] as String?, + ); + } + + String get displayName { + if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) { + return '$nombre $apellido'; + } + return nombre.isNotEmpty ? nombre : dni; + } + + String get rolDisplay { + return switch (rol) { + 'superadmin' || 'admin' || 'profesor' => 'Admin', + 'cliente' => 'Cliente', + _ => rol, + }; + } + + String get initials { + if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) { + return '${nombre[0]}${apellido![0]}'.toUpperCase(); + } + if (nombre.isNotEmpty) return nombre[0].toUpperCase(); + if (dni.length >= 2) return dni.substring(0, 2); + return '?'; + } + + /// Para fc_insertar_usuario (p_datos jsonb). + Map toInsertMap(String password) { + return { + 'dni': dni, + 'nombre': nombre, + if (apellido != null) 'apellido': apellido, + if (peso != null) 'peso': peso, + if (altura != null) 'altura': altura, + 'rol': rol, + if (mail != null && mail!.isNotEmpty) 'mail': mail, + if (telefono != null && telefono!.isNotEmpty) 'telefono': telefono, + if (fuerzaMax != null) 'fuerza_max': fuerzaMax, + if (sexo != null) 'sexo': sexo, + if (tipoCuota != null) 'tipo_cuota': tipoCuota, + if (password.isNotEmpty) 'password': password, + 'isActive': isActive, + }; + } + + /// Para fc_modificar_usuario (p_datos jsonb). Sin password. + Map toUpdateMap() { + return { + 'dni': dni, + 'nombre': nombre, + if (apellido != null) 'apellido': apellido, + if (peso != null) 'peso': peso, + if (altura != null) 'altura': altura, + 'rol': rol, + if (mail != null) 'mail': mail, + if (telefono != null) 'telefono': telefono, + if (fuerzaMax != null) 'fuerza_max': fuerzaMax, + if (sexo != null) 'sexo': sexo, + }; + } +} diff --git a/flutter_soma_app/lib/features/usuarios/domain/repositories/usuarios_repository.dart b/flutter_soma_app/lib/features/usuarios/domain/repositories/usuarios_repository.dart new file mode 100644 index 0000000..d7f193a --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/domain/repositories/usuarios_repository.dart @@ -0,0 +1,24 @@ +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +abstract class UsuariosRepository { + Future> getUsuarios({ + int pagina = 1, + int cantidad = 50, + String? dni, + }); + + Future insertUsuario(Map datos); + + Future updateUsuario(Map datos); + + Future toggleUsuarioStatus(String id, bool estado); + + Future deleteUsuario(String dni); + + /// Reset administrativo de contraseña (solo superadmin). No requiere la + /// contraseña actual del usuario objetivo. + Future resetearContrasena(String usuarioId, String passwordNueva); + + /// Obtener mapping usuario → tipo de cuota. + Future>> getUsuariosTipoCuota({String? dni}); +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_provider.dart b/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_provider.dart new file mode 100644 index 0000000..729f213 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_provider.dart @@ -0,0 +1,152 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/data/repositories/usuarios_repository_impl.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart'; + +final usuariosRepositoryProvider = Provider((ref) { + return UsuariosRepositoryImpl(); +}); + +/// Todos los usuarios sin filtro. Se invalida automáticamente cuando usuariosProvider muta. +final allUsuariosProvider = FutureProvider.autoDispose>((ref) async { + final repo = ref.read(usuariosRepositoryProvider); + return repo.getUsuarios(); +}); + +/// Mapa DNI → monto de deuda del mes actual. +/// Se recalcula automáticamente cuando allUsuariosProvider o tiposCuotaProvider cambian. +final usuariosDeudaProvider = FutureProvider.autoDispose>((ref) async { + final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull; + if (tiposCuota == null || tiposCuota.isEmpty) return {}; + + final usuarios = ref.watch(allUsuariosProvider).valueOrNull; + if (usuarios == null || usuarios.isEmpty) return {}; + + final pagosRepo = ref.read(pagosRepositoryProvider); + final pagos = await pagosRepo.getPagos(cantidad: 500); + + final now = DateTime.now(); + final mesActual = + '${now.year}-${now.month.toString().padLeft(2, '0')}-01'; + + final pagosMes = {}; + for (final p in pagos) { + if (p.anioMesPagado == mesActual && p.cliente != null) { + pagosMes[p.cliente!.dni] = + (pagosMes[p.cliente!.dni] ?? 0) + p.montoTotal; + } + } + + final deuda = {}; + for (final u in usuarios) { + if (u.tipoCuota == null || !u.isActive) continue; + + final tc = + tiposCuota.where((t) => t.id == u.tipoCuota).firstOrNull; + if (tc == null) continue; + + double esperado = tc.precio; + if (tc.recargo != null && + tc.recargo! > 0 && + now.day > tc.diaDePago) { + esperado += tc.recargo!; + } + + final pagado = pagosMes[u.dni] ?? 0; + deuda[u.dni] = esperado - pagado; + } + + return deuda; +}); + +/// Helper compartido: retorna null si el usuario no tiene plan o está inactivo, +/// 0.0 si está al día, >0 si debe. +double? getUsuarioDeuda(Usuario u, Map deudaMap) { + if (u.tipoCuota == null || !u.isActive) return null; + return deudaMap[u.dni] ?? 0.0; +} + +final usuariosProvider = + StateNotifierProvider.autoDispose>>((ref) { + return UsuariosNotifier(ref.read(usuariosRepositoryProvider), ref); +}); + +class UsuariosNotifier extends StateNotifier>> { + final UsuariosRepository _repository; + final Ref _ref; + + UsuariosNotifier(this._repository, this._ref) : super(const AsyncValue.loading()) { + loadUsuarios(); + } + + /// Invalida providers derivados para que refetcheen con datos frescos. + void _invalidateDerived() { + _ref.invalidate(allUsuariosProvider); + // usuariosDeudaProvider y pagosEstadoProvider se recalculan solos + // porque hacen ref.watch(allUsuariosProvider) + } + + Future loadUsuarios() async { + state = const AsyncValue.loading(); + try { + final usuarios = await _repository.getUsuarios(); + state = AsyncValue.data(usuarios); + _invalidateDerived(); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future insertUsuario(Map datos) async { + try { + await _repository.insertUsuario(datos); + await loadUsuarios(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future updateUsuario(Map datos) async { + try { + await _repository.updateUsuario(datos); + await loadUsuarios(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future toggleStatus(String id, bool estado) async { + try { + await _repository.toggleUsuarioStatus(id, estado); + await loadUsuarios(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + Future deleteUsuario(String dni) async { + try { + await _repository.deleteUsuario(dni); + await loadUsuarios(); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } + + /// Reset administrativo de contraseña. No refetchea la lista: no cambia + /// ningún dato visible en ella. + Future resetearContrasena(String usuarioId, String passwordNueva) async { + try { + await _repository.resetearContrasena(usuarioId, passwordNueva); + return null; + } catch (e) { + return e.toString().replaceFirst('Exception: ', ''); + } + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart b/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart new file mode 100644 index 0000000..1d6f4ce --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; + +enum UsuariosViewMode { overview, cards, table } + +final usuariosViewModeProvider = + StateNotifierProvider((ref) { + return UsuariosViewModeNotifier(); +}); + +class UsuariosViewModeNotifier extends StateNotifier { + UsuariosViewModeNotifier() : super(UsuariosViewMode.overview) { + _loadViewMode(); + } + + Future _loadViewMode() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(AppConstants.usuariosViewModeKey); + if (stored == 'table') { + state = UsuariosViewMode.table; + } else if (stored == 'cards') { + state = UsuariosViewMode.cards; + } else { + state = UsuariosViewMode.overview; + } + } + + Future toggle() async { + final newMode = switch (state) { + UsuariosViewMode.overview => UsuariosViewMode.cards, + UsuariosViewMode.cards => UsuariosViewMode.table, + UsuariosViewMode.table => UsuariosViewMode.overview, + }; + await setMode(newMode); + } + + Future setMode(UsuariosViewMode mode) async { + state = mode; + final prefs = await SharedPreferences.getInstance(); + final stored = switch (mode) { + UsuariosViewMode.table => 'table', + UsuariosViewMode.cards => 'cards', + UsuariosViewMode.overview => 'overview', + }; + await prefs.setString(AppConstants.usuariosViewModeKey, stored); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/screens/usuarios_screen.dart b/flutter_soma_app/lib/features/usuarios/presentation/screens/usuarios_screen.dart new file mode 100644 index 0000000..8fee100 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/screens/usuarios_screen.dart @@ -0,0 +1,991 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_header_help.dart'; +import 'package:gimnasio_soma/core/widgets/soma_toast.dart'; +import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_card.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_detail_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_form_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_table_view.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/editar_plan_dialog.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_overview.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart'; +import 'package:go_router/go_router.dart'; + +enum _DeudaFilter { todos, alDia, debe } +enum _RolFilter { todos, staff, clientes } +enum _ActiveFilter { todos, activos, inactivos } + +class UsuariosScreen extends ConsumerStatefulWidget { + const UsuariosScreen({super.key}); + + @override + ConsumerState createState() => _UsuariosScreenState(); +} + +class _UsuariosScreenState extends ConsumerState { + final _searchCtrl = TextEditingController(); + _DeudaFilter _filter = _DeudaFilter.todos; + _RolFilter _rolFilter = _RolFilter.todos; + _ActiveFilter _activeFilter = _ActiveFilter.todos; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + void _onSearch(String value) { + setState(() {}); // Rebuild para aplicar filtro local + } + + bool get _actorIsSuperadmin => + ref.read(authStateProvider).valueOrNull?.isSuperadmin ?? false; + + Widget _filterItem(BuildContext ctx, String label) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(ctx).colorScheme.onSurface, + ), + ), + ); + } + + List _applyLocalSearch(List usuarios) { + final query = _searchCtrl.text.trim().toLowerCase(); + if (query.isEmpty) return usuarios; + return usuarios.where((u) { + return u.nombre.toLowerCase().contains(query) || + (u.apellido?.toLowerCase().contains(query) ?? false) || + u.dni.contains(query) || + (u.mail?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + Future _showCreateDialog() async { + final existingDnis = ref + .read(allUsuariosProvider) + .valueOrNull + ?.map((u) => u.dni) + .toSet() ?? + {}; + final result = await showDialog>( + context: context, + builder: (_) => UsuarioFormDialog(existingDnis: existingDnis), + ); + if (result == null || !mounted) return; + + final error = await ref + .read(usuariosProvider.notifier) + .insertUsuario(result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: 'Usuario creado', + type: ToastType.success, + ); + } + } + + Future _showDetail(Usuario usuario) async { + final result = await showDialog( + context: context, + builder: (_) => UsuarioDetailDialog(usuario: usuario), + ); + if (result == 'edit' && mounted) { + _showEditDialog(usuario); + } + } + + Future _showEditDialog(Usuario usuario) async { + final result = await showDialog>( + context: context, + builder: (_) => UsuarioFormDialog( + usuario: usuario, + actorIsSuperadmin: _actorIsSuperadmin, + ), + ); + if (result == null || !mounted) return; + + final error = await ref + .read(usuariosProvider.notifier) + .updateUsuario(result); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: 'Usuario actualizado', + type: ToastType.success, + ); + } + } + + Future _toggleStatus(Usuario usuario) async { + final newStatus = !usuario.isActive; + final accion = newStatus ? 'activar' : 'desactivar'; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('${newStatus ? 'Activar' : 'Desactivar'} usuario'), + content: Text( + '¿Estás seguro de que querés $accion a ${usuario.displayName}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(newStatus ? 'Activar' : 'Desactivar'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + + final error = await ref + .read(usuariosProvider.notifier) + .toggleStatus(usuario.id, newStatus); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: newStatus ? 'Usuario activado' : 'Usuario desactivado', + type: ToastType.success, + ); + } + } + + Future _deleteUsuario(Usuario usuario) async { + if (!_actorIsSuperadmin) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Eliminar usuario'), + content: Text( + '¿Estás seguro de que querés eliminar a ${usuario.displayName}?\n' + 'Esta acción no se puede deshacer.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Eliminar'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + + final error = await ref + .read(usuariosProvider.notifier) + .deleteUsuario(usuario.dni); + if (!mounted) return; + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: 'Usuario eliminado', + type: ToastType.success, + ); + } + } + + List _applyRolFilter(List usuarios) { + if (_rolFilter == _RolFilter.todos) return usuarios; + return usuarios.where((u) { + if (_rolFilter == _RolFilter.staff) { + return u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor'; + } + if (_rolFilter == _RolFilter.clientes) { + return u.rol == 'cliente'; + } + return true; + }).toList(); + } + + List _applyActiveFilter(List usuarios) { + if (_activeFilter == _ActiveFilter.todos) return usuarios; + return usuarios + .where((u) => _activeFilter == _ActiveFilter.activos ? u.isActive : !u.isActive) + .toList(); + } + + List _applyFilter( + List usuarios, Map deudaMap) { + if (_filter == _DeudaFilter.todos) return usuarios; + + return usuarios.where((u) { + final d = getUsuarioDeuda(u, deudaMap); + if (_filter == _DeudaFilter.debe) return d != null && d > 0; + if (_filter == _DeudaFilter.alDia) return d != null && d <= 0; + return true; + }).toList(); + } + + // Acciones rápidas + + Future _resetearContrasena(Usuario usuario) async { + final nuevaPassword = await showDialog( + context: context, + builder: (_) => ResetearContrasenaDialog(usuario: usuario), + ); + if (nuevaPassword == null || !mounted) return; + + final error = await ref + .read(usuariosProvider.notifier) + .resetearContrasena(usuario.id, nuevaPassword); + if (!mounted) return; + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: 'Contraseña actualizada', + type: ToastType.success, + ); + } + } + + Future _registrarPago(Usuario usuario) async { + final result = await showDialog>( + context: context, + builder: (_) => PagoFormDialog(prefilledDni: usuario.dni), + ); + if (result == null || !mounted) return; + + // Extraer y remover datos de actualización de plan antes de insertar pago + final planUpdate = + result.remove('actualizar_plan') as Map?; + + // Insertar el pago directamente vía repositorio para evitar la race + // condition con pagosProvider.autoDispose (nadie lo watchea aquí). + String? error; + try { + await ref.read(pagosRepositoryProvider).insertPago(result); + ref.invalidate(ultimoPagoMapProvider); + // userPagosProvider y userHistorialProvider son family autoDispose; + // ref.invalidate sobre la familia entera fuerza re-fetch en próximo watch. + ref.invalidate(userPagosProvider); + ref.invalidate(userHistorialProvider); + // fc_insertar_pago activa al cliente incondicionalmente; refrescamos + // la lista para que la UI refleje el nuevo isactive. + await ref.read(usuariosProvider.notifier).loadUsuarios(); + } catch (e) { + error = e.toString().replaceFirst('Exception: ', ''); + } + if (!mounted) return; + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + return; + } + + // Actualizar plan del usuario si se pidió + if (planUpdate != null) { + final planError = + await ref.read(usuariosProvider.notifier).updateUsuario({ + 'id': planUpdate['usuario_id'], + 'tipo_cuota': planUpdate['tipo_cuota_id'], + }); + if (mounted && planError != null) { + SomaToast.show(context, + message: 'Pago registrado, pero error al actualizar plan: $planError', + type: ToastType.info); + return; + } + } + + if (mounted) { + SomaToast.show( + context, + message: planUpdate != null + ? 'Pago registrado y plan actualizado' + : 'Pago registrado', + type: ToastType.success, + ); + } + } + + void _asignarRutina(Usuario usuario) { + context.go('/rutinas?dni=${usuario.dni}'); + } + + void _verHistorial(Usuario usuario) { + showDialog( + context: context, + builder: (_) => UsuarioHistorialDialog( + dni: usuario.dni, + nombre: usuario.displayName, + initials: usuario.initials, + ), + ); + } + + Future _editarPlan(Usuario usuario) async { + final result = await showDialog>( + context: context, + builder: (_) => EditarPlanDialog(usuario: usuario), + ); + if (result == null || !mounted) return; // Usuario canceló + + final newTipoCuota = result['tipo_cuota'] as String?; + + // Si no cambió, no hacer nada + if (newTipoCuota == usuario.tipoCuota) return; + + // Actualizar el tipo_cuota del usuario + final updateData = { + 'id': usuario.id, + 'tipo_cuota': newTipoCuota, // null si se seleccionó "Sin plan" + }; + + final error = await ref + .read(usuariosProvider.notifier) + .updateUsuario(updateData); + if (!mounted) return; + + if (error != null) { + SomaToast.show(context, message: error, type: ToastType.error); + } else { + SomaToast.show( + context, + message: 'Plan actualizado', + type: ToastType.success, + ); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(usuariosProvider); + final deudaAsync = ref.watch(usuariosDeudaProvider); + final deudaMap = deudaAsync.valueOrNull ?? {}; + final ultimoPagoMap = ref.watch(ultimoPagoMapProvider).valueOrNull ?? {}; + final isWide = MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint; + final theme = Theme.of(context); + final viewMode = ref.watch(usuariosViewModeProvider); + final actorIsSuperadmin = + ref.watch(authStateProvider).valueOrNull?.isSuperadmin ?? false; + + // Overview mode: simplified header + dashboard + if (viewMode == UsuariosViewMode.overview) { + return Scaffold( + body: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 0, + ), + child: Row( + children: [ + const Text( + 'Usuarios', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SomaHeaderHelp( + items: [ + SomaHelpItem( + icon: Icons.dashboard_outlined, + text: 'Cambiá entre vista resumen, tarjetas o tabla.', + ), + SomaHelpItem( + icon: Icons.add, + text: 'Creá un nuevo usuario.', + ), + ], + ), + const Spacer(), + _ViewToggleButton(isWide: isWide), + const SizedBox(width: 8), + _AddButton(isWide: isWide, onTap: _showCreateDialog), + ], + ), + ), + Expanded( + child: UsuariosOverview( + onVerTodos: () => ref + .read(usuariosViewModeProvider.notifier) + .setMode(UsuariosViewMode.cards), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + // Header con búsqueda + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + isWide ? 28 : 16, + isWide ? 32 : 16, + 0, + ), + child: Row( + children: [ + if (isWide) ...[ + const Text( + 'Usuarios', + style: TextStyle( + fontSize: 22, fontWeight: FontWeight.w700), + ), + const SomaHeaderHelp( + items: [ + SomaHelpItem( + icon: Icons.search, + text: 'Buscá por nombre, apellido, DNI o email.', + ), + SomaHelpItem( + icon: Icons.dashboard_outlined, + text: 'Cambiá entre vista resumen, tarjetas o tabla.', + ), + SomaHelpItem( + icon: Icons.add, + text: 'Creá un nuevo usuario.', + ), + SomaHelpItem( + icon: Icons.filter_alt_outlined, + text: 'Filtrá la lista por estado de pago, rol o si ' + 'están activos.', + ), + ], + ), + const SizedBox(width: 16), + ], + Expanded( + child: SizedBox( + height: 42, + child: TextField( + controller: _searchCtrl, + onChanged: _onSearch, + decoration: InputDecoration( + hintText: 'Buscar por nombre, email o DNI...', + hintStyle: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface + .withAlpha(100), + ), + prefixIcon: Icon( + Icons.search, + size: 20, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + suffixIcon: _searchCtrl.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () { + _searchCtrl.clear(); + _onSearch(''); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 0, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: theme + .colorScheme.surfaceContainerHighest, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: theme + .colorScheme.surfaceContainerHighest, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: SomaColors.primary, + width: 1.5, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + ), + style: const TextStyle(fontSize: 14), + ), + ), + ), + const SizedBox(width: 12), + _ViewToggleButton(isWide: isWide), + const SizedBox(width: 8), + _AddButton(isWide: isWide, onTap: _showCreateDialog), + ], + ), + ), + + // Filtros de deuda y rol + Padding( + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + 12, + isWide ? 32 : 16, + 8, + ), + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.8, + ), + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton<_DeudaFilter>( + value: _filter, + selectedItemBuilder: (ctx) => [ + _filterItem(ctx, 'Estado'), + _filterItem(ctx, 'Al día'), + _filterItem(ctx, 'Debe'), + ], + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: _DeudaFilter.todos, + child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _DeudaFilter.alDia, + child: Text('Al día', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _DeudaFilter.debe, + child: Text('Debe', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _filter = value); + }, + ), + ), + VerticalDivider( + width: 1, + thickness: 0.8, + color: theme.colorScheme.surfaceContainerHighest, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton<_RolFilter>( + value: _rolFilter, + selectedItemBuilder: (ctx) => [ + _filterItem(ctx, 'Rol'), + _filterItem(ctx, 'Admins'), + _filterItem(ctx, 'Clientes'), + ], + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: _RolFilter.todos, + child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _RolFilter.staff, + child: Text('Admins', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _RolFilter.clientes, + child: Text('Clientes', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _rolFilter = value); + }, + ), + ), + VerticalDivider( + width: 1, + thickness: 0.8, + color: theme.colorScheme.surfaceContainerHighest, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButton<_ActiveFilter>( + value: _activeFilter, + selectedItemBuilder: (ctx) => [ + _filterItem(ctx, 'Estado'), + _filterItem(ctx, 'Activos'), + _filterItem(ctx, 'Inactivos'), + ], + underline: const SizedBox(), + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: _ActiveFilter.todos, + child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _ActiveFilter.activos, + child: Text('Activos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + DropdownMenuItem( + value: _ActiveFilter.inactivos, + child: Text('Inactivos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _activeFilter = value); + }, + ), + ), + ], + ), + ), + ), + if (_filter != _DeudaFilter.todos || _rolFilter != _RolFilter.todos || _activeFilter != _ActiveFilter.todos) ...[ + const SizedBox(width: 8), + InkWell( + onTap: () => setState(() { + _filter = _DeudaFilter.todos; + _rolFilter = _RolFilter.todos; + _activeFilter = _ActiveFilter.todos; + }), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: SomaColors.primary.withAlpha(14), + border: Border.all( + color: SomaColors.primary.withAlpha(50), + width: 0.8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.close, size: 14, color: SomaColors.primaryText), + const SizedBox(width: 5), + Text( + 'Limpiar', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primaryText, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + + // Lista + Expanded( + child: state.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + const SizedBox(height: 12), + Text( + e.toString().replaceFirst('Exception: ', ''), + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurface + .withAlpha(153), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => ref + .read(usuariosProvider.notifier) + .loadUsuarios(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Reintentar'), + ), + ], + ), + ), + data: (usuarios) { + final searched = _applyLocalSearch(usuarios); + final activeFiltered = _applyActiveFilter(searched); + final rolFiltered = _applyRolFilter(activeFiltered); + final filtered = _applyFilter(rolFiltered, deudaMap); + + if (filtered.isEmpty) { + final hasSearch = _searchCtrl.text.isNotEmpty; + final hasFilter = _filter != _DeudaFilter.todos || + _rolFilter != _RolFilter.todos; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people_outline, + size: 56, + color: theme.colorScheme.onSurface.withAlpha(60), + ), + const SizedBox(height: 12), + Text( + hasFilter + ? 'No hay usuarios con este filtro' + : hasSearch + ? 'No se encontraron usuarios para "${_searchCtrl.text}"' + : 'No hay usuarios', + style: TextStyle( + fontSize: 15, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + textAlign: TextAlign.center, + ), + if (hasSearch) ...[ + const SizedBox(height: 14), + TextButton.icon( + onPressed: () { + _searchCtrl.clear(); + _onSearch(''); + }, + icon: const Icon(Icons.close, size: 16), + label: const Text('Limpiar búsqueda'), + ), + ], + ], + ), + ); + } + + return RefreshIndicator( + color: SomaColors.primary, + onRefresh: () => ref + .read(usuariosProvider.notifier) + .loadUsuarios(), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: viewMode == UsuariosViewMode.table && isWide + ? Padding( + key: const ValueKey('table'), + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + 4, + isWide ? 32 : 16, + 80, + ), + child: UsuariosTableView( + usuarios: filtered, + deudaMap: deudaMap, + ultimoPagoMap: ultimoPagoMap, + onTap: _showDetail, + onEdit: _showEditDialog, + onToggleStatus: _toggleStatus, + onDelete: _deleteUsuario, + onRegistrarPago: _registrarPago, + onAsignarRutina: _asignarRutina, + onVerHistorial: _verHistorial, + onEditarPlan: _editarPlan, + actorIsSuperadmin: actorIsSuperadmin, + onResetPassword: _resetearContrasena, + ), + ) + : ListView.separated( + key: const ValueKey('cards'), + padding: EdgeInsets.fromLTRB( + isWide ? 32 : 16, + 4, + isWide ? 32 : 16, + 80, + ), + itemCount: filtered.length, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final usuario = filtered[index]; + return UsuarioCard( + usuario: usuario, + deuda: getUsuarioDeuda(usuario, deudaMap), + onTap: () => _showDetail(usuario), + onEdit: () => _showEditDialog(usuario), + onToggleStatus: () => _toggleStatus(usuario), + onDelete: () => _deleteUsuario(usuario), + onRegistrarPago: () => _registrarPago(usuario), + onAsignarRutina: () => _asignarRutina(usuario), + onVerHistorial: () => _verHistorial(usuario), + onEditarPlan: () => _editarPlan(usuario), + canDelete: actorIsSuperadmin, + canResetPassword: actorIsSuperadmin && + usuario.rol != 'cliente', + onResetPassword: () => + _resetearContrasena(usuario), + ); + }, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _AddButton extends StatelessWidget { + final bool isWide; + final VoidCallback onTap; + + const _AddButton({required this.isWide, required this.onTap}); + + @override + Widget build(BuildContext context) { + if (isWide) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.add, size: 20), + label: const Text('Nuevo'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42)), + ); + } + + return SizedBox( + height: 42, + width: 42, + child: IconButton.filled( + onPressed: onTap, + icon: const Icon(Icons.add, size: 22), + style: IconButton.styleFrom( + backgroundColor: SomaColors.primary, + foregroundColor: SomaColors.onPrimary, + ), + ), + ); + } +} + +class _ViewToggleButton extends ConsumerWidget { + final bool isWide; + + const _ViewToggleButton({required this.isWide}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final viewMode = ref.watch(usuariosViewModeProvider); + final notifier = ref.read(usuariosViewModeProvider.notifier); + final theme = Theme.of(context); + + if (isWide) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: UsuariosViewMode.overview, + icon: Icon(Icons.dashboard_outlined, size: 18), + tooltip: 'Resumen', + ), + ButtonSegment( + value: UsuariosViewMode.cards, + icon: Icon(Icons.view_list, size: 18), + tooltip: 'Tarjetas', + ), + ButtonSegment( + value: UsuariosViewMode.table, + icon: Icon(Icons.table_rows_outlined, size: 18), + tooltip: 'Tabla', + ), + ], + selected: {viewMode}, + onSelectionChanged: (s) => notifier.setMode(s.first), + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + showSelectedIcon: false, + ); + } + + // Narrow: cicla entre modos con un ícono contextual + final (icon, tooltip) = switch (viewMode) { + UsuariosViewMode.overview => (Icons.view_list, 'Vista de tarjetas'), + UsuariosViewMode.cards => (Icons.table_rows_outlined, 'Vista de tabla'), + UsuariosViewMode.table => (Icons.dashboard_outlined, 'Vista resumen'), + }; + + return IconButton( + onPressed: () => notifier.toggle(), + tooltip: tooltip, + icon: Icon(icon, size: 20), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + foregroundColor: theme.colorScheme.onSurface, + minimumSize: const Size(42, 42), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/editar_plan_dialog.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/editar_plan_dialog.dart new file mode 100644 index 0000000..dae687a --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/editar_plan_dialog.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +class EditarPlanDialog extends ConsumerStatefulWidget { + final Usuario usuario; + + const EditarPlanDialog({super.key, required this.usuario}); + + @override + ConsumerState createState() => _EditarPlanDialogState(); +} + +class _EditarPlanDialogState extends ConsumerState { + String? _selectedTipoCuotaId; + + @override + void initState() { + super.initState(); + _selectedTipoCuotaId = widget.usuario.tipoCuota; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tiposCuotaAsync = ref.watch(tiposCuotaProvider); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + constraints: const BoxConstraints(maxWidth: 450), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.card_membership, + color: SomaColors.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Editar Plan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + widget.usuario.displayName, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + + // Content + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Seleccionar Tipo de Cuota', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + const SizedBox(height: 12), + tiposCuotaAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator( + color: SomaColors.primary, + ), + ), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Error al cargar tipos de cuota', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.error, + ), + ), + ), + data: (tiposCuota) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + ), + ), + child: DropdownButton( + isExpanded: true, + value: _selectedTipoCuotaId, + underline: const SizedBox(), + items: [ + DropdownMenuItem( + value: null, + child: Text( + 'Sin plan', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ), + ...tiposCuota.map((tc) { + return DropdownMenuItem( + value: tc.id, + child: Row( + children: [ + Expanded( + child: Text( + tc.nombre, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + Text( + '${tc.precioDisplay} • ${tc.diasDisplay}', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + ], + ), + ); + }), + ], + onChanged: (value) { + setState(() { + _selectedTipoCuotaId = value; + }); + }, + ), + ); + }, + ), + ], + ), + ), + + // Footer + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancelar'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () async { + final removingPlan = widget.usuario.tipoCuota != null && + _selectedTipoCuotaId == null; + if (removingPlan) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('¿Quitar plan?'), + content: Text( + '${widget.usuario.displayName} quedará sin plan asignado. ' + 'No se eliminan los pagos registrados.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: SomaColors.error, + foregroundColor: Colors.white, + ), + child: const Text('Quitar plan'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + } + Navigator.of(context) + .pop({'tipo_cuota': _selectedTipoCuotaId}); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: const Text('Guardar'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart new file mode 100644 index 0000000..23ff2a8 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +/// Dialog de reset administrativo: el superadmin fija una contraseña nueva +/// para otro admin/superadmin, sin pedir la contraseña actual del objetivo. +/// Retorna la nueva contraseña (String) si se confirma, o null si se cancela. +class ResetearContrasenaDialog extends StatefulWidget { + final Usuario usuario; + + const ResetearContrasenaDialog({super.key, required this.usuario}); + + @override + State createState() => + _ResetearContrasenaDialogState(); +} + +class _ResetearContrasenaDialogState extends State { + final _formKey = GlobalKey(); + final _nuevaCtrl = TextEditingController(); + final _repetirCtrl = TextEditingController(); + bool _obscureNueva = true; + bool _obscureRepetir = true; + + @override + void dispose() { + _nuevaCtrl.dispose(); + _repetirCtrl.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + Navigator.of(context).pop(_nuevaCtrl.text); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 16), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cambiar contraseña de ${widget.usuario.displayName}', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + SomaTextField( + controller: _nuevaCtrl, + labelText: 'Contraseña nueva', + prefixIcon: Icons.lock_outline, + obscureText: _obscureNueva, + suffixIcon: IconButton( + icon: Icon( + _obscureNueva + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: theme.colorScheme.onSurface.withAlpha(130), + size: 20, + ), + onPressed: () => + setState(() => _obscureNueva = !_obscureNueva), + ), + validator: (v) { + if (v == null || v.isEmpty) return 'Requerido'; + if (v.length < 8) return 'Mínimo 8 caracteres'; + return null; + }, + ), + const SizedBox(height: 12), + SomaTextField( + controller: _repetirCtrl, + labelText: 'Repetir contraseña', + prefixIcon: Icons.lock_outline, + obscureText: _obscureRepetir, + suffixIcon: IconButton( + icon: Icon( + _obscureRepetir + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: theme.colorScheme.onSurface.withAlpha(130), + size: 20, + ), + onPressed: () => + setState(() => _obscureRepetir = !_obscureRepetir), + ), + validator: (v) => v != _nuevaCtrl.text + ? 'Las contraseñas no coinciden' + : null, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: const Text('Confirmar'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_card.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_card.dart new file mode 100644 index 0000000..18c1d76 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_card.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +class UsuarioCard extends ConsumerWidget { + final Usuario usuario; + final VoidCallback onTap; + final VoidCallback onEdit; + final VoidCallback onToggleStatus; + final VoidCallback onDelete; + final VoidCallback onRegistrarPago; + final VoidCallback onAsignarRutina; + final VoidCallback onVerHistorial; + final VoidCallback onEditarPlan; + final bool canResetPassword; + final VoidCallback? onResetPassword; + final bool canDelete; + /// null = sin plan, 0 = al día, >0 = monto de deuda + final double? deuda; + + const UsuarioCard({ + super.key, + required this.usuario, + required this.onTap, + required this.onEdit, + required this.onToggleStatus, + required this.onDelete, + required this.onRegistrarPago, + required this.onAsignarRutina, + required this.onVerHistorial, + required this.onEditarPlan, + this.canResetPassword = false, + this.onResetPassword, + this.canDelete = false, + this.deuda, + }); + + Color _stripeColor(ThemeData theme) { + if (!usuario.isActive) return theme.colorScheme.surfaceContainerHighest; + if (deuda == null) return theme.colorScheme.surfaceContainerHighest; + return deuda! <= 0 ? SomaColors.success : SomaColors.error; + } + + ContextMenu _buildContextMenu() { + return ContextMenu( + entries: [ + MenuItem( + label: const Text('Registrar pago'), + icon: const Icon(Icons.payment, size: 16), + value: 'pago', + ), + MenuItem( + label: const Text('Asignar rutina'), + icon: const Icon(Icons.fitness_center, size: 16), + value: 'rutina', + ), + MenuItem( + label: const Text('Ver historial'), + icon: const Icon(Icons.history, size: 16), + value: 'historial', + ), + MenuItem( + label: const Text('Editar plan'), + icon: const Icon(Icons.card_membership, size: 16), + value: 'plan', + ), + const MenuDivider(), + MenuItem( + label: const Text('Editar'), + icon: const Icon(Icons.edit_outlined, size: 16), + value: 'edit', + ), + MenuItem( + label: Text(usuario.isActive ? 'Desactivar' : 'Activar'), + icon: Icon( + usuario.isActive + ? Icons.person_off_outlined + : Icons.person_outlined, + size: 16, + ), + value: 'toggle', + ), + if (canResetPassword) + MenuItem( + label: const Text('Cambiar contraseña'), + icon: const Icon(Icons.lock_reset, size: 16), + value: 'password', + ), + if (canDelete) ...[ + const MenuDivider(), + MenuItem( + label: const Text( + 'Eliminar', + style: TextStyle(color: SomaColors.error), + ), + icon: const Icon(Icons.delete_outline, size: 16, color: SomaColors.error), + value: 'delete', + ), + ], + ], + ); + } + + void _handleContextAction(String? value) { + switch (value) { + case 'pago': onRegistrarPago(); + case 'rutina': onAsignarRutina(); + case 'historial': onVerHistorial(); + case 'plan': onEditarPlan(); + case 'edit': onEdit(); + case 'toggle': onToggleStatus(); + case 'password': onResetPassword?.call(); + case 'delete': onDelete(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isWide = MediaQuery.of(context).size.width >= 800; + final isStaff = usuario.rol != 'cliente'; + final stripe = _stripeColor(theme); + + // Último pago — solo si tiene plan asignado + Widget ultimoPagoRow = const SizedBox.shrink(); + if (usuario.tipoCuota != null) { + final pagosAsync = ref.watch( + userPagosProvider((dni: usuario.dni, incluirAnulados: false)), + ); + ultimoPagoRow = pagosAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (pagos) { + if (pagos.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + 'Último pago: ${pagos.first.mesPagadoDisplay}', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ); + }, + ); + } + + final card = ContextMenuRegion( + contextMenu: _buildContextMenu(), + onItemSelected: _handleContextAction, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Rail de estado — franja izquierda semántica + Container(width: 4, color: stripe), + + // Contenido principal + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 8, 11), + child: Row( + children: [ + // Avatar — ring amarillo para staff + Container( + padding: isStaff + ? const EdgeInsets.all(2) + : EdgeInsets.zero, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isStaff + ? SomaColors.primary + : Colors.transparent, + ), + child: CircleAvatar( + radius: AppConstants.kAvatarRadiusMd, + backgroundColor: usuario.isActive + ? SomaColors.primary.withAlpha(40) + : theme.colorScheme.surfaceContainerHighest, + child: Text( + usuario.initials, + style: TextStyle( + color: usuario.isActive + ? (isStaff + ? SomaColors.onPrimary + : theme.colorScheme.onSurface) + : theme.colorScheme.onSurface.withAlpha(100), + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 14), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + usuario.displayName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: usuario.isActive + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withAlpha(100), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _RolBadge(rol: usuario.rol), + if (deuda != null) ...[ + const SizedBox(width: 6), + _DeudaBadge(monto: deuda!), + ], + ], + ), + const SizedBox(height: 3), + Row( + children: [ + // Indicador activo/inactivo inline + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: usuario.isActive + ? SomaColors.success + : SomaColors.error, + ), + ), + Text( + 'DNI: ${usuario.dni}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + ), + if (usuario.mail != null && + usuario.mail!.isNotEmpty) ...[ + Text( + ' • ', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(80), + ), + ), + Expanded( + child: Text( + usuario.mail!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ultimoPagoRow, + ], + ), + ), + + const SizedBox(width: 6), + + // Quick actions con fondo sutil (solo en pantallas anchas) + if (isWide) ...[ + _QuickActionButton( + icon: Icons.payment, + tooltip: 'Registrar pago', + onPressed: onRegistrarPago, + ), + const SizedBox(width: 4), + _QuickActionButton( + icon: Icons.fitness_center, + tooltip: 'Asignar rutina', + onPressed: onAsignarRutina, + ), + const SizedBox(width: 4), + _QuickActionButton( + icon: Icons.history, + tooltip: 'Ver historial', + onPressed: onVerHistorial, + ), + const SizedBox(width: 4), + _QuickActionButton( + icon: Icons.card_membership, + tooltip: 'Editar plan', + onPressed: onEditarPlan, + ), + const SizedBox(width: 4), + ], + + // Menú de acciones + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 20, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + onSelected: (value) { + switch (value) { + case 'edit': + onEdit(); + case 'toggle': + onToggleStatus(); + case 'password': + onResetPassword?.call(); + case 'delete': + onDelete(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 10), + Text('Editar', + style: TextStyle(fontSize: 13)), + ], + ), + ), + PopupMenuItem( + value: 'toggle', + child: Row( + children: [ + Icon( + usuario.isActive + ? Icons.person_off_outlined + : Icons.person_outlined, + size: 18, + ), + const SizedBox(width: 10), + Text( + usuario.isActive ? 'Desactivar' : 'Activar', + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + if (canResetPassword) + const PopupMenuItem( + value: 'password', + child: Row( + children: [ + Icon(Icons.lock_reset, size: 18), + SizedBox(width: 10), + Text('Cambiar contraseña', + style: TextStyle(fontSize: 13)), + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, + size: 18, color: SomaColors.error), + SizedBox(width: 10), + Text('Eliminar', + style: TextStyle( + fontSize: 13, + color: SomaColors.error)), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // Usuarios inactivos se atenúan visualmente + if (!usuario.isActive) { + return Opacity(opacity: 0.58, child: card); + } + return card; + } +} + +class _QuickActionButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + const _QuickActionButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return IconButton( + icon: Icon(icon, size: 17), + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest.withAlpha(180), + foregroundColor: theme.colorScheme.onSurface.withAlpha(160), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } +} + +class _RolBadge extends StatelessWidget { + final String rol; + + const _RolBadge({required this.rol}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isStaff = + rol == 'superadmin' || rol == 'admin' || rol == 'profesor'; + + final label = switch (rol) { + 'superadmin' || 'admin' || 'profesor' => 'Admin', + 'cliente' => 'Cliente', + _ => rol, + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isStaff + ? SomaColors.primary.withAlpha(25) + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(5), + border: isStaff + ? Border.all(color: SomaColors.primary.withAlpha(60), width: 0.5) + : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isStaff + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ); + } +} + +class _DeudaBadge extends StatelessWidget { + final double monto; + + const _DeudaBadge({required this.monto}); + + @override + Widget build(BuildContext context) { + final alDia = monto <= 0; + final color = alDia ? SomaColors.success : SomaColors.error; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withAlpha(60), width: 0.5), + ), + child: Text( + alDia + ? 'Al día' + : 'Debe \$${monto.toStringAsFixed(monto.truncateToDouble() == monto ? 0 : 2)}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_detail_dialog.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_detail_dialog.dart new file mode 100644 index 0000000..3fa8cd0 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_detail_dialog.dart @@ -0,0 +1,712 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart'; +import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart'; +import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +/// Retorna 'edit' si el usuario quiere editar. +class UsuarioDetailDialog extends ConsumerWidget { + final Usuario usuario; + + const UsuarioDetailDialog({super.key, required this.usuario}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull ?? []; + final pagosAsync = ref.watch( + userPagosProvider((dni: usuario.dni, incluirAnulados: false)), + ); + final deudaMap = ref.watch(usuariosDeudaProvider).valueOrNull ?? {}; + final width = MediaQuery.of(context).size.width; + final isWide = width >= AppConstants.kDesktopBreakpoint; + final theme = Theme.of(context); + + final tipoCuota = usuario.tipoCuota != null + ? tiposCuota + .where((t) => t.id == usuario.tipoCuota) + .firstOrNull + : null; + + final deuda = (usuario.tipoCuota != null && usuario.isActive) + ? (deudaMap[usuario.dni] ?? 0.0) + : null; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 500) / 2 : 16, + vertical: 24, + ), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header con cerrar + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 12, 0), + child: Row( + children: [ + const Text( + 'Detalle de usuario', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 20), + + // Body scrollable + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + nombre + rol + _buildHeader(theme), + const SizedBox(height: 20), + + // Info personal + _sectionTitle('Información'), + const SizedBox(height: 8), + _infoCard(theme, [ + _infoRow(Icons.badge_outlined, 'DNI', + usuario.dni), + if (usuario.mail != null && + usuario.mail!.isNotEmpty) + _infoRow(Icons.email_outlined, 'Email', + usuario.mail!), + if (usuario.telefono != null && + usuario.telefono!.isNotEmpty) + _infoRow(Icons.phone_outlined, 'Teléfono', + usuario.telefono!), + if (usuario.sexo != null && + usuario.sexo!.isNotEmpty) + _infoRow( + Icons.person_outline, + 'Sexo', + usuario.sexo == 'M' + ? 'Masculino' + : usuario.sexo == 'F' + ? 'Femenino' + : usuario.sexo!), + if (usuario.fechaCreacion != null) + _infoRow( + Icons.calendar_today_outlined, + 'Miembro desde', + _fmtDate(usuario.fechaCreacion!)), + ]), + + // Medidas + if (usuario.peso != null || + usuario.altura != null || + usuario.fuerzaMax != null) ...[ + const SizedBox(height: 16), + _sectionTitle('Medidas'), + const SizedBox(height: 8), + _buildMedidas(theme), + ], + + // Plan + const SizedBox(height: 16), + _sectionTitle('Plan'), + const SizedBox(height: 8), + tipoCuota != null + ? _buildPlan(theme, tipoCuota) + : _emptyCard( + theme, 'Sin plan asignado'), + + // Estado de cuenta + if (deuda != null) ...[ + const SizedBox(height: 16), + _sectionTitle('Estado de cuenta'), + const SizedBox(height: 8), + _buildEstadoCuenta( + theme, deuda, pagosAsync), + ], + + // Últimos pagos + const SizedBox(height: 16), + _sectionTitle('Últimos pagos'), + const SizedBox(height: 8), + pagosAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ), + ), + error: (_, _) => _emptyCard( + theme, 'Error cargando pagos'), + data: (pagos) => pagos.isEmpty + ? _emptyCard( + theme, 'Sin pagos registrados') + : _buildPagosList(theme, pagos), + ), + ], + ), + ), + ), + + const Divider(height: 1), + + // Acciones + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cerrar', + style: TextStyle( + color: theme.colorScheme.onSurface + .withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: () => + Navigator.of(context).pop('edit'), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // ─── Secciones ────────────────────────────────────────── + + Widget _buildHeader(ThemeData theme) { + final isStaff = usuario.rol != 'cliente'; + final statusColor = + usuario.isActive ? SomaColors.success : SomaColors.error; + + return Row( + children: [ + // Avatar con ring para staff activos + Container( + padding: isStaff ? const EdgeInsets.all(2.5) : EdgeInsets.zero, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isStaff ? SomaColors.primary : Colors.transparent, + ), + child: CircleAvatar( + radius: AppConstants.kAvatarRadiusLg, + backgroundColor: usuario.isActive + ? SomaColors.primary.withAlpha(40) + : theme.colorScheme.surfaceContainerHighest, + child: Text( + usuario.initials, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: usuario.isActive + ? (isStaff + ? SomaColors.onPrimary + : theme.colorScheme.onSurface) + : theme.colorScheme.onSurface.withAlpha(100), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + usuario.displayName, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Row( + children: [ + // Rol badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isStaff + ? SomaColors.primary.withAlpha(25) + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(5), + border: isStaff + ? Border.all( + color: SomaColors.primary.withAlpha(60), + width: 0.5) + : null, + ), + child: Text( + usuario.rolDisplay, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isStaff + ? SomaColors.primaryText + : theme.colorScheme.onSurface.withAlpha(178), + ), + ), + ), + const SizedBox(width: 8), + // Estado pill + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withAlpha(18), + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: statusColor.withAlpha(60), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + ), + ), + const SizedBox(width: 5), + Text( + usuario.isActive ? 'Activo' : 'Inactivo', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildMedidas(ThemeData theme) { + final items = <(String, String)>[]; + if (usuario.peso != null) { + items.add(('Peso', + '${usuario.peso!.toStringAsFixed(usuario.peso!.truncateToDouble() == usuario.peso! ? 0 : 1)} kg')); + } + if (usuario.altura != null) { + items.add(('Altura', '${usuario.altura} cm')); + } + if (usuario.fuerzaMax != null) { + items.add(('Fuerza máx', usuario.fuerzaMax!.toStringAsFixed(0))); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: SomaColors.primary.withAlpha(8), + border: Border.all( + color: SomaColors.primary.withAlpha(40), + width: 0.5, + ), + ), + child: IntrinsicHeight( + child: Row( + children: [ + for (int i = 0; i < items.length; i++) ...[ + if (i > 0) + Container( + width: 1, + color: theme.colorScheme.surfaceContainerHighest, + margin: const EdgeInsets.symmetric(horizontal: 8), + ), + Expanded(child: _medidaItem(theme, items[i].$1, items[i].$2)), + ], + ], + ), + ), + ); + } + + Widget _medidaItem(ThemeData theme, String label, String value) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ); + } + + Widget _buildPlan(ThemeData theme, TipoCuota tc) { + return _cardContainer( + theme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tc.nombre, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600), + ), + if (tc.descripcion != null && + tc.descripcion!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + tc.descripcion!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + const SizedBox(height: 8), + Row( + children: [ + _planChip(theme, tc.precioDisplay), + const SizedBox(width: 8), + _planChip(theme, tc.diasDisplay), + const SizedBox(width: 8), + _planChip(theme, 'Vto. día ${tc.diaDePago}'), + ], + ), + if (tc.recargo != null && tc.recargo! > 0) ...[ + const SizedBox(height: 6), + Text( + 'Recargo: \$${tc.recargo!.toStringAsFixed(tc.recargo!.truncateToDouble() == tc.recargo! ? 0 : 2)}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ], + ), + ); + } + + Widget _planChip(ThemeData theme, String text) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ); + } + + Widget _buildEstadoCuenta( + ThemeData theme, + double deuda, + AsyncValue> pagosAsync, + ) { + final alDia = deuda <= 0; + final statusColor = alDia ? SomaColors.success : SomaColors.error; + final ultimoPago = pagosAsync.valueOrNull?.firstOrNull; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: statusColor.withAlpha(alDia ? 14 : 18), + border: Border.all( + color: statusColor.withAlpha(60), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + ), + ), + const SizedBox(width: 8), + Text( + alDia + ? 'Al día' + : 'Debe \$${deuda.toStringAsFixed(deuda.truncateToDouble() == deuda ? 0 : 2)}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: statusColor, + ), + ), + ], + ), + if (ultimoPago != null) ...[ + const SizedBox(height: 10), + Divider(height: 1, color: statusColor.withAlpha(40)), + const SizedBox(height: 10), + Text( + 'ÚLTIMO PAGO', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: statusColor.withAlpha(160), + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + ultimoPago.mesPagadoDisplay, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 8), + Text( + '\$${ultimoPago.montoTotal.toStringAsFixed(ultimoPago.montoTotal.truncateToDouble() == ultimoPago.montoTotal ? 0 : 2)}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: statusColor), + ), + const SizedBox(width: 8), + Text( + ultimoPago.metodo, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + const Spacer(), + Text( + ultimoPago.fechaPagoDisplay, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildPagosList(ThemeData theme, List pagos) { + final show = pagos.take(5).toList(); + return _cardContainer( + theme, + child: Column( + children: [ + for (int i = 0; i < show.length; i++) ...[ + if (i > 0) + Divider( + height: 16, + color: + theme.colorScheme.surfaceContainerHighest, + ), + Row( + children: [ + Expanded( + child: Text( + show[i].mesPagadoDisplay, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500), + ), + ), + Text( + _fmtMonto(show[i].montoTotal), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600), + ), + const SizedBox(width: 12), + SizedBox( + width: 70, + child: Text( + show[i].metodo, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface + .withAlpha(130), + ), + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ); + } + + // ─── Helpers ────────────────────────────────────────── + + Widget _sectionTitle(String title) { + return Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + title.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: SomaColors.primary, + letterSpacing: 0.8, + ), + ), + ], + ); + } + + Widget _cardContainer(ThemeData theme, {required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + width: 0.5, + ), + ), + child: child, + ); + } + + Widget _emptyCard(ThemeData theme, String text) { + return _cardContainer( + theme, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + ), + ), + ); + } + + Widget _infoCard(ThemeData theme, List rows) { + return _cardContainer( + theme, + child: Column(children: rows), + ); + } + + Widget _infoRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 18, color: SomaColors.primary.withAlpha(150)), + const SizedBox(width: 10), + SizedBox( + width: 100, + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF999999), + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } + + String _fmtMonto(double n) => + '\$${n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2)}'; + + String _fmtDate(DateTime d) { + const meses = [ + '', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', + 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', + ]; + return '${meses[d.month]} ${d.year}'; + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_form_dialog.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_form_dialog.dart new file mode 100644 index 0000000..0548bb0 --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuario_form_dialog.dart @@ -0,0 +1,446 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/core/widgets/soma_text_field.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +/// Dialog para crear o editar un usuario. +/// Retorna un `Map` con los datos si el usuario confirma, o null si cancela. +class UsuarioFormDialog extends StatefulWidget { + final Usuario? usuario; // null = crear, non-null = editar + final Set existingDnis; + final bool actorIsSuperadmin; + + const UsuarioFormDialog({ + super.key, + this.usuario, + this.existingDnis = const {}, + this.actorIsSuperadmin = false, + }); + + @override + State createState() => _UsuarioFormDialogState(); +} + +class _UsuarioFormDialogState extends State { + final _formKey = GlobalKey(); + + late final TextEditingController _dniCtrl; + late final TextEditingController _nombreCtrl; + late final TextEditingController _apellidoCtrl; + late final TextEditingController _mailCtrl; + late final TextEditingController _telefonoCtrl; + late final TextEditingController _passwordCtrl; + late final TextEditingController _pesoCtrl; + late final TextEditingController _alturaCtrl; + late final TextEditingController _fuerzaMaxCtrl; + late String _rol; + late String _sexo; + bool _obscurePassword = true; + + bool get isEditing => widget.usuario != null; + + bool get _showPasswordField => + _rol != 'cliente' && (!isEditing || widget.actorIsSuperadmin); + + @override + void initState() { + super.initState(); + final u = widget.usuario; + _dniCtrl = TextEditingController(text: u?.dni ?? ''); + _nombreCtrl = TextEditingController(text: u?.nombre ?? ''); + _apellidoCtrl = TextEditingController(text: u?.apellido ?? ''); + _mailCtrl = TextEditingController(text: u?.mail ?? ''); + _telefonoCtrl = TextEditingController(text: u?.telefono ?? ''); + _passwordCtrl = TextEditingController(); + _pesoCtrl = TextEditingController( + text: u?.peso != null ? u!.peso!.toString() : '', + ); + _alturaCtrl = TextEditingController( + text: u?.altura != null ? u!.altura!.toString() : '', + ); + _fuerzaMaxCtrl = TextEditingController( + text: u?.fuerzaMax != null ? u!.fuerzaMax!.toString() : '', + ); + _rol = u?.rol ?? 'cliente'; + _sexo = u?.sexo ?? 'Hombre'; + } + + @override + void dispose() { + _dniCtrl.dispose(); + _nombreCtrl.dispose(); + _apellidoCtrl.dispose(); + _mailCtrl.dispose(); + _telefonoCtrl.dispose(); + _passwordCtrl.dispose(); + _pesoCtrl.dispose(); + _alturaCtrl.dispose(); + _fuerzaMaxCtrl.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + + if (isEditing) { + final data = { + 'dni': _dniCtrl.text.trim(), + 'nombre': _nombreCtrl.text.trim(), + }; + if (_apellidoCtrl.text.trim().isNotEmpty) { + data['apellido'] = _apellidoCtrl.text.trim(); + } + if (_mailCtrl.text.trim().isNotEmpty) { + data['mail'] = _mailCtrl.text.trim(); + } + if (_telefonoCtrl.text.trim().isNotEmpty) { + data['telefono'] = _telefonoCtrl.text.trim(); + } + data['rol'] = _rol; + data['sexo'] = _sexo; + if (_pesoCtrl.text.trim().isNotEmpty) { + data['peso'] = double.tryParse(_pesoCtrl.text.trim()); + } + if (_alturaCtrl.text.trim().isNotEmpty) { + data['altura'] = int.tryParse(_alturaCtrl.text.trim()); + } + if (_fuerzaMaxCtrl.text.trim().isNotEmpty) { + data['fuerza_max'] = double.tryParse(_fuerzaMaxCtrl.text.trim()); + } + if (_showPasswordField && _passwordCtrl.text.trim().isNotEmpty) { + data['password'] = _passwordCtrl.text.trim(); + } + Navigator.of(context).pop(data); + } else { + // Crear + final usuario = Usuario( + id: '', + dni: _dniCtrl.text.trim(), + nombre: _nombreCtrl.text.trim(), + apellido: _apellidoCtrl.text.trim().isEmpty + ? null + : _apellidoCtrl.text.trim(), + mail: _mailCtrl.text.trim().isEmpty ? null : _mailCtrl.text.trim(), + telefono: _telefonoCtrl.text.trim().isEmpty + ? null + : _telefonoCtrl.text.trim(), + rol: _rol, + sexo: _sexo, + peso: double.tryParse(_pesoCtrl.text.trim()), + altura: int.tryParse(_alturaCtrl.text.trim()), + fuerzaMax: double.tryParse(_fuerzaMaxCtrl.text.trim()), + ); + Navigator.of(context) + .pop(usuario.toInsertMap(_passwordCtrl.text.trim())); + } + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isWide = width >= 600; + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isWide ? (width - 520) / 2 : 20, + vertical: 24, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row( + children: [ + Text( + isEditing ? 'Editar Usuario' : 'Nuevo Usuario', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + const Divider(height: 20), + + // Form + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Información básica'), + const SizedBox(height: 8), + SomaTextField( + controller: _dniCtrl, + labelText: 'DNI *', + prefixIcon: Icons.badge_outlined, + enabled: !isEditing, + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'DNI requerido'; + } + if (!isEditing && + widget.existingDnis.contains(v.trim())) { + return 'Ya existe un usuario con ese DNI'; + } + return null; + }, + ), + const SizedBox(height: 12), + SomaTextField( + controller: _nombreCtrl, + labelText: 'Nombre *', + prefixIcon: Icons.person_outline, + validator: (v) => v == null || v.trim().isEmpty + ? 'Nombre requerido' + : null, + ), + const SizedBox(height: 12), + SomaTextField( + controller: _apellidoCtrl, + labelText: 'Apellido', + prefixIcon: Icons.person_outline, + ), + const SizedBox(height: 20), + + _sectionLabel('Contacto'), + const SizedBox(height: 8), + SomaTextField( + controller: _mailCtrl, + labelText: 'Email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + SomaTextField( + controller: _telefonoCtrl, + labelText: 'Teléfono', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 20), + + _sectionLabel('Acceso'), + const SizedBox(height: 8), + // El campo de contraseña es dinámico según el rol + // elegido: los clientes no tienen contraseña en este + // panel (usan el bot de WhatsApp). En edición, solo el + // superadmin puede ver/tocar la contraseña de otro + // usuario. + if (_showPasswordField) ...[ + SomaTextField( + controller: _passwordCtrl, + labelText: isEditing + ? 'Nueva contraseña (opcional)' + : 'Contraseña *', + prefixIcon: Icons.lock_outline, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(130), + size: 20, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), + validator: (v) { + final value = v?.trim() ?? ''; + if (!isEditing && value.isEmpty) { + return 'Contraseña requerida'; + } + if (value.isNotEmpty && value.length < 8) { + return 'Mínimo 8 caracteres'; + } + return null; + }, + ), + const SizedBox(height: 12), + ], + _dropdownField( + label: 'Rol', + value: _rol, + items: const [ + DropdownMenuItem( + value: 'cliente', + child: Text('Cliente'), + ), + DropdownMenuItem( + value: 'admin', + child: Text('Admin'), + ), + DropdownMenuItem( + value: 'superadmin', + child: Text('Super Admin'), + ), + ], + onChanged: (v) => setState(() => _rol = v!), + ), + const SizedBox(height: 20), + + _sectionLabel('Datos físicos'), + const SizedBox(height: 8), + _dropdownField( + label: 'Sexo', + value: _sexo, + items: const [ + DropdownMenuItem( + value: 'Hombre', + child: Text('Hombre'), + ), + DropdownMenuItem( + value: 'Mujer', + child: Text('Mujer'), + ), + DropdownMenuItem( + value: 'Otro', + child: Text('Otro'), + ), + ], + onChanged: (v) => setState(() => _sexo = v!), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SomaTextField( + controller: _pesoCtrl, + labelText: 'Peso (kg)', + prefixIcon: Icons.monitor_weight_outlined, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d{0,3}\.?\d{0,2}'), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: SomaTextField( + controller: _alturaCtrl, + labelText: 'Altura (cm)', + prefixIcon: Icons.height, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + ), + ], + ), + const SizedBox(height: 12), + SomaTextField( + controller: _fuerzaMaxCtrl, + labelText: 'Fuerza máx. (kg)', + prefixIcon: Icons.fitness_center_outlined, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d{0,7}\.?\d{0,2}'), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + + const Divider(height: 1), + + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancelar', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(178), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 42), + ), + child: Text(isEditing ? 'Guardar' : 'Crear'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _sectionLabel(String text) { + return Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + letterSpacing: 0.5, + ), + ); + } + + Widget _dropdownField({ + required String label, + required T value, + required List> items, + required ValueChanged onChanged, + }) { + return DropdownButtonFormField( + initialValue: value, + items: items, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_overview.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_overview.dart new file mode 100644 index 0000000..026103c --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_overview.dart @@ -0,0 +1,981 @@ +import 'dart:ui'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_constants.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; +import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart'; + +class UsuariosOverview extends ConsumerWidget { + final VoidCallback? onVerTodos; + + const UsuariosOverview({super.key, this.onVerTodos}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usuariosAsync = ref.watch(usuariosProvider); + final deudaAsync = ref.watch(usuariosDeudaProvider); + final isWide = + MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint; + + return usuariosAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SomaColors.primary), + ), + error: (e, _) => Center( + child: Text( + e.toString().replaceFirst('Exception: ', ''), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withAlpha(153), + ), + ), + ), + data: (usuarios) { + final deudaMap = deudaAsync.valueOrNull ?? {}; + return _OverviewContent( + usuarios: usuarios, + deudaMap: deudaMap, + isWide: isWide, + onVerTodos: onVerTodos, + ); + }, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Content +// ───────────────────────────────────────────────────────────────────────────── + +class _OverviewContent extends StatelessWidget { + final List usuarios; + final Map deudaMap; + final bool isWide; + final VoidCallback? onVerTodos; + + const _OverviewContent({ + required this.usuarios, + required this.deudaMap, + required this.isWide, + this.onVerTodos, + }); + + static const _kInactive = Color(0xFF9E9E9E); + static const _kSinPlan = Color(0xFFBDBDBD); + static const _kStaff = Color(0xFF42A5F5); + + @override + Widget build(BuildContext context) { + final hPad = isWide ? 32.0 : 16.0; + + // ── Metrics ────────────────────────────────────────────────────────────── + final total = usuarios.length; + final activos = usuarios.where((u) => u.isActive).length; + final inactivos = total - activos; + final conPlan = usuarios + .where((u) => u.isActive && u.tipoCuota != null) + .length; + final sinPlan = activos - conPlan; + final staff = usuarios + .where( + (u) => + u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor', + ) + .length; + final clientes = usuarios.where((u) => u.rol == 'cliente').length; + final alDia = deudaMap.values.where((v) => v <= 0).length; + final debe = deudaMap.values.where((v) => v > 0).length; + + final ultimos = + ([...usuarios]..sort((a, b) { + if (a.fechaCreacion == null && b.fechaCreacion == null) return 0; + if (a.fechaCreacion == null) return 1; + if (b.fechaCreacion == null) return -1; + return b.fechaCreacion!.compareTo(a.fechaCreacion!); + })) + .take(5) + .toList(); + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(hPad, 12, hPad, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stat cards + _StatGrid( + total: total, + activos: activos, + conPlan: conPlan, + alDia: alDia, + isWide: isWide, + ), + const SizedBox(height: 14), + + // Membership pulse + _MembershipPulse( + alDia: alDia, + debe: debe, + sinPlan: sinPlan, + inactivos: inactivos, + total: total, + ), + const SizedBox(height: 14), + + // Donut charts + isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _DonutCard( + title: 'Membresía', + sections: _membershipSections(activos, inactivos), + legend: [ + _LegendItem('Activos', SomaColors.success, activos), + _LegendItem('Inactivos', _kInactive, inactivos), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DonutCard( + title: 'Pagos del mes', + sections: _pageSections(alDia, debe, sinPlan), + legend: [ + _LegendItem('Al día', SomaColors.success, alDia), + _LegendItem('Debe', SomaColors.error, debe), + _LegendItem('Sin plan', _kSinPlan, sinPlan), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DonutCard( + title: 'Roles', + sections: _rolesSections(clientes, staff), + legend: [ + _LegendItem('Clientes', SomaColors.primary, clientes), + _LegendItem('Staff', _kStaff, staff), + ], + ), + ), + ], + ) + : Column( + children: [ + _DonutCard( + title: 'Membresía', + sections: _membershipSections(activos, inactivos), + legend: [ + _LegendItem('Activos', SomaColors.success, activos), + _LegendItem('Inactivos', _kInactive, inactivos), + ], + ), + const SizedBox(height: 12), + _DonutCard( + title: 'Pagos del mes', + sections: _pageSections(alDia, debe, sinPlan), + legend: [ + _LegendItem('Al día', SomaColors.success, alDia), + _LegendItem('Debe', SomaColors.error, debe), + _LegendItem('Sin plan', _kSinPlan, sinPlan), + ], + ), + const SizedBox(height: 12), + _DonutCard( + title: 'Roles', + sections: _rolesSections(clientes, staff), + legend: [ + _LegendItem('Clientes', SomaColors.primary, clientes), + _LegendItem('Staff', _kStaff, staff), + ], + ), + ], + ), + + // Recent users + if (ultimos.isNotEmpty) ...[ + const SizedBox(height: 14), + Row( + children: [ + const _SectionLabel('Últimos ingresados'), + const Spacer(), + if (onVerTodos != null) + TextButton( + onPressed: onVerTodos, + style: TextButton.styleFrom( + foregroundColor: SomaColors.primary, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Ver todos'), + SizedBox(width: 4), + Icon(Icons.arrow_forward, size: 14), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + ...ultimos.map( + (u) => _RecentUserStub(usuario: u, deuda: deudaMap[u.dni]), + ), + ], + ], + ), + ); + } + + List _membershipSections(int activos, int inactivos) { + final total = activos + inactivos; + if (total == 0) { + return [ + PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30), + ]; + } + return [ + if (activos > 0) + PieChartSectionData( + color: SomaColors.success, + value: activos.toDouble(), + title: '', + radius: 30, + ), + if (inactivos > 0) + PieChartSectionData( + color: _kInactive, + value: inactivos.toDouble(), + title: '', + radius: 30, + ), + ]; + } + + List _rolesSections(int clientes, int staff) { + final total = clientes + staff; + if (total == 0) { + return [ + PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30), + ]; + } + return [ + if (clientes > 0) + PieChartSectionData( + color: SomaColors.primary, + value: clientes.toDouble(), + title: '', + radius: 30, + ), + if (staff > 0) + PieChartSectionData( + color: _kStaff, + value: staff.toDouble(), + title: '', + radius: 30, + ), + ]; + } + + List _pageSections(int alDia, int debe, int sinPlan) { + final total = alDia + debe + sinPlan; + if (total == 0) { + return [ + PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30), + ]; + } + return [ + if (alDia > 0) + PieChartSectionData( + color: SomaColors.success, + value: alDia.toDouble(), + title: '', + radius: 30, + ), + if (debe > 0) + PieChartSectionData( + color: SomaColors.error, + value: debe.toDouble(), + title: '', + radius: 30, + ), + if (sinPlan > 0) + PieChartSectionData( + color: _kSinPlan, + value: sinPlan.toDouble(), + title: '', + radius: 30, + ), + ]; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Glass card container +// ───────────────────────────────────────────────────────────────────────────── + +class _GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsets padding; + final double radius; + + const _GlassCard({ + required this.child, + this.padding = const EdgeInsets.all(16), + this.radius = 20, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + color: Colors.white.withAlpha(18), + border: Border.all( + color: Colors.white.withAlpha(38), + width: 0.8, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(55), + blurRadius: 24, + spreadRadius: -4, + offset: const Offset(0, 6), + ), + ], + ), + child: child, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stat grid + card +// ───────────────────────────────────────────────────────────────────────────── + +class _StatGrid extends StatelessWidget { + final int total; + final int activos; + final int conPlan; + final int alDia; + final bool isWide; + + const _StatGrid({ + required this.total, + required this.activos, + required this.conPlan, + required this.alDia, + required this.isWide, + }); + + @override + Widget build(BuildContext context) { + final items = [ + (Icons.groups_outlined, 'Total', total, SomaColors.primary), + (Icons.how_to_reg_outlined, 'Activos', activos, SomaColors.success), + ( + Icons.card_membership_outlined, + 'Con plan', + conPlan, + const Color(0xFF42A5F5), + ), + (Icons.check_circle_outline, 'Al día', alDia, SomaColors.success), + ]; + + if (isWide) { + return Row( + children: [ + for (int i = 0; i < items.length; i++) ...[ + Expanded( + child: _StatCard( + icon: items[i].$1, + label: items[i].$2, + value: items[i].$3, + color: items[i].$4, + ), + ), + if (i < items.length - 1) const SizedBox(width: 10), + ], + ], + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded( + child: _StatCard( + icon: items[0].$1, + label: items[0].$2, + value: items[0].$3, + color: items[0].$4, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + icon: items[1].$1, + label: items[1].$2, + value: items[1].$3, + color: items[1].$4, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _StatCard( + icon: items[2].$1, + label: items[2].$2, + value: items[2].$3, + color: items[2].$4, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + icon: items[3].$1, + label: items[3].$2, + value: items[3].$3, + color: items[3].$4, + ), + ), + ], + ), + ], + ); + } +} + +class _StatCard extends StatelessWidget { + final IconData icon; + final String label; + final int value; + final Color color; + + const _StatCard({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _GlassCard( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(height: 10), + Text( + '$value', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: color, + height: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Membership pulse +// ───────────────────────────────────────────────────────────────────────────── + +class _MembershipPulse extends StatelessWidget { + final int alDia; + final int debe; + final int sinPlan; + final int inactivos; + final int total; + + const _MembershipPulse({ + required this.alDia, + required this.debe, + required this.sinPlan, + required this.inactivos, + required this.total, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final segments = <({Color color, int count, String label})>[ + (color: SomaColors.success, count: alDia, label: 'Al día'), + (color: SomaColors.error, count: debe, label: 'Debe'), + ( + color: SomaColors.primary.withAlpha(130), + count: sinPlan, + label: 'Sin plan', + ), + ( + color: theme.colorScheme.onSurface.withAlpha(45), + count: inactivos, + label: 'Inactivos', + ), + ].where((s) => s.count > 0).toList(); + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Pulso de membresía', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: SomaColors.primary.withAlpha(25), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: SomaColors.primary.withAlpha(50), + width: 0.5, + ), + ), + child: Text( + '$total miembros', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: SomaColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + // Proportional strip + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 18, + child: total == 0 + ? Container(color: theme.colorScheme.surfaceContainerHighest) + : Row( + children: [ + for (int i = 0; i < segments.length; i++) ...[ + if (i > 0) const SizedBox(width: 2), + Expanded( + flex: segments[i].count, + child: Container(color: segments[i].color), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 14, + runSpacing: 6, + children: segments.map((s) { + final pct = total > 0 ? (s.count / total * 100).round() : 0; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: s.color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 5), + Text( + '${s.label} $pct%', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ); + }).toList(), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Donut card — column layout fixes overflow +// ───────────────────────────────────────────────────────────────────────────── + +class _LegendItem { + final String label; + final Color color; + final int count; + const _LegendItem(this.label, this.color, this.count); +} + +class _DonutCard extends StatelessWidget { + final String title; + final List sections; + final List<_LegendItem> legend; + + const _DonutCard({ + required this.title, + required this.sections, + required this.legend, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final total = legend.fold(0, (s, item) => s + item.count); + final leadColor = legend.isNotEmpty + ? legend.first.color + : SomaColors.primary; + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title + total + Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + ), + Text( + '$total', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: leadColor, + height: 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Donut chart — constrained to avoid overflow + SizedBox( + height: 110, + child: Center( + child: AspectRatio( + aspectRatio: 1, + child: PieChart( + PieChartData( + sections: sections, + centerSpaceRadius: 32, + sectionsSpace: 3, + startDegreeOffset: -90, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + + // Legend below — no overflow possible + ...legend.map((item) { + final pct = total > 0 ? (item.count / total * 100).round() : 0; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: item.color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.label, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(160), + ), + ), + ), + Text( + '$pct%', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + const SizedBox(width: 8), + Text( + '${item.count}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: item.color, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section label +// ───────────────────────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + width: 3, + height: 14, + decoration: BoxDecoration( + color: SomaColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Recent user stub +// ───────────────────────────────────────────────────────────────────────────── + +class _RecentUserStub extends StatelessWidget { + final Usuario usuario; + final double? deuda; + + const _RecentUserStub({required this.usuario, required this.deuda}); + + String _initials() { + if (usuario.nombre.isNotEmpty) { + final ap = usuario.apellido; + if (ap != null && ap.isNotEmpty) { + return '${usuario.nombre[0]}${ap[0]}'.toUpperCase(); + } + return usuario.nombre[0].toUpperCase(); + } + if (usuario.dni.length >= 2) return usuario.dni.substring(0, 2); + return '?'; + } + + String _rolLabel() => switch (usuario.rol) { + 'superadmin' => 'Super Admin', + 'admin' => 'Admin', + 'profesor' => 'Profesor', + _ => 'Cliente', + }; + + String _fechaLabel() { + final d = usuario.fechaCreacion; + if (d == null) return ''; + return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final Color statusColor; + final String statusLabel; + + if (!usuario.isActive) { + statusColor = theme.colorScheme.onSurface.withAlpha(100); + statusLabel = 'Inactivo'; + } else if (usuario.tipoCuota == null) { + statusColor = SomaColors.primary.withAlpha(160); + statusLabel = 'Sin plan'; + } else if (deuda != null && deuda! > 0) { + statusColor = SomaColors.error; + statusLabel = 'Debe'; + } else { + statusColor = SomaColors.success; + statusLabel = 'Al día'; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + radius: 14, + child: Row( + children: [ + // Avatar with status dot + Stack( + clipBehavior: Clip.none, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: SomaColors.primary.withAlpha(35), + child: Text( + _initials(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + Positioned( + right: -1, + bottom: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + border: Border.all( + color: SomaColors.darkBackground, + width: 1.5, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + + // Name + role + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + usuario.displayName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + _rolLabel(), + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + ], + ), + ), + + // Date + if (_fechaLabel().isNotEmpty) ...[ + Text( + _fechaLabel(), + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withAlpha(110), + ), + ), + const SizedBox(width: 10), + ], + + // Status badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withAlpha(25), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: statusColor.withAlpha(60), + width: 0.5, + ), + ), + child: Text( + statusLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_table_view.dart b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_table_view.dart new file mode 100644 index 0000000..452bfef --- /dev/null +++ b/flutter_soma_app/lib/features/usuarios/presentation/widgets/usuarios_table_view.dart @@ -0,0 +1,756 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:gimnasio_soma/core/theme/soma_colors.dart'; +import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart'; + +enum SortColumn { nombre, dni, email, rol, deuda, estado, ultimoPago } + +enum TableCol { avatar, nombre, dni, email, rol, estadoPago, ultimoPago, estado } + +extension _ColProps on TableCol { + String get label => switch (this) { + TableCol.avatar => 'Avatar', + TableCol.nombre => 'Nombre', + TableCol.dni => 'DNI', + TableCol.email => 'Email', + TableCol.rol => 'Rol', + TableCol.estadoPago => 'Estado Pago', + TableCol.ultimoPago => 'Último Pago', + TableCol.estado => 'Estado', + }; + + int get flex => switch (this) { + TableCol.avatar => 0, + TableCol.nombre => 28, + TableCol.dni => 15, + TableCol.email => 23, + TableCol.rol => 13, + TableCol.estadoPago => 17, + TableCol.ultimoPago => 15, + TableCol.estado => 13, + }; + + bool get hideable => this != TableCol.nombre && this != TableCol.dni; + + SortColumn? get sort => switch (this) { + TableCol.nombre => SortColumn.nombre, + TableCol.dni => SortColumn.dni, + TableCol.email => SortColumn.email, + TableCol.rol => SortColumn.rol, + TableCol.estadoPago => SortColumn.deuda, + TableCol.ultimoPago => SortColumn.ultimoPago, + TableCol.estado => SortColumn.estado, + _ => null, + }; +} + +const double _kAvatarColW = 44.0; +const double _kToggleBtnW = 36.0; + +class UsuariosTableView extends StatefulWidget { + final List usuarios; + final Map deudaMap; + final Map ultimoPagoMap; + final void Function(Usuario) onTap; + final void Function(Usuario) onEdit; + final void Function(Usuario) onToggleStatus; + final void Function(Usuario) onDelete; + final void Function(Usuario) onRegistrarPago; + final void Function(Usuario) onAsignarRutina; + final void Function(Usuario) onVerHistorial; + final void Function(Usuario) onEditarPlan; + final bool actorIsSuperadmin; + final void Function(Usuario)? onResetPassword; + + const UsuariosTableView({ + super.key, + required this.usuarios, + required this.deudaMap, + required this.ultimoPagoMap, + required this.onTap, + required this.onEdit, + required this.onToggleStatus, + required this.onDelete, + required this.onRegistrarPago, + required this.onAsignarRutina, + required this.onVerHistorial, + required this.onEditarPlan, + this.actorIsSuperadmin = false, + this.onResetPassword, + }); + + @override + State createState() => _UsuariosTableViewState(); +} + +class _UsuariosTableViewState extends State { + SortColumn _sortColumn = SortColumn.nombre; + bool _sortAscending = true; + final _scrollCtrl = ScrollController(); + bool _scrollbarVisible = false; + final _columnBtnKey = GlobalKey(); + + final Set _visibleCols = { + TableCol.nombre, + TableCol.dni, + TableCol.rol, + TableCol.estadoPago, + TableCol.ultimoPago, + TableCol.estado, + }; + + @override + void dispose() { + _scrollCtrl.dispose(); + super.dispose(); + } + + double? _getDeuda(Usuario u) { + if (u.tipoCuota == null || !u.isActive) return null; + return widget.deudaMap[u.dni] ?? 0.0; + } + + List get _sorted { + final list = List.from(widget.usuarios); + list.sort((a, b) { + final int cmp; + switch (_sortColumn) { + case SortColumn.nombre: + cmp = a.displayName + .toLowerCase() + .compareTo(b.displayName.toLowerCase()); + case SortColumn.dni: + cmp = a.dni.compareTo(b.dni); + case SortColumn.email: + cmp = (a.mail ?? '') + .toLowerCase() + .compareTo((b.mail ?? '').toLowerCase()); + case SortColumn.rol: + cmp = a.rolDisplay.compareTo(b.rolDisplay); + case SortColumn.deuda: + cmp = (_getDeuda(a) ?? -999999) + .compareTo(_getDeuda(b) ?? -999999); + case SortColumn.ultimoPago: + final fa = widget.ultimoPagoMap[a.dni]; + final fb = widget.ultimoPagoMap[b.dni]; + if (fa == null && fb == null) { + cmp = 0; + } else if (fa == null) { + cmp = -1; + } else if (fb == null) { + cmp = 1; + } else { + cmp = fa.compareTo(fb); + } + case SortColumn.estado: + cmp = + (a.isActive ? 1 : 0).compareTo(b.isActive ? 1 : 0); + } + return _sortAscending ? cmp : -cmp; + }); + return list; + } + + void _onSort(SortColumn col) => setState(() { + if (_sortColumn == col) { + _sortAscending = !_sortAscending; + } else { + _sortColumn = col; + _sortAscending = true; + } + }); + + void _showColumnPicker() { + final ctx = _columnBtnKey.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject() as RenderBox; + final offset = box.localToGlobal(Offset.zero); + final btnSize = box.size; + final screenWidth = MediaQuery.of(context).size.width; + + showDialog( + context: context, + barrierColor: Colors.transparent, + builder: (dCtx) => StatefulBuilder( + builder: (dCtx, setLocal) => Stack( + children: [ + GestureDetector( + onTap: () => Navigator.pop(dCtx), + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + Positioned( + top: offset.dy + btnSize.height + 4, + right: screenWidth - (offset.dx + btnSize.width), + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(10), + color: Theme.of(dCtx).colorScheme.surface, + child: Container( + constraints: const BoxConstraints(minWidth: 175), + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 8), + child: Text( + 'COLUMNAS VISIBLES', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: Theme.of(dCtx) + .colorScheme + .onSurface + .withAlpha(100), + ), + ), + ), + for (final col + in TableCol.values.where((c) => c.hideable)) + InkWell( + onTap: () { + setState(() { + if (_visibleCols.contains(col)) { + _visibleCols.remove(col); + } else { + _visibleCols.add(col); + } + }); + setLocal(() {}); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 9), + child: Row( + children: [ + Icon( + _visibleCols.contains(col) + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + size: 17, + color: _visibleCols.contains(col) + ? SomaColors.primary + : Theme.of(dCtx) + .colorScheme + .onSurface + .withAlpha(100), + ), + const SizedBox(width: 10), + Text(col.label, + style: const TextStyle(fontSize: 13)), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + ContextMenu _contextMenuFor(Usuario u) => ContextMenu( + entries: [ + MenuItem( + label: const Text('Registrar pago'), + icon: const Icon(Icons.payment, size: 16), + value: 'pago', + ), + MenuItem( + label: const Text('Asignar rutina'), + icon: const Icon(Icons.fitness_center, size: 16), + value: 'rutina', + ), + MenuItem( + label: const Text('Ver historial'), + icon: const Icon(Icons.history, size: 16), + value: 'historial', + ), + MenuItem( + label: const Text('Editar plan'), + icon: const Icon(Icons.card_membership, size: 16), + value: 'plan', + ), + const MenuDivider(), + MenuItem( + label: const Text('Editar'), + icon: const Icon(Icons.edit_outlined, size: 16), + value: 'edit', + ), + MenuItem( + label: Text(u.isActive ? 'Desactivar' : 'Activar'), + icon: Icon( + u.isActive + ? Icons.person_off_outlined + : Icons.person_outlined, + size: 16, + ), + value: 'toggle', + ), + if (widget.actorIsSuperadmin && u.rol != 'cliente') + MenuItem( + label: const Text('Cambiar contraseña'), + icon: const Icon(Icons.lock_reset, size: 16), + value: 'password', + ), + if (widget.actorIsSuperadmin) + MenuItem( + label: const Text('Eliminar', + style: TextStyle(color: SomaColors.error)), + icon: const Icon(Icons.delete_outline, + size: 16, color: SomaColors.error), + value: 'delete', + ), + ], + ); + + void _handleAction(String? value, Usuario u) { + if (value == null) return; + switch (value) { + case 'pago': + widget.onRegistrarPago(u); + case 'rutina': + widget.onAsignarRutina(u); + case 'historial': + widget.onVerHistorial(u); + case 'plan': + widget.onEditarPlan(u); + case 'edit': + widget.onEdit(u); + case 'toggle': + widget.onToggleStatus(u); + case 'password': + widget.onResetPassword?.call(u); + case 'delete': + widget.onDelete(u); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final sorted = _sorted; + + if (sorted.isEmpty) { + return const Center(child: Text('No hay usuarios para mostrar')); + } + + final flexCols = TableCol.values + .where((c) => c != TableCol.avatar && _visibleCols.contains(c)) + .toList(); + + return Container( + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest.withAlpha(70), + ), + borderRadius: BorderRadius.circular(14), + ), + clipBehavior: Clip.hardEdge, + child: Column( + children: [ + // ── Header ─────────────────────────────────────────────────── + Container( + decoration: BoxDecoration( + color: + theme.colorScheme.surfaceContainerHighest.withAlpha(50), + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHighest + .withAlpha(80), + ), + ), + ), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row( + children: [ + if (_visibleCols.contains(TableCol.avatar)) + const SizedBox(width: _kAvatarColW), + for (final col in flexCols) + Expanded( + flex: col.flex, + child: _HeaderCell( + col: col, + sortColumn: _sortColumn, + ascending: _sortAscending, + onSort: () { + if (col.sort != null) _onSort(col.sort!); + }, + ), + ), + SizedBox( + width: _kToggleBtnW, + child: Align( + alignment: Alignment.centerRight, + child: IconButton( + key: _columnBtnKey, + icon: const Icon(Icons.view_column_outlined, + size: 16), + tooltip: 'Columnas', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, minHeight: 28), + style: IconButton.styleFrom( + foregroundColor: + theme.colorScheme.onSurface.withAlpha(110), + ), + onPressed: _showColumnPicker, + ), + ), + ), + ], + ), + ), + // ── Rows ───────────────────────────────────────────────────── + Expanded( + child: MouseRegion( + onEnter: (_) => + setState(() => _scrollbarVisible = true), + onExit: (_) => + setState(() => _scrollbarVisible = false), + child: ScrollbarTheme( + data: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all( + _scrollbarVisible + ? theme.colorScheme.onSurface + .withValues(alpha: 0.35) + : Colors.transparent, + ), + trackVisibility: + WidgetStateProperty.all(false), + trackColor: + WidgetStateProperty.all(Colors.transparent), + trackBorderColor: + WidgetStateProperty.all(Colors.transparent), + ), + child: Scrollbar( + controller: _scrollCtrl, + thumbVisibility: true, + child: ListView.builder( + controller: _scrollCtrl, + itemCount: sorted.length, + itemBuilder: (context, i) { + final u = sorted[i]; + return ContextMenuRegion( + contextMenu: _contextMenuFor(u), + onItemSelected: (v) => + _handleAction(v, u), + child: _UserRow( + usuario: u, + deuda: _getDeuda(u), + ultimoPago: widget.ultimoPagoMap[u.dni], + visibleCols: _visibleCols, + flexCols: flexCols, + onTap: () => widget.onTap(u), + isLast: i == sorted.length - 1, + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ── Header cell ──────────────────────────────────────────────────────────────── + +class _HeaderCell extends StatelessWidget { + final TableCol col; + final SortColumn sortColumn; + final bool ascending; + final VoidCallback onSort; + + const _HeaderCell({ + required this.col, + required this.sortColumn, + required this.ascending, + required this.onSort, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isActive = col.sort != null && col.sort == sortColumn; + + final label = Text( + col.label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + color: isActive + ? SomaColors.primary + : theme.colorScheme.onSurface.withAlpha(110), + ), + ); + + if (col.sort == null) return label; + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onSort, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + label, + const SizedBox(width: 3), + if (isActive) + Icon( + ascending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 11, + color: SomaColors.primary, + ) + else + Icon( + Icons.unfold_more_rounded, + size: 11, + color: theme.colorScheme.onSurface.withAlpha(55), + ), + ], + ), + ), + ); + } +} + +// ── Data row ─────────────────────────────────────────────────────────────────── + +class _UserRow extends StatefulWidget { + final Usuario usuario; + final double? deuda; + final DateTime? ultimoPago; + final Set visibleCols; + final List flexCols; + final VoidCallback onTap; + final bool isLast; + + const _UserRow({ + required this.usuario, + required this.deuda, + required this.ultimoPago, + required this.visibleCols, + required this.flexCols, + required this.onTap, + required this.isLast, + }); + + @override + State<_UserRow> createState() => _UserRowState(); +} + +class _UserRowState extends State<_UserRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final u = widget.usuario; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: _hovered + ? theme.colorScheme.onSurface.withAlpha(7) + : Colors.transparent, + border: widget.isLast + ? null + : Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHighest + .withAlpha(50), + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + height: 52, + child: Row( + children: [ + if (widget.visibleCols.contains(TableCol.avatar)) + SizedBox( + width: _kAvatarColW, + child: CircleAvatar( + radius: 14, + backgroundColor: u.isActive + ? SomaColors.primary.withAlpha(30) + : theme.colorScheme.surfaceContainerHighest, + child: Text( + u.initials, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: u.isActive + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withAlpha(100), + ), + ), + ), + ), + for (final col in widget.flexCols) + Expanded( + flex: col.flex, + child: _cellFor(col, u, theme), + ), + const SizedBox(width: _kToggleBtnW), + ], + ), + ), + ), + ); + } + + Widget _cellFor(TableCol col, Usuario u, ThemeData theme) => + switch (col) { + TableCol.nombre => Padding( + padding: const EdgeInsets.only(right: 12), + child: Text( + u.displayName, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: u.isActive + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withAlpha(100), + ), + overflow: TextOverflow.ellipsis, + ), + ), + TableCol.dni => Text( + u.dni, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + TableCol.email => Padding( + padding: const EdgeInsets.only(right: 12), + child: Text( + u.mail ?? '-', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + overflow: TextOverflow.ellipsis, + ), + ), + TableCol.rol => _RolText(rol: u.rol), + TableCol.estadoPago => widget.deuda != null + ? _DeudaText(monto: widget.deuda!) + : Text( + 'Sin plan', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ), + TableCol.ultimoPago => () { + final d = widget.ultimoPago; + if (d == null) { + return Text( + '-', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + ); + } + return Text( + '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ); + }(), + TableCol.estado => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + u.isActive ? SomaColors.success : SomaColors.error, + ), + ), + const SizedBox(width: 5), + Text( + u.isActive ? 'Activo' : 'Inactivo', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withAlpha(130), + ), + ), + ], + ), + _ => const SizedBox.shrink(), + }; +} + +// ── Role text ────────────────────────────────────────────────────────────────── + +class _RolText extends StatelessWidget { + final String rol; + const _RolText({required this.rol}); + + @override + Widget build(BuildContext context) { + final (Color fg, String label) = switch (rol) { + 'superadmin' => (const Color(0xFFCE93D8), 'Superadmin'), + 'admin' => (SomaColors.primary, 'Admin'), + 'profesor' => (const Color(0xFF90CAF9), 'Profesor'), + _ => ( + Theme.of(context).colorScheme.onSurface.withAlpha(130), + 'Cliente', + ), + }; + + return Text( + label, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg), + ); + } +} + +// ── Debt text ────────────────────────────────────────────────────────────────── + +class _DeudaText extends StatelessWidget { + final double monto; + const _DeudaText({required this.monto}); + + @override + Widget build(BuildContext context) { + final isDebe = monto > 0; + final (Color fg, String label) = isDebe + ? (SomaColors.error, 'Debe \$$monto') + : (SomaColors.success, 'Al día'); + + return Text( + label, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg), + ); + } +} diff --git a/flutter_soma_app/lib/main.dart b/flutter_soma_app/lib/main.dart new file mode 100644 index 0000000..02256ea --- /dev/null +++ b/flutter_soma_app/lib/main.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gimnasio_soma/core/config/app_env.dart'; +import 'package:gimnasio_soma/core/config/env_banner.dart'; +import 'package:gimnasio_soma/core/config/supabase_config.dart'; +import 'package:gimnasio_soma/core/router/app_router.dart'; +import 'package:gimnasio_soma/core/services/soma_logger.dart'; +import 'package:gimnasio_soma/core/theme/soma_theme.dart'; + +/// Si se compiló para prod y no vino marcada por `soma_pro_updater`, relanza +/// a través de él en vez de arrancar directo. Evita que alguien se quede en +/// una versión vieja por abrir el .exe de la app sin pasar por el updater. +/// Se salta en dev porque ahí no existe `soma_pro_updater` al lado. +Future _redirectToUpdaterIfNeeded(List args) async { + if (!AppEnv.isProd || args.contains('--from-updater')) return false; + + // En Windows/Linux esta app vive en una subcarpeta `app/` al lado del + // updater (evita que sus carpetas `data/` se pisen); en macOS son + // hermanos porque cada `.app` ya es autocontenido. + final exeDir = File(Platform.resolvedExecutable).parent; + // En macOS el exe vive en .app/Contents/MacOS/ — subimos 3 niveles para llegar + // a la carpeta que contiene ambos .app bundles (donde está soma_pro_updater.app). + // En Windows/Linux la app vive en app/ — subimos 1 nivel para llegar al updater. + final updaterDir = Platform.isMacOS ? exeDir.parent.parent.parent : exeDir.parent; + final sep = Platform.pathSeparator; + final updaterName = Platform.isMacOS ? 'soma_pro_updater.app' : 'soma_pro_updater.exe'; + final updaterPath = '${updaterDir.path}$sep$updaterName'; + + final exists = + Platform.isMacOS ? Directory(updaterPath).existsSync() : File(updaterPath).existsSync(); + if (!exists) return false; + + // Freno de rebote: si ya redirigimos hace poco, el updater ya nos relanzó + // pero este proceso arrancó de nuevo sin `--from-updater` (en macOS, `open` + // sin `-n` puede reactivar una instancia existente en vez de crear una + // nueva, y ahí se pierde el `--args`). En vez de rebotar para siempre, + // cortamos el ciclo y arrancamos directo. + final bounceFile = File('${updaterDir.path}$sep.soma_redirect_bounce'); + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (bounceFile.existsSync()) { + final lastMs = int.tryParse(bounceFile.readAsStringSync().trim()) ?? 0; + if (nowMs - lastMs < 8000) { + try { + bounceFile.deleteSync(); + } catch (_) {} + return false; + } + } + bounceFile.writeAsStringSync('$nowMs'); + + if (Platform.isMacOS) { + // `-n` fuerza una instancia nueva del updater en vez de reactivar una + // existente, así el `open` de abajo siempre entrega los args frescos. + await Process.start('open', ['-n', updaterPath], mode: ProcessStartMode.detached); + } else { + await Process.start(updaterPath, const [], mode: ProcessStartMode.detached); + } + // Mismo respiro que del lado del updater: si este proceso sale demasiado + // rápido, macOS puede no llegar a transferirle el foco al proceso recién + // lanzado y su ventana queda sin renderizar el primer frame. + await Future.delayed(const Duration(milliseconds: 600)); + return true; +} + +void main(List args) async { + if (await _redirectToUpdaterIfNeeded(args)) { + exit(0); + } + + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); + + final log = SomaLogger.instance; + log.detectPlatform(); + log.info('App', 'Iniciando SOMA PRO...'); + + // Config de entorno horneada al compilar (--dart-define-from-file=config/.json). + // Corta el arranque con un mensaje claro si se compiló sin pasar el archivo. + AppEnv.assertConfigured(); + log.info('App', 'Entorno: ${AppEnv.env}'); + + try { + await SupabaseConfig.initialize(); + log.info('App', 'Supabase inicializado'); + } catch (e) { + log.error('App', 'Error inicializando Supabase', detail: e.toString()); + } + + // Captura errores de Flutter (widgets, rendering, etc.) + FlutterError.onError = (details) { + log.error('Flutter', details.exceptionAsString(), + detail: details.stack?.toString().split('\n').take(5).join('\n')); + }; + + runApp(const ProviderScope(child: MainApp())); + }, (error, stack) { + // Captura errores no manejados fuera del framework + SomaLogger.instance.error('Zone', error.toString(), + detail: stack.toString().split('\n').take(5).join('\n')); + }); +} + +class MainApp extends ConsumerWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'SOMA PRO', + debugShowCheckedModeBanner: false, + theme: SomaTheme.dark, + routerConfig: router, + // Cinta "DEV" en cualquier build que no sea prod (ver EnvBanner). + builder: (context, child) => + EnvBanner(child: child ?? const SizedBox.shrink()), + ); + } +} diff --git a/flutter_soma_app/linux/.gitignore b/flutter_soma_app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/flutter_soma_app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/flutter_soma_app/linux/CMakeLists.txt b/flutter_soma_app/linux/CMakeLists.txt new file mode 100644 index 0000000..1c5bfc3 --- /dev/null +++ b/flutter_soma_app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "SOMA_PRO") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.gimnasio_soma") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/flutter_soma_app/linux/flutter/CMakeLists.txt b/flutter_soma_app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/flutter_soma_app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/flutter_soma_app/linux/runner/CMakeLists.txt b/flutter_soma_app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/flutter_soma_app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/flutter_soma_app/linux/runner/main.cc b/flutter_soma_app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/flutter_soma_app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/flutter_soma_app/linux/runner/my_application.cc b/flutter_soma_app/linux/runner/my_application.cc new file mode 100644 index 0000000..2a56f83 --- /dev/null +++ b/flutter_soma_app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "SOMA PRO"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "SOMA PRO"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/flutter_soma_app/linux/runner/my_application.h b/flutter_soma_app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/flutter_soma_app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/flutter_soma_app/macos/.gitignore b/flutter_soma_app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/flutter_soma_app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/flutter_soma_app/macos/Flutter/Flutter-Debug.xcconfig b/flutter_soma_app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/flutter_soma_app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter_soma_app/macos/Flutter/Flutter-Release.xcconfig b/flutter_soma_app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/flutter_soma_app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter_soma_app/macos/Podfile b/flutter_soma_app/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/flutter_soma_app/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/flutter_soma_app/macos/Podfile.lock b/flutter_soma_app/macos/Podfile.lock new file mode 100644 index 0000000..e40085f --- /dev/null +++ b/flutter_soma_app/macos/Podfile.lock @@ -0,0 +1,54 @@ +PODS: + - app_links (6.4.1): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - printing (1.0.0): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + printing: + :path: Flutter/ephemeral/.symlinks/plugins/printing/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/flutter_soma_app/macos/Runner.xcodeproj/project.pbxproj b/flutter_soma_app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b3cfa06 --- /dev/null +++ b/flutter_soma_app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 2B20570A255A85C8CB95281B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EB424497AE804DFD6092CE5 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + F1F98EA41C3255EABE86E7CC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF677E07AA2D5B5B0B9FBCA4 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 27D2D2229CFF4F212FB31280 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 284FE0D3FD01D87DB884BCF8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* SOMA PRO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SOMA PRO.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3A176292DAB0567537ED473F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 5EB424497AE804DFD6092CE5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9B7BB219200CCD704FF25948 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B22769D9E6A7D9B1240D7D53 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + BF677E07AA2D5B5B0B9FBCA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E163CB5648F49DD4B519DD32 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B20570A255A85C8CB95281B /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F1F98EA41C3255EABE86E7CC /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 492A837CF725D5EFF90521A9 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* SOMA PRO.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 492A837CF725D5EFF90521A9 /* Pods */ = { + isa = PBXGroup; + children = ( + 9B7BB219200CCD704FF25948 /* Pods-Runner.debug.xcconfig */, + 284FE0D3FD01D87DB884BCF8 /* Pods-Runner.release.xcconfig */, + 27D2D2229CFF4F212FB31280 /* Pods-Runner.profile.xcconfig */, + 3A176292DAB0567537ED473F /* Pods-RunnerTests.debug.xcconfig */, + E163CB5648F49DD4B519DD32 /* Pods-RunnerTests.release.xcconfig */, + B22769D9E6A7D9B1240D7D53 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + BF677E07AA2D5B5B0B9FBCA4 /* Pods_Runner.framework */, + 5EB424497AE804DFD6092CE5 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 0FE1B564A44D7B8C0D96981F /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 6B6F210BB0F0E4E1EA605478 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + CEEE9A36890B8AF00CE1D0DD /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* SOMA PRO.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0FE1B564A44D7B8C0D96981F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 6B6F210BB0F0E4E1EA605478 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CEEE9A36890B8AF00CE1D0DD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A176292DAB0567537ED473F /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SOMA PRO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SOMA PRO"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E163CB5648F49DD4B519DD32 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SOMA PRO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SOMA PRO"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B22769D9E6A7D9B1240D7D53 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SOMA PRO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SOMA PRO"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/flutter_soma_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_soma_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_soma_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_soma_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_soma_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..86e0160 --- /dev/null +++ b/flutter_soma_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/flutter_soma_app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/flutter_soma_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/flutter_soma_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_soma_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_soma_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_soma_app/macos/Runner/AppDelegate.swift b/flutter_soma_app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..407366a --- /dev/null +++ b/flutter_soma_app/macos/Runner/AppDelegate.swift @@ -0,0 +1,32 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + // Esta app se relanza siempre por script (redirect al updater y viceversa), + // nunca tiene sentido "resumir" la ventana de una sesión anterior. Con esto + // en `true`, macOS intenta restaurar el estado de ventana guardado al + // arrancar y, si falla la restauración (visto en el log del sistema: + // "Unable to find className=(null)"), aplica una heurística que termina + // interfiriendo con la ventana recién creada antes de que Flutter pinte el + // primer frame — pantalla negra permanente sin ningún error de Dart. + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return false + } + + // Cuando esta app la lanza soma_pro_updater (vía `open --args`) en vez de + // Finder/el usuario directamente, la ventana puede quedar creada pero sin + // volverse la app activa/en primer plano — macOS pausa el render de + // ventanas no activas para ahorrar batería, y como esta pantalla no tiene + // animación propia que fuerce un repaint, queda negra para siempre. Forzar + // la activación acá evita depender de que el proceso que nos lanzó haya + // completado bien el traspaso de foco. + override func applicationDidFinishLaunching(_ notification: Notification) { + super.applicationDidFinishLaunching(notification) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/flutter_soma_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/flutter_soma_app/macos/Runner/Base.lproj/MainMenu.xib b/flutter_soma_app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/flutter_soma_app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_soma_app/macos/Runner/Configs/AppInfo.xcconfig b/flutter_soma_app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..3a41e9a --- /dev/null +++ b/flutter_soma_app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = SOMA PRO + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.gimnasioSoma + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/flutter_soma_app/macos/Runner/Configs/Debug.xcconfig b/flutter_soma_app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/flutter_soma_app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/flutter_soma_app/macos/Runner/Configs/Release.xcconfig b/flutter_soma_app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/flutter_soma_app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/flutter_soma_app/macos/Runner/Configs/Warnings.xcconfig b/flutter_soma_app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/flutter_soma_app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/flutter_soma_app/macos/Runner/DebugProfile.entitlements b/flutter_soma_app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..a11661a --- /dev/null +++ b/flutter_soma_app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,23 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + com.apple.security.network.client + + + com.apple.security.print + + + com.apple.security.files.user-selected.read-write + + + diff --git a/flutter_soma_app/macos/Runner/Info.plist b/flutter_soma_app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/flutter_soma_app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/flutter_soma_app/macos/Runner/MainFlutterWindow.swift b/flutter_soma_app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..cd7961b --- /dev/null +++ b/flutter_soma_app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,32 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + // Esta app se relanza siempre por script (redirect al updater y + // viceversa), nunca hay que "resumir" la ventana de una sesión anterior. + // Sin esto, macOS puede intentar restaurar estado de ventana guardado en + // un lanzamiento previo, fallar (log: "Unable to find className=(null)") + // y aplicar una heurística que interfiere con esta ventana antes de que + // Flutter pinte el primer frame — pantalla negra permanente sin error. + self.isRestorable = false + + let project = FlutterDartProject() + // Reenvía los argumentos de línea de comandos a Dart (main(List args)) + // para que --from-updater llegue correctamente desde soma_pro_updater. + project.dartEntrypointArguments = Array(CommandLine.arguments.dropFirst()) + // `initWithProject:` crea y corre el engine internamente, ya asociado a + // este view controller. Crearlo a mano y llamar engine.run() antes de que + // exista el view controller (como se hacía antes) puede pedir el primer + // frame sin que haya superficie para pintarlo — ventana negra permanente + // sin ningún error, porque no vuelve a dispararse un repaint. + let flutterViewController = FlutterViewController(project: project) + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/flutter_soma_app/macos/Runner/Release.entitlements b/flutter_soma_app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..85706ff --- /dev/null +++ b/flutter_soma_app/macos/Runner/Release.entitlements @@ -0,0 +1,22 @@ + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + com.apple.security.print + + + com.apple.security.files.user-selected.read-write + + + diff --git a/flutter_soma_app/macos/RunnerTests/RunnerTests.swift b/flutter_soma_app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/flutter_soma_app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter_soma_app/pubspec.lock b/flutter_soma_app/pubspec.lock new file mode 100644 index 0000000..ffc8638 --- /dev/null +++ b/flutter_soma_app/pubspec.lock @@ -0,0 +1,850 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a + url: "https://pub.dev" + source: hosted + version: "3.3.2" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" + url: "https://pub.dev" + source: hosted + version: "0.69.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_context_menu: + dependency: "direct main" + description: + name: flutter_context_menu + sha256: "7040c03fb9cb2a113282d836edadf32b6cdff67cccdf3d158e7411ecc15d7712" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" + url: "https://pub.dev" + source: hosted + version: "2.0.29" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + url: "https://pub.dev" + source: hosted + version: "6.3.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.10.1 <4.0.0" + flutter: ">=3.27.0" diff --git a/flutter_soma_app/pubspec.yaml b/flutter_soma_app/pubspec.yaml new file mode 100644 index 0000000..bc938da --- /dev/null +++ b/flutter_soma_app/pubspec.yaml @@ -0,0 +1,42 @@ +name: gimnasio_soma +description: "SOMA - Gym Manager App" +publish_to: 'none' +version: 0.1.1 + +environment: + sdk: ^3.10.1 + +dependencies: + flutter: + sdk: flutter + + # Backend + supabase_flutter: ^2.8.4 + + # State Management + flutter_riverpod: ^2.6.1 + + # Navigation + go_router: ^14.8.1 + + # Local Storage + shared_preferences: ^2.3.4 + + flutter_context_menu: ^0.4.2 + fl_chart: ^0.69.0 + url_launcher: ^6.3.1 + pdf: ^3.11.1 + printing: ^5.13.1 + file_picker: ^8.1.7 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/logo.png diff --git a/flutter_soma_app/test/features/horarios/horarios_provider_test.dart b/flutter_soma_app/test/features/horarios/horarios_provider_test.dart new file mode 100644 index 0000000..18a1325 --- /dev/null +++ b/flutter_soma_app/test/features/horarios/horarios_provider_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart'; +import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart'; +import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart'; +import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart'; + +/// Repositorio falso que cuenta cuántas veces se llama a cada operación de +/// lectura. Así verificamos que una escritura dispara una **relectura** de las +/// vistas derivadas (que es justo lo que arregla la desincronización). +class _FakeHorariosRepository implements HorariosRepository { + int obtenerSemanaCalls = 0; + int listarEspecialesCalls = 0; + int futurosCalls = 0; + + @override + Future obtenerSemana(DateTime weekStart) async { + obtenerSemanaCalls++; + return SemanaHorarios.fromResponse(weekStart, const {}); + } + + @override + Future guardarDia({ + required DateTime fecha, + required bool esEspecial, + String? motivo, + required List> bloques, + DateTime? validoDesde, + Alcance? alcance, + }) async {} + + @override + Future eliminarDiaEspecial(DateTime fecha) async {} + + @override + Future> listarDiasEspeciales({int dias = 90}) async { + listarEspecialesCalls++; + return const []; + } + + @override + Future> futurosParaDiaSemana({ + required int diaSemana, + required DateTime desde, + int meses = 6, + }) async { + futurosCalls++; + return const []; + } + + @override + Future contarHuerfanasDesde(DateTime instante) async => 0; +} + +void main() { + late _FakeHorariosRepository repo; + late ProviderContainer container; + + setUp(() { + repo = _FakeHorariosRepository(); + container = ProviderContainer( + overrides: [horariosRepositoryProvider.overrideWithValue(repo)], + ); + }); + + tearDown(() => container.dispose()); + + // Abre las dos vistas derivadas una vez. Tras esto listarEspecialesCalls == 1 + // y futurosCalls == 7 (el provider de cambios consulta los 7 días de la + // semana). + Future abrirVistasDerivadas() async { + container.read(diasEspecialesProvider.notifier); // dispara load() + await container.read(diasCambioProvider.future); + await Future.delayed(Duration.zero); // deja resolver el load() + } + + // Vuelve a leer las vistas derivadas para forzar el recómputo si fueron + // invalidadas. + Future releerVistasDerivadas() async { + container.read(diasEspecialesProvider.notifier); + await container.read(diasCambioProvider.future); + await Future.delayed(Duration.zero); + } + + test('guardarDia invalida especiales y cambios (no solo la semana)', () async { + await container + .read(horariosProvider.notifier) + .cargarSemana(DateTime(2026, 6, 22)); + await abrirVistasDerivadas(); + + final especialesAntes = repo.listarEspecialesCalls; + final cambiosAntes = repo.futurosCalls; + + await container.read(horariosProvider.notifier).guardarDia( + fecha: DateTime(2026, 6, 22), + esEspecial: true, + bloques: const [], + ); + + await releerVistasDerivadas(); + + // Si la invalidación no se propagara, estas relecturas devolverían el valor + // cacheado y los contadores no se moverían: ese era el bug. + expect(repo.listarEspecialesCalls, greaterThan(especialesAntes), + reason: 'la lista de días especiales debería recargarse tras guardar'); + expect(repo.futurosCalls, greaterThan(cambiosAntes), + reason: 'los cambios del calendario deberían recargarse tras guardar'); + }); + + test('eliminarDiaEspecial invalida especiales y cambios', () async { + await container + .read(horariosProvider.notifier) + .cargarSemana(DateTime(2026, 6, 22)); + await abrirVistasDerivadas(); + + final especialesAntes = repo.listarEspecialesCalls; + final cambiosAntes = repo.futurosCalls; + + await container + .read(horariosProvider.notifier) + .eliminarDiaEspecial(DateTime(2026, 6, 22)); + + await releerVistasDerivadas(); + + expect(repo.listarEspecialesCalls, greaterThan(especialesAntes)); + expect(repo.futurosCalls, greaterThan(cambiosAntes)); + }); +} diff --git a/flutter_soma_app/web/favicon.png b/flutter_soma_app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/flutter_soma_app/web/favicon.png differ diff --git a/flutter_soma_app/web/icons/Icon-192.png b/flutter_soma_app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/flutter_soma_app/web/icons/Icon-192.png differ diff --git a/flutter_soma_app/web/icons/Icon-512.png b/flutter_soma_app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/flutter_soma_app/web/icons/Icon-512.png differ diff --git a/flutter_soma_app/web/icons/Icon-maskable-192.png b/flutter_soma_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/flutter_soma_app/web/icons/Icon-maskable-192.png differ diff --git a/flutter_soma_app/web/icons/Icon-maskable-512.png b/flutter_soma_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/flutter_soma_app/web/icons/Icon-maskable-512.png differ diff --git a/flutter_soma_app/web/index.html b/flutter_soma_app/web/index.html new file mode 100644 index 0000000..2a4f575 --- /dev/null +++ b/flutter_soma_app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + gimnasio_soma + + + + + + diff --git a/flutter_soma_app/web/manifest.json b/flutter_soma_app/web/manifest.json new file mode 100644 index 0000000..516c256 --- /dev/null +++ b/flutter_soma_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "gimnasio_soma", + "short_name": "gimnasio_soma", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/flutter_soma_app/windows/.gitignore b/flutter_soma_app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/flutter_soma_app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/flutter_soma_app/windows/CMakeLists.txt b/flutter_soma_app/windows/CMakeLists.txt new file mode 100644 index 0000000..44ce3c9 --- /dev/null +++ b/flutter_soma_app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(SOMA_PRO LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "SOMA_PRO") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/flutter_soma_app/windows/flutter/CMakeLists.txt b/flutter_soma_app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/flutter_soma_app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/flutter_soma_app/windows/runner/CMakeLists.txt b/flutter_soma_app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/flutter_soma_app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/flutter_soma_app/windows/runner/Runner.rc b/flutter_soma_app/windows/runner/Runner.rc new file mode 100644 index 0000000..fe3f1b6 --- /dev/null +++ b/flutter_soma_app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "SOMA PRO" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "SOMA_PRO" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "SOMA_PRO.exe" "\0" + VALUE "ProductName", "SOMA PRO" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/flutter_soma_app/windows/runner/flutter_window.cpp b/flutter_soma_app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/flutter_soma_app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/flutter_soma_app/windows/runner/flutter_window.h b/flutter_soma_app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/flutter_soma_app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/flutter_soma_app/windows/runner/main.cpp b/flutter_soma_app/windows/runner/main.cpp new file mode 100644 index 0000000..f6e9a04 --- /dev/null +++ b/flutter_soma_app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"SOMA PRO", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/flutter_soma_app/windows/runner/resource.h b/flutter_soma_app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/flutter_soma_app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/flutter_soma_app/windows/runner/resources/app_icon.ico b/flutter_soma_app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..2b36e22 Binary files /dev/null and b/flutter_soma_app/windows/runner/resources/app_icon.ico differ diff --git a/flutter_soma_app/windows/runner/runner.exe.manifest b/flutter_soma_app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/flutter_soma_app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/flutter_soma_app/windows/runner/utils.cpp b/flutter_soma_app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/flutter_soma_app/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/flutter_soma_app/windows/runner/utils.h b/flutter_soma_app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/flutter_soma_app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/flutter_soma_app/windows/runner/win32_window.cpp b/flutter_soma_app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/flutter_soma_app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/flutter_soma_app/windows/runner/win32_window.h b/flutter_soma_app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/flutter_soma_app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_