diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..70a7d44a --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "9f455d2486bcb28cad87b062475f42edc959f636" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: android + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: ios + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: linux + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: macos + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: web + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: windows + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md index e77cc37e..57a291a9 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,189 @@ -# Bienvenido al coding-interview-frontend +# 💱 Crypto Calculator Challenge -## Descripción -Acá tienes todos los assets que necesitas para llevar a cabo una pequeña prueba técnica. El objetivo es que puedas demostrar tus habilidades de programación y de UI. El proyecto consiste de una pequeña calculadora que te muestra cuanto vas a recibir si quieres cambiar una determinada cantidad de una moneda a otra. +
-## Características -1. Hay dos tipos de monedas: "FIAT" y "CRYPTO". -2. La tasa de cambio la podrás obtener de nuestro API público. -3. La moneda del input +![Flutter](https://img.shields.io/badge/Flutter-3.8.0+-02569B?style=for-the-badge&logo=flutter&logoColor=white) +![Dart](https://img.shields.io/badge/Dart-3.8.0+-0175C2?style=for-the-badge&logo=dart&logoColor=white) +![Riverpod](https://img.shields.io/badge/Riverpod-3.0.3-5ECCAA?style=for-the-badge) +![Tests](https://img.shields.io/badge/Tests-Passing-success?style=for-the-badge) -## API -- URL: https://74j6q7lg6a.execute-api.eu-west-1.amazonaws.com/stage/orderbook/public/recommendations -- Query Params: - - `type`: 0 -> Cambio de CRYPTO a FIAT, 1 -> Cambio de FIAT a CRYPTO - - `cryptoCurrencyId`: La moneda crypto (el ID está en el nombre del asset) - - `fiatCurrencyId`: La moneda fiat (el ID está en el nombre del asset) - - `amount`: Cantidad a cambiar - - `amountCurrencyId`: La moneda en la que está del input +**Una calculadora de criptomonedas moderna, escalable y pixel-perfect construida con Flutter** -Del response, simplemente obtener el `data.byPrice.fiatToCryptoExchangeRate` y multiplicarlo/dividirlo para mostrar toda la data necesaria. +[Características](#-características) • [Arquitectura](#-arquitectura) • [Instalación](#-instalación) • [Testing](#-testing) -### Que puedes hacer: -- ✅ Preferiblemente, usa Flutter :) -- ✅ Cuantas mejoras de UX como veas necesarias/quieras -- ✅ No todo tiene que estar funcionando a la perfección, lo que más vamos a tomar en cuenta es el parecido con el diseño y la calidad del código. -- ✅ Desarrolla la app con la arquitecura de una app que va a escalar, no hagas un código que no puedas mantener en el futuro. +
+--- -### Que **no** puedes hacer: -- ❌ Estresarte 🤗 +## 🎥 Video Demo +
-## Pasos para comenzar -1. Haz un fork usando este repositorio como template -2. Clona el repositorio en tu máquina -3. Desarrolla la mini-app -4. Sube tus cambios a tu repositorio -5. Avísanos que has terminado -6. ??? -7. PROFIT +### Android -### Cualquier duda contactarme a https://www.linkedin.com/in/carlosfontest/ +https://github.com/user-attachments/assets/a6854466-b527-4db8-a559-f64a38a883f9 + +### iOS + +https://github.com/user-attachments/assets/32faa560-6e04-4a00-a03f-ca6e2c438074 + +
+ +--- + +## ✨ Características + +### 🎨 UI/UX Excellence +- **Pixel Perfect Implementation** - Diseño implementado con precisión absoluta +- **Animaciones Suaves** - Chevron animados y transiciones fluidas +- **Mejoras de UX** - Tasa de conversión mostrada (1 FIAT = X USDT) para mayor claridad +- **Tema Moderno** - Paleta de colores vibrante con modo oscuro nativo +- **Responsive Design** - Adaptable a diferentes tamaños de pantalla + +### 🏗️ Arquitectura Robusta +- **Clean Architecture** - Separación clara en capas: Data, Domain, Presentation +- **Escalabilidad** - Preparado para crecer con nuevas features +- **SOLID Principles** - Código mantenible y de alta calidad +- **Gestión de Estado** - Riverpod 3.0 con code generation +- **Navegación Declarativa** - GoRouter para routing type-safe y deep linking + +### 🛡️ Manejo de Errores Profesional +- **Validaciones Robustas** - Límites de monto, validaciones de entrada +- **Mensajes Amigables** - Errores claros y comprensibles para el usuario +- **Recuperación Elegante** - Manejo de errores de red y API +- **Estados Consistentes** - Loading, Error y Success states bien definidos + +### ⚡ Optimizaciones Técnicas +- **Use Cases** - Lógica de negocio encapsulada y testeable +- **Providers Optimizados** - Mínimos rebuilds, máximo rendimiento +- **Code Generation** - Riverpod Generator y Freezed para código type-safe +- **Últimas Versiones** - Flutter 3.8+, Riverpod 3.0, Dio 5.9 + +### 🧪 Testing & Calidad +- **11 Unit Tests** - Cobertura completa de lógica crítica +- **100% de Éxito** - Todos los tests pasan correctamente +- **Validaciones Testeadas** - ValidateExchangeAmountUseCase +- **Cálculos Testeados** - CalculateCryptoExchangeUseCase + +--- + +## 🏛️ Arquitectura + +El proyecto sigue **Clean Architecture** con tres capas bien definidas: + +``` +lib/ +├── src/ +│ ├── core/ # Recursos compartidos +│ │ ├── constants/ # Strings, validaciones, colores +│ │ ├── theme/ # Temas y estilos +│ │ ├── utils/ # Utilidades y helpers +│ │ └── widgets/ # Widgets reutilizables +│ │ +│ └── features/ +│ └── calculator/ +│ ├── data/ # Capa de Datos +│ │ ├── datasources/ # API & Local data +│ │ └── repositories/ # Implementaciones +│ │ +│ ├── domain/ # Capa de Dominio +│ │ ├── entities/ # Modelos de negocio +│ │ ├── repositories/ # Contratos +│ │ └── usecases/ # Lógica de negocio +│ │ +│ └── presentation/ # Capa de Presentación +│ ├── providers/ # Riverpod providers +│ ├── screens/ # Pantallas +│ ├── states/ # Estados +│ └── widgets/ # UI components +``` + +### 📦 Paquetes Principales + +| Paquete | Versión | Propósito | +|---------|---------|-----------| +| `flutter_riverpod` | 3.0.3 | Gestión de estado reactiva | +| `riverpod_annotation` | 3.0.3 | Code generation para providers | +| `dio` | 5.9.0 | Cliente HTTP robusto | +| `freezed` | 3.2.3 | Modelos inmutables | +| `flutter_svg` | 2.2.1 | Renderizado de SVG | +| `go_router` | 16.3.0 | Navegación declarativa y deep linking | + +--- + +## 🚀 Instalación + +### Prerequisitos + +- Flutter SDK: `>= 3.8.0` +- Dart SDK: `>= 3.8.0` + +### Pasos + +1. **Clonar el repositorio** +```bash +git clone https://github.com/cuambyte/crypto-calculator.git +cd crypto-calculator +``` + +2. **Instalar dependencias** +```bash +flutter pub get +``` + +3. **Generar código** +```bash +dart run build_runner build -d +``` + +4. **Ejecutar la app** +```bash +flutter run +``` + +### Ejecutar Tests + +```bash +flutter test +``` + +--- + +## 🎯 Decisiones Técnicas + +### ¿Por qué Clean Architecture? +- ✅ Facilita el testing +- ✅ Código desacoplado y mantenible +- ✅ Preparado para escalar +- ✅ Independencia de frameworks + +### ¿Por qué Riverpod? +- ✅ Type-safe y compile-time +- ✅ Sin context necesario +- ✅ Code generation +- ✅ Performance optimizado + +### ¿Por qué Unit Testing? +- ✅ Valida lógica crítica del negocio +- ✅ Rápido de ejecutar +- ✅ Detecta regresiones temprano +- ✅ Documenta comportamiento esperado + +--- + +## 🔮 Mejoras Futuras + +- [ ] Tests de integración E2E +- [ ] Persistencia local con Hive/SQLite +- [ ] Soporte multi-idioma (i18n) +- [ ] Más criptomonedas y fiat +- [ ] Historial de conversiones +- [ ] Modo offline con cache + +--- + +
+ +⭐ Si te gustó este proyecto, dale una estrella + +
diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..27547855 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.crypto.calculator.crypto_calculator" + 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.crypto.calculator.crypto_calculator" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fa976df0 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/crypto/calculator/crypto_calculator/MainActivity.kt b/android/app/src/main/kotlin/com/crypto/calculator/crypto_calculator/MainActivity.kt new file mode 100644 index 00000000..3ee92428 --- /dev/null +++ b/android/app/src/main/kotlin/com/crypto/calculator/crypto_calculator/MainActivity.kt @@ -0,0 +1,5 @@ +package com.crypto.calculator.crypto_calculator + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ac3b4792 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..fb605bc8 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/icons/swap.svg b/assets/icons/swap.svg new file mode 100644 index 00000000..4dd16c2e --- /dev/null +++ b/assets/icons/swap.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/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/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..71464462 --- /dev/null +++ b/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.crypto.calculator.cryptoCalculator; + 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.crypto.calculator.cryptoCalculator.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.crypto.calculator.cryptoCalculator.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.crypto.calculator.cryptoCalculator.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.crypto.calculator.cryptoCalculator; + 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.crypto.calculator.cryptoCalculator; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..b8994b5d --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Crypto Calculator + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + crypto_calculator + 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/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..864e5746 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +import 'src/crypo_calculator_app.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + runApp(const CryptoCalculatorApp()); +} diff --git a/lib/src/core/constants/api_constants.dart b/lib/src/core/constants/api_constants.dart new file mode 100644 index 00000000..a5594570 --- /dev/null +++ b/lib/src/core/constants/api_constants.dart @@ -0,0 +1,21 @@ +class ApiConstants { + static const String baseUrl = + 'https://74j6q7lg6a.execute-api.eu-west-1.amazonaws.com/stage'; + static const String exchangeRateUrl = + '$baseUrl/orderbook/public/recommendations'; + + // Query parameters + static const String typeParam = 'type'; + static const String cryptoCurrencyIdParam = 'cryptoCurrencyId'; + static const String fiatCurrencyIdParam = 'fiatCurrencyId'; + static const String amountParam = 'amount'; + static const String amountCurrencyIdParam = 'amountCurrencyId'; + + // Exchange types + static const int cryptoToFiat = 0; + static const int fiatToCrypto = 1; + + // Timeouts + static const Duration connectTimeout = Duration(seconds: 30); + static const Duration receiveTimeout = Duration(seconds: 30); +} diff --git a/lib/src/core/constants/string_constants.dart b/lib/src/core/constants/string_constants.dart new file mode 100644 index 00000000..9497eb42 --- /dev/null +++ b/lib/src/core/constants/string_constants.dart @@ -0,0 +1,54 @@ +final class StringConstants { + // Errores generales + static const String errorMessage = + 'Ocurrió un error inesperado. Por favor, intenta nuevamente.'; + static const String pageNotFound = 'Página no encontrada'; + static const String goToHome = 'Ir a Inicio'; + // Errores de validación + static const String invalidAmountError = 'Debes ingresar un monto válido'; + static const String amountExceedsLimitError = + 'El monto excede el límite permitido'; + static const String amountMustBePositiveError = 'El monto debe ser mayor a 0'; + + // Errores de red + static const String connectionTimeoutError = + 'La conexión está tardando demasiado. Verifica tu internet e intenta nuevamente.'; + static const String noInternetConnectionError = + 'No hay conexión a internet. Verifica tu conexión e intenta nuevamente.'; + static const String connectionProblemError = + 'Ocurrió un problema de conexión. Por favor, intenta más tarde.'; + + // Errores del servidor + static const String invalidServerResponseError = + 'Formato de respuesta inválido del servidor'; + static const String conversionUnavailableError = + 'Esta conversión no está disponible en este momento. Por favor, intenta con otra moneda o cantidad.'; + static const String serverProcessingError = + 'No se pudo procesar la información del servidor. Intenta con otra moneda.'; + static const String serverRequestError = + 'El servidor no pudo procesar tu solicitud. Intenta más tarde.'; + static const String serverErrorPrefix = 'Error del servidor: '; + + // Labels de UI + static const String fiatLabel = 'FIAT'; + static const String cryptoLabel = 'Cripto'; + static const String haveLabel = 'TENGO'; + static const String wantLabel = 'QUIERO'; + + // Mensajes informativos + static const String estimatedRateLabel = 'Tasa estimada'; + static const String youWillReceiveLabel = 'Recibirás'; + static const String estimatedTimeLabel = 'Tiempo estimado'; + static const String estimatedTimeValue = '≈ 10 Min'; + static const String exchangeInfoPlaceholder = + 'Ingresa el monto y selecciona las monedas para ver la información del intercambio'; + static const String loadCurrenciesError = 'Error al cargar las monedas'; + + // Textos de botones + static const String exchangeButtonText = 'Cambiar'; + + // Placeholders + static const String amountPlaceholder = '0.00'; + static const String loadingText = 'Cargando...'; + static const String currencySymbol = '\$'; +} diff --git a/lib/src/core/constants/validation_constants.dart b/lib/src/core/constants/validation_constants.dart new file mode 100644 index 00000000..7494944a --- /dev/null +++ b/lib/src/core/constants/validation_constants.dart @@ -0,0 +1,13 @@ +/// Constantes de validación para la aplicación +/// +/// Centraliza todos los límites y reglas de validación +/// para facilitar su mantenimiento y modificación. +final class ValidationConstants { + // Validaciones de montos de intercambio + static const double maxExchangeAmount = 1000000.0; + static const double minExchangeAmount = 0.0; + + // Prevenir instanciación + ValidationConstants._(); +} + diff --git a/lib/src/core/errors/failures.dart b/lib/src/core/errors/failures.dart new file mode 100644 index 00000000..88cf5792 --- /dev/null +++ b/lib/src/core/errors/failures.dart @@ -0,0 +1,25 @@ +abstract final class Failure { + final String message; + final int? code; + + const Failure({required this.message, this.code}); + + @override + String toString() => message; +} + +final class ServerFailure extends Failure { + const ServerFailure({required super.message, super.code}); +} + +final class NetworkFailure extends Failure { + const NetworkFailure({required super.message, super.code}); +} + +final class ValidationFailure extends Failure { + const ValidationFailure({required super.message, super.code}); +} + +final class UnknownFailure extends Failure { + const UnknownFailure({required super.message, super.code}); +} diff --git a/lib/src/core/network/dio_client.dart b/lib/src/core/network/dio_client.dart new file mode 100644 index 00000000..51b03568 --- /dev/null +++ b/lib/src/core/network/dio_client.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; + +import '../constants/api_constants.dart'; + +class DioClient { + static Dio? _instance; + + static Dio get instance { + _instance ??= _createDio(); + return _instance!; + } + + static Dio _createDio() { + final dio = Dio(); + + dio.options = BaseOptions( + baseUrl: ApiConstants.baseUrl, + connectTimeout: ApiConstants.connectTimeout, + receiveTimeout: ApiConstants.receiveTimeout, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + dio.interceptors.add( + LogInterceptor( + requestBody: true, + responseBody: true, + error: true, + ), + ); + + return dio; + } +} diff --git a/lib/src/core/theme/app_theme.dart b/lib/src/core/theme/app_theme.dart new file mode 100644 index 00000000..228c0b14 --- /dev/null +++ b/lib/src/core/theme/app_theme.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'colors/app_colors.dart'; + +class AppTheme { + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + textSelectionTheme: TextSelectionThemeData( + cursorColor: AppColors.primaryYellow, + selectionColor: AppColors.primaryYellow.withValues(alpha: 0.2), + selectionHandleColor: AppColors.primaryYellow, + ), + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primaryYellow, + brightness: Brightness.light, + primary: AppColors.primaryYellow, + secondary: AppColors.secondaryYellow, + surface: AppColors.white, + onSurface: AppColors.black, + onPrimary: AppColors.white, + onSecondary: AppColors.white, + ), + scaffoldBackgroundColor: AppColors.lightBlue, + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.primaryYellow, + foregroundColor: AppColors.white, + elevation: 0, + centerTitle: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryYellow, + foregroundColor: AppColors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + ), + ), + cardTheme: CardThemeData( + color: AppColors.white, + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: const EdgeInsets.all(16), + ), + inputDecorationTheme: InputDecorationTheme( + contentPadding: const EdgeInsets.all(14.0), + filled: true, + fillColor: AppColors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.secondaryYellow), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: AppColors.secondaryYellow, + width: 1.5, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.red.shade400, width: 2), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.red.shade400, width: 2), + ), + ), + textTheme: const TextTheme( + headlineLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: AppColors.black, + ), + headlineMedium: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.black, + ), + titleLarge: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.black, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.black, + ), + bodyLarge: TextStyle(fontSize: 16, color: AppColors.black), + bodyMedium: TextStyle(fontSize: 14, color: AppColors.black), + bodySmall: TextStyle(fontSize: 12, color: AppColors.grey), + ), + ); + } +} diff --git a/lib/src/core/theme/colors/app_colors.dart b/lib/src/core/theme/colors/app_colors.dart new file mode 100644 index 00000000..9d89fbb6 --- /dev/null +++ b/lib/src/core/theme/colors/app_colors.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Colores principales del diseño + static const Color primaryYellow = Color.fromRGBO(255, 178, 0, 1); + static const Color secondaryYellow = Color.fromRGBO(255, 200, 1, 1); + static const Color lightBlue = Color.fromRGBO(224, 248, 250, 1); + static const Color white = Color(0xFFFFFFFF); + static const Color black = Color(0xFF212121); + static const Color grey = Color(0xFF757575); + + // Color para handles de bottom sheets + static const Color bottomSheetHandle = Color.fromRGBO(190, 195, 203, 1); +} diff --git a/lib/src/core/ui/painters/background_painter.dart b/lib/src/core/ui/painters/background_painter.dart new file mode 100644 index 00000000..1c9bf177 --- /dev/null +++ b/lib/src/core/ui/painters/background_painter.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import '../../theme/colors/app_colors.dart'; + +class BackgroundPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.primaryYellow + ..style = PaintingStyle.fill; + + // Óvalo alto (más alto que ancho), corrido a la derecha y hacia arriba + final centerX = size.width * 1.3; // empuja a la derecha + final centerY = size.height * 0.40; // levanta hacia arriba + final ovalWidth = size.width * 1.5; // más angosto + final ovalHeight = size.height * 1.2; // ~alto de pantalla o un poco más + + final rect = Rect.fromCenter( + center: Offset(centerX, centerY), + width: ovalWidth, + height: ovalHeight, + ); + + canvas.drawOval(rect, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/src/core/utils/formatters/rate_formatter.dart b/lib/src/core/utils/formatters/rate_formatter.dart new file mode 100644 index 00000000..471a6e0f --- /dev/null +++ b/lib/src/core/utils/formatters/rate_formatter.dart @@ -0,0 +1,8 @@ +final class RateFormatter { + static String formatRate({required double rate}) { + if (rate < 1) { + return rate.toStringAsFixed(4); + } + return rate.toStringAsFixed(2); + } +} diff --git a/lib/src/core/utils/formatters/thousand_separator_input_formatter.dart b/lib/src/core/utils/formatters/thousand_separator_input_formatter.dart new file mode 100644 index 00000000..8c72bc5f --- /dev/null +++ b/lib/src/core/utils/formatters/thousand_separator_input_formatter.dart @@ -0,0 +1,80 @@ +import 'package:flutter/services.dart'; + +/// Formatter que agrega separadores de miles mientras el usuario escribe +class ThousandSeparatorInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + // Si el texto está vacío, no hacer nada + if (newValue.text.isEmpty) { + return newValue; + } + + // Remover todas las comas existentes + final String unformattedText = newValue.text.replaceAll(',', ''); + + // Validar que solo contenga números y punto decimal + if (!RegExp(r'^\d*\.?\d*$').hasMatch(unformattedText)) { + return oldValue; + } + + // Separar parte entera y decimal + final parts = unformattedText.split('.'); + String integerPart = parts[0]; + final String? decimalPart = parts.length > 1 ? parts[1] : null; + + // Agregar comas a la parte entera + String formattedInteger = ''; + int counter = 0; + for (int i = integerPart.length - 1; i >= 0; i--) { + if (counter == 3) { + formattedInteger = ',$formattedInteger'; + counter = 0; + } + formattedInteger = integerPart[i] + formattedInteger; + counter++; + } + + // Construir el texto final + final String formattedText = decimalPart != null + ? '$formattedInteger.$decimalPart' + : formattedInteger; + + // Calcular la nueva posición del cursor + int selectionOffset = newValue.selection.end; + + // Contar cuántas comas hay antes del cursor en el texto antiguo + final int oldCommasBeforeCursor = + oldValue.text.substring(0, oldValue.selection.end).split(',').length - + 1; + + // Contar cuántas comas hay antes del cursor en el texto nuevo + final String beforeCursor = unformattedText.substring( + 0, + newValue.selection.end - oldCommasBeforeCursor, + ); + int newCommasBeforeCursor = 0; + int charCount = 0; + + for ( + int i = 0; + i < formattedText.length && charCount < beforeCursor.length; + i++ + ) { + if (formattedText[i] == ',') { + newCommasBeforeCursor++; + } else { + charCount++; + } + } + + selectionOffset = beforeCursor.length + newCommasBeforeCursor; + + return TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: selectionOffset), + ); + } +} diff --git a/lib/src/core/widgets/bottom_sheet_handle.dart b/lib/src/core/widgets/bottom_sheet_handle.dart new file mode 100644 index 00000000..90cbff64 --- /dev/null +++ b/lib/src/core/widgets/bottom_sheet_handle.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../theme/colors/app_colors.dart'; + +/// Handle indicator para bottom sheets +/// +/// Widget reutilizable que muestra el indicador visual en la parte +/// superior de los bottom sheets para indicar que se pueden arrastrar. +/// Usa el color global definido en AppColors.bottomSheetHandle. +final class BottomSheetHandle extends StatelessWidget { + final double width; + final double height; + final double topMargin; + + const BottomSheetHandle({ + super.key, + this.width = 40, + this.height = 4, + this.topMargin = 12, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(top: topMargin), + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.bottomSheetHandle, + borderRadius: BorderRadius.circular(height / 2), + ), + ); + } +} + diff --git a/lib/src/core/widgets/currency_image_widget.dart b/lib/src/core/widgets/currency_image_widget.dart new file mode 100644 index 00000000..fde39d7c --- /dev/null +++ b/lib/src/core/widgets/currency_image_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Widget reutilizable para mostrar imágenes de monedas con fallback +/// +/// Este widget encapsula la lógica de carga de imágenes de assets +/// con un fallback elegante en caso de error. +final class CurrencyImageWidget extends StatelessWidget { + final String assetPath; + final double size; + final double borderRadius; + + const CurrencyImageWidget({ + super.key, + required this.assetPath, + this.size = 40, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Image.asset( + assetPath, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: theme.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Icon( + Icons.currency_exchange, + size: size * 0.6, + color: theme.colorScheme.outline, + ), + ); + }, + ), + ); + } +} + diff --git a/lib/src/core/widgets/error_state_widget.dart b/lib/src/core/widgets/error_state_widget.dart new file mode 100644 index 00000000..19115174 --- /dev/null +++ b/lib/src/core/widgets/error_state_widget.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// Widget genérico para mostrar estados de error +/// +/// Este widget proporciona una UI consistente para mostrar +/// mensajes de error con un ícono y texto descriptivo. +final class ErrorStateWidget extends StatelessWidget { + final String title; + final String? message; + final IconData icon; + final VoidCallback? onRetry; + final String? retryButtonText; + + const ErrorStateWidget({ + super.key, + required this.title, + this.message, + this.icon = Icons.error_outline, + this.onRetry, + this.retryButtonText, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + ], + if (onRetry != null) ...[ + const SizedBox(height: 24), + ElevatedButton( + onPressed: onRetry, + child: Text(retryButtonText ?? 'Reintentar'), + ), + ], + ], + ), + ), + ); + } +} + diff --git a/lib/src/core/widgets/loading_button.dart b/lib/src/core/widgets/loading_button.dart new file mode 100644 index 00000000..cf4c9613 --- /dev/null +++ b/lib/src/core/widgets/loading_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Botón reutilizable con estado de loading +/// +/// Este widget encapsula un ElevatedButton que puede mostrar +/// un indicador de carga cuando está procesando una acción. +final class LoadingButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isEnabled; + final EdgeInsetsGeometry? padding; + final BorderRadius? borderRadius; + + const LoadingButton({ + super.key, + required this.text, + required this.onPressed, + this.isLoading = false, + this.isEnabled = true, + this.padding, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isEnabled && !isLoading ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + padding: padding ?? const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? BorderRadius.circular(12), + ), + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + text, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onPrimary, + ), + ), + ), + ); + } +} + diff --git a/lib/src/crypo_calculator_app.dart b/lib/src/crypo_calculator_app.dart new file mode 100644 index 00000000..aa30c1f7 --- /dev/null +++ b/lib/src/crypo_calculator_app.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/theme/app_theme.dart'; +import 'routes/app_router.dart'; + +class CryptoCalculatorApp extends StatelessWidget { + const CryptoCalculatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + child: MaterialApp.router( + debugShowCheckedModeBanner: false, + title: 'Crypto Calculator', + theme: AppTheme.lightTheme, + routerConfig: AppRouter.router, + ), + ); + } +} diff --git a/lib/src/features/calculator/data/datasources/currency_local_datasource.dart b/lib/src/features/calculator/data/datasources/currency_local_datasource.dart new file mode 100644 index 00000000..8f9ff691 --- /dev/null +++ b/lib/src/features/calculator/data/datasources/currency_local_datasource.dart @@ -0,0 +1,5 @@ +import '../../domain/entities/currency.dart'; + +abstract class CurrencyLocalDataSource { + Future> getAvailableCurrencies(); +} diff --git a/lib/src/features/calculator/data/datasources/currency_local_datasource_impl.dart b/lib/src/features/calculator/data/datasources/currency_local_datasource_impl.dart new file mode 100644 index 00000000..32d7fdbd --- /dev/null +++ b/lib/src/features/calculator/data/datasources/currency_local_datasource_impl.dart @@ -0,0 +1,52 @@ +// lib/src/features/calculator/data/datasources/currency_local_datasource_impl.dart +import '../../../../core/constants/string_constants.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/currency_type.dart'; +import 'currency_local_datasource.dart'; + +final class CurrencyLocalDataSourceImpl implements CurrencyLocalDataSource { + const CurrencyLocalDataSourceImpl(); + + @override + Future> getAvailableCurrencies() async { + await Future.delayed(const Duration(milliseconds: 2300)); + + return [ + const Currency( + id: 'TATUM-TRON-USDT', + name: 'Tether (USDT)', + symbol: 'USDT', + assetPath: 'assets/cripto_currencies/TATUM-TRON-USDT.png', + type: CurrencyType.crypto, + ), + const Currency( + id: 'VES', + name: 'Bolívares (Bs)', + symbol: 'VES', + assetPath: 'assets/fiat_currencies/VES.png', + type: CurrencyType.fiat, + ), + const Currency( + id: 'BRL', + name: 'Real Brasileño (R${StringConstants.currencySymbol})', + symbol: 'BRL', + assetPath: 'assets/fiat_currencies/BRL.png', + type: CurrencyType.fiat, + ), + const Currency( + id: 'COP', + name: 'Pesos Colombianos (COL${StringConstants.currencySymbol})', + symbol: 'COP', + assetPath: 'assets/fiat_currencies/COP.png', + type: CurrencyType.fiat, + ), + const Currency( + id: 'PEN', + name: 'Soles Peruanos (S/)', + symbol: 'PEN', + assetPath: 'assets/fiat_currencies/PEN.png', + type: CurrencyType.fiat, + ), + ]; + } +} diff --git a/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource.dart b/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource.dart new file mode 100644 index 00000000..48e9b948 --- /dev/null +++ b/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource.dart @@ -0,0 +1,11 @@ +import '../models/exchange_rate_response_model.dart'; + +abstract class ExchangeRateRemoteDataSource { + Future getExchangeRate({ + required String cryptoCurrencyId, + required String fiatCurrencyId, + required String amount, + required String amountCurrencyId, + required int type, + }); +} diff --git a/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource_impl.dart b/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource_impl.dart new file mode 100644 index 00000000..86b15b8d --- /dev/null +++ b/lib/src/features/calculator/data/datasources/exchange_rate_remote_datasource_impl.dart @@ -0,0 +1,103 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/constants/api_constants.dart'; +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/errors/failures.dart'; +import '../models/exchange_rate_response_model.dart'; +import 'exchange_rate_remote_datasource.dart'; + +final class ExchangeRateRemoteDataSourceImpl + implements ExchangeRateRemoteDataSource { + final Dio _dio; + + const ExchangeRateRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + @override + Future getExchangeRate({ + required String cryptoCurrencyId, + required String fiatCurrencyId, + required String amount, + required String amountCurrencyId, + required int type, + }) async { + try { + ExchangeRateResponseModel exchangeRateResponseModel; + + final response = await _dio.get( + ApiConstants.exchangeRateUrl, + queryParameters: { + ApiConstants.typeParam: type, + ApiConstants.cryptoCurrencyIdParam: cryptoCurrencyId, + ApiConstants.fiatCurrencyIdParam: fiatCurrencyId, + ApiConstants.amountParam: amount, + ApiConstants.amountCurrencyIdParam: amountCurrencyId, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + if (response.data is! Map) { + throw ServerFailure( + message: StringConstants.invalidServerResponseError, + code: response.statusCode, + ); + } + + final Map data = response.data; + + if (data.isEmpty || + data['data'] == null || + (data['data'] is Map && (data['data'] as Map).isEmpty)) { + throw ServerFailure( + message: StringConstants.conversionUnavailableError, + code: response.statusCode, + ); + } + + try { + exchangeRateResponseModel = ExchangeRateResponseModel.fromJson(data); + } catch (_) { + throw ServerFailure( + message: StringConstants.serverProcessingError, + code: response.statusCode, + ); + } + } else { + throw ServerFailure( + message: '${StringConstants.serverErrorPrefix}${response.statusCode}', + code: response.statusCode, + ); + } + + return exchangeRateResponseModel; + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout) { + throw NetworkFailure( + message: StringConstants.connectionTimeoutError, + code: e.response?.statusCode, + ); + } else if (e.type == DioExceptionType.badResponse) { + throw ServerFailure( + message: StringConstants.serverRequestError, + code: e.response?.statusCode, + ); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkFailure( + message: StringConstants.noInternetConnectionError, + code: e.response?.statusCode, + ); + } else { + throw NetworkFailure( + message: StringConstants.connectionProblemError, + code: e.response?.statusCode, + ); + } + } on Failure { + // Re-lanzar los Failures que ya creamos + rethrow; + } catch (e) { + throw UnknownFailure(message: StringConstants.errorMessage); + } + } +} diff --git a/lib/src/features/calculator/data/models/by_price.dart b/lib/src/features/calculator/data/models/by_price.dart new file mode 100644 index 00000000..15d1e83c --- /dev/null +++ b/lib/src/features/calculator/data/models/by_price.dart @@ -0,0 +1,19 @@ +final class ByPrice { + final double? fiatToCryptoExchangeRate; + + const ByPrice({required this.fiatToCryptoExchangeRate}); + + static double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + factory ByPrice.fromJson(Map json) { + final double? fiatToCryptoExchangeRate = _toDouble( + json['fiatToCryptoExchangeRate'], + ); + return ByPrice(fiatToCryptoExchangeRate: fiatToCryptoExchangeRate); + } +} diff --git a/lib/src/features/calculator/data/models/data_wrapper.dart b/lib/src/features/calculator/data/models/data_wrapper.dart new file mode 100644 index 00000000..a2807c81 --- /dev/null +++ b/lib/src/features/calculator/data/models/data_wrapper.dart @@ -0,0 +1,15 @@ +import 'by_price.dart'; + +final class DataWrapper { + final ByPrice byPrice; + + const DataWrapper({required this.byPrice}); + + factory DataWrapper.fromJson(Map json) { + final ByPrice byPrice = ByPrice.fromJson( + json['byPrice'] as Map, + ); + + return DataWrapper(byPrice: byPrice); + } +} diff --git a/lib/src/features/calculator/data/models/exchange_rate_response_model.dart b/lib/src/features/calculator/data/models/exchange_rate_response_model.dart new file mode 100644 index 00000000..5c318ad6 --- /dev/null +++ b/lib/src/features/calculator/data/models/exchange_rate_response_model.dart @@ -0,0 +1,15 @@ +import 'data_wrapper.dart'; + +final class ExchangeRateResponseModel { + final DataWrapper data; + + const ExchangeRateResponseModel({required this.data}); + + factory ExchangeRateResponseModel.fromJson(Map json) { + final DataWrapper data = DataWrapper.fromJson( + json['data'] as Map, + ); + + return ExchangeRateResponseModel(data: data); + } +} diff --git a/lib/src/features/calculator/data/models/mappers/exchange_rate_response_mapper.dart b/lib/src/features/calculator/data/models/mappers/exchange_rate_response_mapper.dart new file mode 100644 index 00000000..0e37ce0d --- /dev/null +++ b/lib/src/features/calculator/data/models/mappers/exchange_rate_response_mapper.dart @@ -0,0 +1,8 @@ +import '../../../domain/entities/exchange_rate.dart'; +import '../exchange_rate_response_model.dart'; + +extension ExchangeRateResponseMapper on ExchangeRateResponseModel { + ExchangeRate toEntity() => ExchangeRate( + fiatToCryptoExchangeRate: data.byPrice.fiatToCryptoExchangeRate ?? 0.0, + ); +} diff --git a/lib/src/features/calculator/data/repositories/calculator_repository_impl.dart b/lib/src/features/calculator/data/repositories/calculator_repository_impl.dart new file mode 100644 index 00000000..b051eb00 --- /dev/null +++ b/lib/src/features/calculator/data/repositories/calculator_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:crypto_calculator/src/features/calculator/data/models/mappers/exchange_rate_response_mapper.dart'; + +import '../../domain/entities/exchange_rate.dart'; +import '../../domain/repositories/calculator_repository.dart'; +import '../datasources/exchange_rate_remote_datasource.dart'; +import '../models/exchange_rate_response_model.dart'; + +final class CalculatorRepositoryImpl implements CalculatorRepository { + final ExchangeRateRemoteDataSource _exchangeRateRemoteDataSource; + + const CalculatorRepositoryImpl({ + required ExchangeRateRemoteDataSource exchangeRateRemoteDataSource, + }) : _exchangeRateRemoteDataSource = exchangeRateRemoteDataSource; + + @override + Future getExchangeRate({ + required String cryptoCurrencyId, + required String fiatCurrencyId, + required String amount, + required String amountCurrencyId, + required int type, + }) async { + final ExchangeRate exchangeRate; + + final ExchangeRateResponseModel exchangeRateResult = + await _exchangeRateRemoteDataSource.getExchangeRate( + cryptoCurrencyId: cryptoCurrencyId, + fiatCurrencyId: fiatCurrencyId, + amount: amount, + amountCurrencyId: amountCurrencyId, + type: type, + ); + + exchangeRate = exchangeRateResult.toEntity(); + + return exchangeRate.fiatToCryptoExchangeRate; + } +} diff --git a/lib/src/features/calculator/data/repositories/currency_repository_impl.dart b/lib/src/features/calculator/data/repositories/currency_repository_impl.dart new file mode 100644 index 00000000..8eb4b36b --- /dev/null +++ b/lib/src/features/calculator/data/repositories/currency_repository_impl.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/currency.dart'; +import '../../domain/repositories/currency_repository.dart'; +import '../datasources/currency_local_datasource.dart'; + +class CurrencyRepositoryImpl implements CurrencyRepository { + final CurrencyLocalDataSource localDataSource; + + const CurrencyRepositoryImpl({required this.localDataSource}); + + @override + Future> getAvailableCurrencies() async { + try { + return await localDataSource.getAvailableCurrencies(); + } catch (e) { + // TODO: Manejar errores apropiadamente + rethrow; + } + } +} diff --git a/lib/src/features/calculator/domain/entities/calculate_exchange_params.dart b/lib/src/features/calculator/domain/entities/calculate_exchange_params.dart new file mode 100644 index 00000000..413bf92a --- /dev/null +++ b/lib/src/features/calculator/domain/entities/calculate_exchange_params.dart @@ -0,0 +1,13 @@ +import 'currency.dart'; + +final class CalculateExchangeParams { + final Currency fromCurrency; + final Currency toCurrency; + final double amount; + + const CalculateExchangeParams({ + required this.fromCurrency, + required this.toCurrency, + required this.amount, + }); +} diff --git a/lib/src/features/calculator/domain/entities/currency.dart b/lib/src/features/calculator/domain/entities/currency.dart new file mode 100644 index 00000000..cdc892db --- /dev/null +++ b/lib/src/features/calculator/domain/entities/currency.dart @@ -0,0 +1,17 @@ +import 'enum/currency_type.dart'; + +final class Currency { + final String id; + final String name; + final String symbol; + final String assetPath; + final CurrencyType type; + + const Currency({ + required this.id, + required this.name, + required this.symbol, + required this.assetPath, + required this.type, + }); +} diff --git a/lib/src/features/calculator/domain/entities/enum/bottom_sheet_type.dart b/lib/src/features/calculator/domain/entities/enum/bottom_sheet_type.dart new file mode 100644 index 00000000..62c2c539 --- /dev/null +++ b/lib/src/features/calculator/domain/entities/enum/bottom_sheet_type.dart @@ -0,0 +1 @@ +enum BottomSheetType { none, from, to } diff --git a/lib/src/features/calculator/domain/entities/enum/currency_type.dart b/lib/src/features/calculator/domain/entities/enum/currency_type.dart new file mode 100644 index 00000000..3bf0b2d7 --- /dev/null +++ b/lib/src/features/calculator/domain/entities/enum/currency_type.dart @@ -0,0 +1 @@ +enum CurrencyType { fiat, crypto } diff --git a/lib/src/features/calculator/domain/entities/exchange_calculation_result.dart b/lib/src/features/calculator/domain/entities/exchange_calculation_result.dart new file mode 100644 index 00000000..d9aa6a56 --- /dev/null +++ b/lib/src/features/calculator/domain/entities/exchange_calculation_result.dart @@ -0,0 +1,9 @@ +final class ExchangeCalculationResult { + final double convertedAmount; + final double exchangeRate; + + const ExchangeCalculationResult({ + required this.convertedAmount, + required this.exchangeRate, + }); +} diff --git a/lib/src/features/calculator/domain/entities/exchange_rate.dart b/lib/src/features/calculator/domain/entities/exchange_rate.dart new file mode 100644 index 00000000..6dcc293a --- /dev/null +++ b/lib/src/features/calculator/domain/entities/exchange_rate.dart @@ -0,0 +1,5 @@ +final class ExchangeRate { + final double fiatToCryptoExchangeRate; + + const ExchangeRate({required this.fiatToCryptoExchangeRate}); +} diff --git a/lib/src/features/calculator/domain/repositories/calculator_repository.dart b/lib/src/features/calculator/domain/repositories/calculator_repository.dart new file mode 100644 index 00000000..4fd48031 --- /dev/null +++ b/lib/src/features/calculator/domain/repositories/calculator_repository.dart @@ -0,0 +1,9 @@ +abstract class CalculatorRepository { + Future getExchangeRate({ + required String cryptoCurrencyId, + required String fiatCurrencyId, + required String amount, + required String amountCurrencyId, + required int type, + }); +} diff --git a/lib/src/features/calculator/domain/repositories/currency_repository.dart b/lib/src/features/calculator/domain/repositories/currency_repository.dart new file mode 100644 index 00000000..94867bb7 --- /dev/null +++ b/lib/src/features/calculator/domain/repositories/currency_repository.dart @@ -0,0 +1,5 @@ +import '../entities/currency.dart'; + +abstract class CurrencyRepository { + Future> getAvailableCurrencies(); +} diff --git a/lib/src/features/calculator/domain/usecases/calculate_crypto_exchange_usecase.dart b/lib/src/features/calculator/domain/usecases/calculate_crypto_exchange_usecase.dart new file mode 100644 index 00000000..52ef9589 --- /dev/null +++ b/lib/src/features/calculator/domain/usecases/calculate_crypto_exchange_usecase.dart @@ -0,0 +1,54 @@ +import '../../../../core/constants/string_constants.dart'; +import '../../domain/entities/enum/currency_type.dart'; +import '../entities/calculate_exchange_params.dart'; +import '../entities/exchange_calculation_result.dart'; +import '../repositories/calculator_repository.dart'; + +/// Use Case: Calcular el cambio entre criptomonedas y monedas fiat +final class CalculateCryptoExchangeUseCase { + final CalculatorRepository _repository; + + const CalculateCryptoExchangeUseCase({ + required CalculatorRepository repository, + }) : _repository = repository; + + Future call({ + required CalculateExchangeParams params, + }) async { + if (params.amount <= 0) { + throw ArgumentError(StringConstants.amountMustBePositiveError); + } + + final bool isCryptoToFiat = + params.fromCurrency.type == CurrencyType.crypto && + params.toCurrency.type == CurrencyType.fiat; + + final int exchangeType = isCryptoToFiat ? 0 : 1; + + final String cryptoCurrencyId = + params.fromCurrency.type == CurrencyType.crypto + ? params.fromCurrency.id + : params.toCurrency.id; + + final String fiatCurrencyId = params.fromCurrency.type == CurrencyType.fiat + ? params.fromCurrency.id + : params.toCurrency.id; + + final double exchangeRate = await _repository.getExchangeRate( + cryptoCurrencyId: cryptoCurrencyId, + fiatCurrencyId: fiatCurrencyId, + amount: params.amount.toString(), + amountCurrencyId: params.fromCurrency.id, + type: exchangeType, + ); + + final convertedAmount = isCryptoToFiat + ? params.amount * exchangeRate + : params.amount / exchangeRate; + + return ExchangeCalculationResult( + convertedAmount: convertedAmount, + exchangeRate: exchangeRate, + ); + } +} diff --git a/lib/src/features/calculator/domain/usecases/get_available_currencies_usecase.dart b/lib/src/features/calculator/domain/usecases/get_available_currencies_usecase.dart new file mode 100644 index 00000000..82a1df07 --- /dev/null +++ b/lib/src/features/calculator/domain/usecases/get_available_currencies_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/currency.dart'; +import '../repositories/currency_repository.dart'; + +final class GetAvailableCurrenciesUseCase { + final CurrencyRepository repository; + + const GetAvailableCurrenciesUseCase({required this.repository}); + + Future> call() async { + return await repository.getAvailableCurrencies(); + } +} diff --git a/lib/src/features/calculator/domain/usecases/validate_exchange_amount_usecase.dart b/lib/src/features/calculator/domain/usecases/validate_exchange_amount_usecase.dart new file mode 100644 index 00000000..2d4975ba --- /dev/null +++ b/lib/src/features/calculator/domain/usecases/validate_exchange_amount_usecase.dart @@ -0,0 +1,44 @@ +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/constants/validation_constants.dart'; + +/// Result de validación de monto +sealed class AmountValidationResult { + const AmountValidationResult(); +} + +/// Validación exitosa +final class AmountValidationSuccess extends AmountValidationResult { + const AmountValidationSuccess(); +} + +/// Validación fallida con mensaje de error +final class AmountValidationFailure extends AmountValidationResult { + final String errorMessage; + + const AmountValidationFailure(this.errorMessage); +} + +/// Use Case para validar el monto de intercambio +/// +/// Centraliza todas las reglas de validación de montos: +/// - Debe ser mayor a 0 +/// - No debe exceder el límite máximo +/// +/// Retorna un [AmountValidationResult] que puede ser Success o Failure. +final class ValidateExchangeAmountUseCase { + const ValidateExchangeAmountUseCase(); + + AmountValidationResult call(double amount) { + if (amount <= ValidationConstants.minExchangeAmount) { + return const AmountValidationFailure(StringConstants.invalidAmountError); + } + + if (amount > ValidationConstants.maxExchangeAmount) { + return const AmountValidationFailure( + StringConstants.amountExceedsLimitError, + ); + } + + return const AmountValidationSuccess(); + } +} diff --git a/lib/src/features/calculator/presentation/providers/amount_input_field_controller.dart b/lib/src/features/calculator/presentation/providers/amount_input_field_controller.dart new file mode 100644 index 00000000..f45678bb --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/amount_input_field_controller.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../states/amount_input_field_state.dart'; + +part 'amount_input_field_controller.g.dart'; + +@riverpod +final class AmountInputFieldController extends _$AmountInputFieldController { + late final TextEditingController textController; + + @override + AmountInputFieldState build() { + textController = TextEditingController(); + + ref.onDispose(() => textController.dispose()); + + return const AmountInputFieldState(); + } + + void onTextChanged({required String value}) { + final cleanValue = value.replaceAll(',', ''); + final amount = double.tryParse(cleanValue) ?? 0.0; + state = state.copyWith(amount: amount, errorText: null); + } + + void setError({required String? errorText}) => + state = state.copyWith(errorText: errorText); + + void selectAll() => textController.selection = TextSelection( + baseOffset: 0, + extentOffset: textController.text.length, + ); +} diff --git a/lib/src/features/calculator/presentation/providers/amount_input_field_controller.g.dart b/lib/src/features/calculator/presentation/providers/amount_input_field_controller.g.dart new file mode 100644 index 00000000..99e76761 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/amount_input_field_controller.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'amount_input_field_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AmountInputFieldController) +const amountInputFieldControllerProvider = + AmountInputFieldControllerProvider._(); + +final class AmountInputFieldControllerProvider + extends + $NotifierProvider { + const AmountInputFieldControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'amountInputFieldControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$amountInputFieldControllerHash(); + + @$internal + @override + AmountInputFieldController create() => AmountInputFieldController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AmountInputFieldState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$amountInputFieldControllerHash() => + r'1cc24033a231044d02d3cff7a925a62c8e39185e'; + +abstract class _$AmountInputFieldController + extends $Notifier { + AmountInputFieldState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + AmountInputFieldState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/src/features/calculator/presentation/providers/calculator_controller.dart b/lib/src/features/calculator/presentation/providers/calculator_controller.dart new file mode 100644 index 00000000..2cee7349 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/calculator_controller.dart @@ -0,0 +1,170 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/errors/failures.dart'; +import '../../domain/entities/calculate_exchange_params.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/entities/exchange_calculation_result.dart'; +import '../../domain/usecases/validate_exchange_amount_usecase.dart'; +import '../states/calculator_state.dart'; +import 'providers.dart'; + +part 'calculator_controller.g.dart'; + +@riverpod +final class CalculatorController extends _$CalculatorController { + final ValidateExchangeAmountUseCase _validateAmountUseCase = + const ValidateExchangeAmountUseCase(); + + @override + CalculatorState build() { + final AsyncValue> currenciesAsync = ref.watch( + availableCurrenciesProvider, + ); + + return currenciesAsync.when( + data: (currencies) { + final Currency usdt = currencies.firstWhere( + (c) => c.id == 'TATUM-TRON-USDT', + ); + final Currency ves = currencies.firstWhere((c) => c.id == 'VES'); + + return CalculatorState(fromCurrency: usdt, toCurrency: ves); + }, + loading: () => const CalculatorState(), + error: (error, stack) => const CalculatorState(), + ); + } + + void setFromCurrency({required Currency currency}) { + state = _updateCurrency( + currency: currency, + isFrom: true, + currentFrom: state.fromCurrency, + currentTo: state.toCurrency, + ); + } + + void setToCurrency({required Currency currency}) { + state = _updateCurrency( + currency: currency, + isFrom: false, + currentFrom: state.fromCurrency, + currentTo: state.toCurrency, + ); + } + + void setAmount({required double amount}) { + state = state.copyWith( + amount: amount, + errorMessage: null, + inputFieldError: null, + ); + } + + void swapCurrencies() { + if (!state.canSwap) return; + + state = state.copyWith( + fromCurrency: state.toCurrency, + toCurrency: state.fromCurrency, + convertedAmount: null, + estimatedRate: null, + errorMessage: null, + inputFieldError: null, + ); + } + + Future calculateExchange() async { + if (!state.canCalculate) return; + + final AmountValidationResult validationResult = _validateAmountUseCase( + state.amount, + ); + + if (validationResult is AmountValidationFailure) { + _handleValidationError(validationResult.errorMessage); + return; + } + + state = state.copyWith( + isLoading: true, + errorMessage: null, + inputFieldError: null, + ); + + try { + final result = await _performCalculation(); + _handleCalculationSuccess(result); + } on Failure catch (failure) { + _handleCalculationError(failure.message); + } catch (e) { + _handleCalculationError(StringConstants.errorMessage); + } + } + + CalculatorState _updateCurrency({ + required Currency currency, + required bool isFrom, + required Currency? currentFrom, + required Currency? currentTo, + }) { + final otherCurrency = isFrom ? currentTo : currentFrom; + + if (otherCurrency != null && currency.type == otherCurrency.type) { + return state.copyWith( + fromCurrency: isFrom ? currency : otherCurrency, + toCurrency: isFrom ? otherCurrency : currency, + errorMessage: null, + convertedAmount: null, + estimatedRate: null, + inputFieldError: null, + ); + } + + return state.copyWith( + fromCurrency: isFrom ? currency : currentFrom, + toCurrency: isFrom ? currentTo : currency, + errorMessage: null, + convertedAmount: null, + estimatedRate: null, + inputFieldError: null, + ); + } + + /// Realiza el cálculo de intercambio + Future _performCalculation() async { + final calculateUseCase = ref.read(calculateCryptoExchangeUseCaseProvider); + + final params = CalculateExchangeParams( + fromCurrency: state.fromCurrency!, + toCurrency: state.toCurrency!, + amount: state.amount, + ); + + return await calculateUseCase(params: params); + } + + /// Maneja el resultado exitoso del cálculo + void _handleCalculationSuccess(ExchangeCalculationResult result) { + state = state.copyWith( + isLoading: false, + convertedAmount: result.convertedAmount, + estimatedRate: result.exchangeRate, + ); + } + + /// Maneja errores de validación + void _handleValidationError(String errorMessage) { + state = state.copyWith(inputFieldError: errorMessage); + } + + /// Maneja errores durante el cálculo + void _handleCalculationError(String errorMessage) { + state = state.copyWith( + isLoading: false, + errorMessage: errorMessage, + inputFieldError: errorMessage, + ); + } +} diff --git a/lib/src/features/calculator/presentation/providers/calculator_controller.g.dart b/lib/src/features/calculator/presentation/providers/calculator_controller.g.dart new file mode 100644 index 00000000..20ba7a5b --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/calculator_controller.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'calculator_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CalculatorController) +const calculatorControllerProvider = CalculatorControllerProvider._(); + +final class CalculatorControllerProvider + extends $NotifierProvider { + const CalculatorControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'calculatorControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$calculatorControllerHash(); + + @$internal + @override + CalculatorController create() => CalculatorController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CalculatorState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$calculatorControllerHash() => + r'350199e59a6ab62077009e1bd5c2ed8f39f5717b'; + +abstract class _$CalculatorController extends $Notifier { + CalculatorState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + CalculatorState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.dart b/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.dart new file mode 100644 index 00000000..001632a4 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.dart @@ -0,0 +1,18 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/enum/bottom_sheet_type.dart'; + +part 'currency_bottom_sheet_controller.g.dart'; + +@riverpod +final class CurrencyBottomSheetController + extends _$CurrencyBottomSheetController { + @override + BottomSheetType build() => BottomSheetType.none; + + void openFrom() => state = BottomSheetType.from; + + void openTo() => state = BottomSheetType.to; + + void close() => state = BottomSheetType.none; +} diff --git a/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.g.dart b/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.g.dart new file mode 100644 index 00000000..a6d7ee68 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/currency_bottom_sheet_controller.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'currency_bottom_sheet_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CurrencyBottomSheetController) +const currencyBottomSheetControllerProvider = + CurrencyBottomSheetControllerProvider._(); + +final class CurrencyBottomSheetControllerProvider + extends $NotifierProvider { + const CurrencyBottomSheetControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currencyBottomSheetControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currencyBottomSheetControllerHash(); + + @$internal + @override + CurrencyBottomSheetController create() => CurrencyBottomSheetController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(BottomSheetType value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currencyBottomSheetControllerHash() => + r'd0c8b81d0315d0193e581c33d661d63452641928'; + +abstract class _$CurrencyBottomSheetController + extends $Notifier { + BottomSheetType build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + BottomSheetType, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.dart b/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.dart new file mode 100644 index 00000000..7772a507 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.dart @@ -0,0 +1,30 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/currency_type.dart'; + +part 'exchange_rate_display_provider.g.dart'; + +/// Provider que calcula la tasa de visualización según el tipo de conversión +/// +/// Invierte la tasa cuando es una conversión de fiat a crypto para +/// mostrar siempre la tasa desde la perspectiva de la moneda origen. +@riverpod +double? exchangeRateDisplay( + Ref ref, { + required double? estimatedRate, + required Currency? fromCurrency, + required Currency? toCurrency, +}) { + if (estimatedRate == null || + fromCurrency == null || + toCurrency == null) { + return null; + } + + final bool isCryptoToFiat = fromCurrency.type == CurrencyType.crypto && + toCurrency.type == CurrencyType.fiat; + + return isCryptoToFiat ? estimatedRate : 1 / estimatedRate; +} + diff --git a/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.g.dart b/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.g.dart new file mode 100644 index 00000000..6544abb3 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/exchange_rate_display_provider.g.dart @@ -0,0 +1,145 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exchange_rate_display_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider que calcula la tasa de visualización según el tipo de conversión +/// +/// Invierte la tasa cuando es una conversión de fiat a crypto para +/// mostrar siempre la tasa desde la perspectiva de la moneda origen. + +@ProviderFor(exchangeRateDisplay) +const exchangeRateDisplayProvider = ExchangeRateDisplayFamily._(); + +/// Provider que calcula la tasa de visualización según el tipo de conversión +/// +/// Invierte la tasa cuando es una conversión de fiat a crypto para +/// mostrar siempre la tasa desde la perspectiva de la moneda origen. + +final class ExchangeRateDisplayProvider + extends $FunctionalProvider + with $Provider { + /// Provider que calcula la tasa de visualización según el tipo de conversión + /// + /// Invierte la tasa cuando es una conversión de fiat a crypto para + /// mostrar siempre la tasa desde la perspectiva de la moneda origen. + const ExchangeRateDisplayProvider._({ + required ExchangeRateDisplayFamily super.from, + required ({ + double? estimatedRate, + Currency? fromCurrency, + Currency? toCurrency, + }) + super.argument, + }) : super( + retry: null, + name: r'exchangeRateDisplayProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$exchangeRateDisplayHash(); + + @override + String toString() { + return r'exchangeRateDisplayProvider' + '' + '$argument'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + double? create(Ref ref) { + final argument = + this.argument + as ({ + double? estimatedRate, + Currency? fromCurrency, + Currency? toCurrency, + }); + return exchangeRateDisplay( + ref, + estimatedRate: argument.estimatedRate, + fromCurrency: argument.fromCurrency, + toCurrency: argument.toCurrency, + ); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(double? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ExchangeRateDisplayProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$exchangeRateDisplayHash() => + r'587162ddb28600a3dfe663100f90a392bec08696'; + +/// Provider que calcula la tasa de visualización según el tipo de conversión +/// +/// Invierte la tasa cuando es una conversión de fiat a crypto para +/// mostrar siempre la tasa desde la perspectiva de la moneda origen. + +final class ExchangeRateDisplayFamily extends $Family + with + $FunctionalFamilyOverride< + double?, + ({ + double? estimatedRate, + Currency? fromCurrency, + Currency? toCurrency, + }) + > { + const ExchangeRateDisplayFamily._() + : super( + retry: null, + name: r'exchangeRateDisplayProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider que calcula la tasa de visualización según el tipo de conversión + /// + /// Invierte la tasa cuando es una conversión de fiat a crypto para + /// mostrar siempre la tasa desde la perspectiva de la moneda origen. + + ExchangeRateDisplayProvider call({ + required double? estimatedRate, + required Currency? fromCurrency, + required Currency? toCurrency, + }) => ExchangeRateDisplayProvider._( + argument: ( + estimatedRate: estimatedRate, + fromCurrency: fromCurrency, + toCurrency: toCurrency, + ), + from: this, + ); + + @override + String toString() => r'exchangeRateDisplayProvider'; +} diff --git a/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.dart b/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.dart new file mode 100644 index 00000000..41df1386 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.dart @@ -0,0 +1,22 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/currency_type.dart'; +import 'providers.dart'; + +part 'filtered_currencies_provider.g.dart'; + +/// Provider que filtra las monedas disponibles por tipo +/// +/// Retorna solo las monedas del tipo especificado (crypto o fiat), +/// facilitando la presentación en diferentes selectores. +@riverpod +Future> filteredCurrencies( + Ref ref, { + required CurrencyType currencyType, +}) async { + final allCurrencies = await ref.watch(availableCurrenciesProvider.future); + + return allCurrencies.where((c) => c.type == currencyType).toList(); +} + diff --git a/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.g.dart b/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.g.dart new file mode 100644 index 00000000..9b660018 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/filtered_currencies_provider.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'filtered_currencies_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider que filtra las monedas disponibles por tipo +/// +/// Retorna solo las monedas del tipo especificado (crypto o fiat), +/// facilitando la presentación en diferentes selectores. + +@ProviderFor(filteredCurrencies) +const filteredCurrenciesProvider = FilteredCurrenciesFamily._(); + +/// Provider que filtra las monedas disponibles por tipo +/// +/// Retorna solo las monedas del tipo especificado (crypto o fiat), +/// facilitando la presentación en diferentes selectores. + +final class FilteredCurrenciesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// Provider que filtra las monedas disponibles por tipo + /// + /// Retorna solo las monedas del tipo especificado (crypto o fiat), + /// facilitando la presentación en diferentes selectores. + const FilteredCurrenciesProvider._({ + required FilteredCurrenciesFamily super.from, + required CurrencyType super.argument, + }) : super( + retry: null, + name: r'filteredCurrenciesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$filteredCurrenciesHash(); + + @override + String toString() { + return r'filteredCurrenciesProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as CurrencyType; + return filteredCurrencies(ref, currencyType: argument); + } + + @override + bool operator ==(Object other) { + return other is FilteredCurrenciesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$filteredCurrenciesHash() => + r'0cf2f51de851d7e02c3ce6a051042ad06a73dcd4'; + +/// Provider que filtra las monedas disponibles por tipo +/// +/// Retorna solo las monedas del tipo especificado (crypto o fiat), +/// facilitando la presentación en diferentes selectores. + +final class FilteredCurrenciesFamily extends $Family + with $FunctionalFamilyOverride>, CurrencyType> { + const FilteredCurrenciesFamily._() + : super( + retry: null, + name: r'filteredCurrenciesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider que filtra las monedas disponibles por tipo + /// + /// Retorna solo las monedas del tipo especificado (crypto o fiat), + /// facilitando la presentación en diferentes selectores. + + FilteredCurrenciesProvider call({required CurrencyType currencyType}) => + FilteredCurrenciesProvider._(argument: currencyType, from: this); + + @override + String toString() => r'filteredCurrenciesProvider'; +} diff --git a/lib/src/features/calculator/presentation/providers/providers.dart b/lib/src/features/calculator/presentation/providers/providers.dart new file mode 100644 index 00000000..3ad9eff1 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/providers.dart @@ -0,0 +1,73 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/network/dio_client.dart'; +import '../../data/datasources/currency_local_datasource.dart'; +import '../../data/datasources/currency_local_datasource_impl.dart'; +import '../../data/datasources/exchange_rate_remote_datasource_impl.dart'; +import '../../data/repositories/calculator_repository_impl.dart'; +import '../../data/repositories/currency_repository_impl.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/repositories/calculator_repository.dart'; +import '../../domain/repositories/currency_repository.dart'; +import '../../domain/usecases/calculate_crypto_exchange_usecase.dart'; +import '../../domain/usecases/get_available_currencies_usecase.dart'; + +part 'providers.g.dart'; + +@riverpod +Dio dio(Ref ref) => DioClient.instance; + +@riverpod +ExchangeRateRemoteDataSourceImpl exchangeRateRemoteDataSource(Ref ref) => + ExchangeRateRemoteDataSourceImpl(dio: ref.read(dioProvider)); + +@riverpod +CalculatorRepository calculatorRepository(Ref ref) => CalculatorRepositoryImpl( + exchangeRateRemoteDataSource: ref.read(exchangeRateRemoteDataSourceProvider), +); + +@riverpod +CalculateCryptoExchangeUseCase calculateCryptoExchangeUseCase(Ref ref) => + CalculateCryptoExchangeUseCase( + repository: ref.read(calculatorRepositoryProvider), + ); + +@riverpod +CurrencyLocalDataSource currencyLocalDataSource(Ref ref) => + const CurrencyLocalDataSourceImpl(); + +@riverpod +CurrencyRepository currencyRepository(Ref ref) => CurrencyRepositoryImpl( + localDataSource: ref.read(currencyLocalDataSourceProvider), +); + +@riverpod +GetAvailableCurrenciesUseCase getAvailableCurrenciesUseCase(Ref ref) => + GetAvailableCurrenciesUseCase( + repository: ref.read(currencyRepositoryProvider), + ); + +@riverpod +Future> availableCurrencies(Ref ref) async { + final GetAvailableCurrenciesUseCase useCase = ref.read( + getAvailableCurrenciesUseCaseProvider, + ); + return await useCase(); +} + +@riverpod +Currency defaultCryptoCurrency(Ref ref) { + final List currencies = ref + .watch(availableCurrenciesProvider) + .requireValue; + return currencies.firstWhere((c) => c.id == 'TATUM-TRON-USDT'); +} + +@riverpod +Currency defaultFiatCurrency(Ref ref) { + final List currencies = ref + .watch(availableCurrenciesProvider) + .requireValue; + return currencies.firstWhere((c) => c.id == 'VES'); +} diff --git a/lib/src/features/calculator/presentation/providers/providers.g.dart b/lib/src/features/calculator/presentation/providers/providers.g.dart new file mode 100644 index 00000000..8fca82c9 --- /dev/null +++ b/lib/src/features/calculator/presentation/providers/providers.g.dart @@ -0,0 +1,471 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(dio) +const dioProvider = DioProvider._(); + +final class DioProvider extends $FunctionalProvider + with $Provider { + const DioProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dioProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dioHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Dio create(Ref ref) { + return dio(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Dio value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$dioHash() => r'fb5e1293a1d2f2aca782a019e4e71351db0ad1ab'; + +@ProviderFor(exchangeRateRemoteDataSource) +const exchangeRateRemoteDataSourceProvider = + ExchangeRateRemoteDataSourceProvider._(); + +final class ExchangeRateRemoteDataSourceProvider + extends + $FunctionalProvider< + ExchangeRateRemoteDataSourceImpl, + ExchangeRateRemoteDataSourceImpl, + ExchangeRateRemoteDataSourceImpl + > + with $Provider { + const ExchangeRateRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'exchangeRateRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$exchangeRateRemoteDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ExchangeRateRemoteDataSourceImpl create(Ref ref) { + return exchangeRateRemoteDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ExchangeRateRemoteDataSourceImpl value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$exchangeRateRemoteDataSourceHash() => + r'2114e183cc38b2c7cfc6d3630762182ee1cf602a'; + +@ProviderFor(calculatorRepository) +const calculatorRepositoryProvider = CalculatorRepositoryProvider._(); + +final class CalculatorRepositoryProvider + extends + $FunctionalProvider< + CalculatorRepository, + CalculatorRepository, + CalculatorRepository + > + with $Provider { + const CalculatorRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'calculatorRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$calculatorRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CalculatorRepository create(Ref ref) { + return calculatorRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CalculatorRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$calculatorRepositoryHash() => + r'a932b0a7f40e74725b8923d93afab78ab7dc30c4'; + +@ProviderFor(calculateCryptoExchangeUseCase) +const calculateCryptoExchangeUseCaseProvider = + CalculateCryptoExchangeUseCaseProvider._(); + +final class CalculateCryptoExchangeUseCaseProvider + extends + $FunctionalProvider< + CalculateCryptoExchangeUseCase, + CalculateCryptoExchangeUseCase, + CalculateCryptoExchangeUseCase + > + with $Provider { + const CalculateCryptoExchangeUseCaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'calculateCryptoExchangeUseCaseProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$calculateCryptoExchangeUseCaseHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CalculateCryptoExchangeUseCase create(Ref ref) { + return calculateCryptoExchangeUseCase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CalculateCryptoExchangeUseCase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$calculateCryptoExchangeUseCaseHash() => + r'cdc2a18aa9672dbd95dcac9016fd358f283eac2f'; + +@ProviderFor(currencyLocalDataSource) +const currencyLocalDataSourceProvider = CurrencyLocalDataSourceProvider._(); + +final class CurrencyLocalDataSourceProvider + extends + $FunctionalProvider< + CurrencyLocalDataSource, + CurrencyLocalDataSource, + CurrencyLocalDataSource + > + with $Provider { + const CurrencyLocalDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currencyLocalDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currencyLocalDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CurrencyLocalDataSource create(Ref ref) { + return currencyLocalDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CurrencyLocalDataSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currencyLocalDataSourceHash() => + r'207f1c539760104831a3c0041fa0de576fc3fd45'; + +@ProviderFor(currencyRepository) +const currencyRepositoryProvider = CurrencyRepositoryProvider._(); + +final class CurrencyRepositoryProvider + extends + $FunctionalProvider< + CurrencyRepository, + CurrencyRepository, + CurrencyRepository + > + with $Provider { + const CurrencyRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currencyRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currencyRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CurrencyRepository create(Ref ref) { + return currencyRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CurrencyRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currencyRepositoryHash() => + r'b7519b9d2ae591a658bc1e3cc81aaed7cd5f7018'; + +@ProviderFor(getAvailableCurrenciesUseCase) +const getAvailableCurrenciesUseCaseProvider = + GetAvailableCurrenciesUseCaseProvider._(); + +final class GetAvailableCurrenciesUseCaseProvider + extends + $FunctionalProvider< + GetAvailableCurrenciesUseCase, + GetAvailableCurrenciesUseCase, + GetAvailableCurrenciesUseCase + > + with $Provider { + const GetAvailableCurrenciesUseCaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'getAvailableCurrenciesUseCaseProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$getAvailableCurrenciesUseCaseHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + GetAvailableCurrenciesUseCase create(Ref ref) { + return getAvailableCurrenciesUseCase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GetAvailableCurrenciesUseCase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$getAvailableCurrenciesUseCaseHash() => + r'3b46da41f0bd50241de23340966fa4fb2fec4d96'; + +@ProviderFor(availableCurrencies) +const availableCurrenciesProvider = AvailableCurrenciesProvider._(); + +final class AvailableCurrenciesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + const AvailableCurrenciesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'availableCurrenciesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$availableCurrenciesHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return availableCurrencies(ref); + } +} + +String _$availableCurrenciesHash() => + r'6d817799ed5ee10a628d91afda08892fd59236f0'; + +@ProviderFor(defaultCryptoCurrency) +const defaultCryptoCurrencyProvider = DefaultCryptoCurrencyProvider._(); + +final class DefaultCryptoCurrencyProvider + extends $FunctionalProvider + with $Provider { + const DefaultCryptoCurrencyProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'defaultCryptoCurrencyProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$defaultCryptoCurrencyHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Currency create(Ref ref) { + return defaultCryptoCurrency(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Currency value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$defaultCryptoCurrencyHash() => + r'21e5361c97090785ce917a99620ec6d740e0e1f1'; + +@ProviderFor(defaultFiatCurrency) +const defaultFiatCurrencyProvider = DefaultFiatCurrencyProvider._(); + +final class DefaultFiatCurrencyProvider + extends $FunctionalProvider + with $Provider { + const DefaultFiatCurrencyProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'defaultFiatCurrencyProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$defaultFiatCurrencyHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Currency create(Ref ref) { + return defaultFiatCurrency(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Currency value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$defaultFiatCurrencyHash() => + r'6e94296a9a4d3150e03b9c92b68c4ba7001da280'; diff --git a/lib/src/features/calculator/presentation/screens/calculator_screen.dart b/lib/src/features/calculator/presentation/screens/calculator_screen.dart new file mode 100644 index 00000000..7db416c0 --- /dev/null +++ b/lib/src/features/calculator/presentation/screens/calculator_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/ui/painters/background_painter.dart'; +import '../../../../core/widgets/loading_button.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/currency_type.dart'; +import '../providers/calculator_controller.dart'; +import '../providers/currency_bottom_sheet_controller.dart'; +import '../states/calculator_state.dart'; +import '../widgets/amount_input_field.dart'; +import '../widgets/currency_bottom_sheet.dart'; +import '../widgets/currency_exchange_bar.dart'; +import '../widgets/exchange_info_card.dart'; + +final class CalculatorScreen extends ConsumerWidget { + const CalculatorScreen({super.key}); + + Future _showCurrencyBottomSheet({ + required BuildContext context, + required WidgetRef ref, + required Currency? currency, + required void Function(Currency) onCurrencySelected, + required bool isFromSelector, + }) async { + if (isFromSelector) { + ref.read(currencyBottomSheetControllerProvider.notifier).openFrom(); + } else { + ref.read(currencyBottomSheetControllerProvider.notifier).openTo(); + } + + final CurrencyType currencyType = currency?.type ?? CurrencyType.crypto; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => CurrencyBottomSheet( + selectedCurrency: currency, + onCurrencySelected: onCurrencySelected, + currencyType: currencyType, + ), + ).whenComplete( + () => ref.read(currencyBottomSheetControllerProvider.notifier).close(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final CalculatorState state = ref.watch(calculatorControllerProvider); + final CalculatorController controller = ref.read( + calculatorControllerProvider.notifier, + ); + + return Scaffold( + body: SafeArea( + child: CustomPaint( + painter: BackgroundPainter(), + child: Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 38.0, + ), + child: Column( + spacing: 16.0, + children: [ + CurrencyExchangeBar( + fromCurrency: state.fromCurrency, + toCurrency: state.toCurrency, + onFromTap: () => _showCurrencyBottomSheet( + context: context, + ref: ref, + currency: state.fromCurrency, + onCurrencySelected: (currency) => + controller.setFromCurrency(currency: currency), + isFromSelector: true, + ), + onSwap: controller.swapCurrencies, + onToTap: () => _showCurrencyBottomSheet( + context: context, + ref: ref, + currency: state.toCurrency, + onCurrencySelected: (currency) => + controller.setToCurrency(currency: currency), + isFromSelector: false, + ), + canSwap: state.canSwap, + ), + const AmountInputField(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: ExchangeInfoCard( + estimatedRate: state.estimatedRate, + convertedAmount: state.convertedAmount, + fromCurrency: state.fromCurrency, + toCurrency: state.toCurrency, + isLoading: state.isLoading, + ), + ), + LoadingButton( + text: StringConstants.exchangeButtonText, + onPressed: controller.calculateExchange, + isLoading: state.isLoading, + isEnabled: state.canCalculate, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/features/calculator/presentation/states/amount_input_field_state.dart b/lib/src/features/calculator/presentation/states/amount_input_field_state.dart new file mode 100644 index 00000000..56368278 --- /dev/null +++ b/lib/src/features/calculator/presentation/states/amount_input_field_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'amount_input_field_state.freezed.dart'; + +@freezed +abstract class AmountInputFieldState with _$AmountInputFieldState { + const factory AmountInputFieldState({ + @Default(0.0) double amount, + String? errorText, + }) = _AmountInputFieldState; + + const AmountInputFieldState._(); + + bool get hasValue => amount > 0; +} diff --git a/lib/src/features/calculator/presentation/states/amount_input_field_state.freezed.dart b/lib/src/features/calculator/presentation/states/amount_input_field_state.freezed.dart new file mode 100644 index 00000000..689fd943 --- /dev/null +++ b/lib/src/features/calculator/presentation/states/amount_input_field_state.freezed.dart @@ -0,0 +1,274 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'amount_input_field_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AmountInputFieldState { + + double get amount; String? get errorText; +/// Create a copy of AmountInputFieldState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AmountInputFieldStateCopyWith get copyWith => _$AmountInputFieldStateCopyWithImpl(this as AmountInputFieldState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AmountInputFieldState&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.errorText, errorText) || other.errorText == errorText)); +} + + +@override +int get hashCode => Object.hash(runtimeType,amount,errorText); + +@override +String toString() { + return 'AmountInputFieldState(amount: $amount, errorText: $errorText)'; +} + + +} + +/// @nodoc +abstract mixin class $AmountInputFieldStateCopyWith<$Res> { + factory $AmountInputFieldStateCopyWith(AmountInputFieldState value, $Res Function(AmountInputFieldState) _then) = _$AmountInputFieldStateCopyWithImpl; +@useResult +$Res call({ + double amount, String? errorText +}); + + + + +} +/// @nodoc +class _$AmountInputFieldStateCopyWithImpl<$Res> + implements $AmountInputFieldStateCopyWith<$Res> { + _$AmountInputFieldStateCopyWithImpl(this._self, this._then); + + final AmountInputFieldState _self; + final $Res Function(AmountInputFieldState) _then; + +/// Create a copy of AmountInputFieldState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? amount = null,Object? errorText = freezed,}) { + return _then(_self.copyWith( +amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as double,errorText: freezed == errorText ? _self.errorText : errorText // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AmountInputFieldState]. +extension AmountInputFieldStatePatterns on AmountInputFieldState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AmountInputFieldState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AmountInputFieldState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AmountInputFieldState value) $default,){ +final _that = this; +switch (_that) { +case _AmountInputFieldState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AmountInputFieldState value)? $default,){ +final _that = this; +switch (_that) { +case _AmountInputFieldState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double amount, String? errorText)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AmountInputFieldState() when $default != null: +return $default(_that.amount,_that.errorText);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double amount, String? errorText) $default,) {final _that = this; +switch (_that) { +case _AmountInputFieldState(): +return $default(_that.amount,_that.errorText);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double amount, String? errorText)? $default,) {final _that = this; +switch (_that) { +case _AmountInputFieldState() when $default != null: +return $default(_that.amount,_that.errorText);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AmountInputFieldState extends AmountInputFieldState { + const _AmountInputFieldState({this.amount = 0.0, this.errorText}): super._(); + + +@override@JsonKey() final double amount; +@override final String? errorText; + +/// Create a copy of AmountInputFieldState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AmountInputFieldStateCopyWith<_AmountInputFieldState> get copyWith => __$AmountInputFieldStateCopyWithImpl<_AmountInputFieldState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AmountInputFieldState&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.errorText, errorText) || other.errorText == errorText)); +} + + +@override +int get hashCode => Object.hash(runtimeType,amount,errorText); + +@override +String toString() { + return 'AmountInputFieldState(amount: $amount, errorText: $errorText)'; +} + + +} + +/// @nodoc +abstract mixin class _$AmountInputFieldStateCopyWith<$Res> implements $AmountInputFieldStateCopyWith<$Res> { + factory _$AmountInputFieldStateCopyWith(_AmountInputFieldState value, $Res Function(_AmountInputFieldState) _then) = __$AmountInputFieldStateCopyWithImpl; +@override @useResult +$Res call({ + double amount, String? errorText +}); + + + + +} +/// @nodoc +class __$AmountInputFieldStateCopyWithImpl<$Res> + implements _$AmountInputFieldStateCopyWith<$Res> { + __$AmountInputFieldStateCopyWithImpl(this._self, this._then); + + final _AmountInputFieldState _self; + final $Res Function(_AmountInputFieldState) _then; + +/// Create a copy of AmountInputFieldState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? amount = null,Object? errorText = freezed,}) { + return _then(_AmountInputFieldState( +amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as double,errorText: freezed == errorText ? _self.errorText : errorText // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/src/features/calculator/presentation/states/calculator_state.dart b/lib/src/features/calculator/presentation/states/calculator_state.dart new file mode 100644 index 00000000..7316ffb9 --- /dev/null +++ b/lib/src/features/calculator/presentation/states/calculator_state.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/currency.dart'; + +part 'calculator_state.freezed.dart'; + +@freezed +abstract class CalculatorState with _$CalculatorState { + const factory CalculatorState({ + Currency? fromCurrency, + Currency? toCurrency, + @Default(0.0) double amount, + double? convertedAmount, + double? estimatedRate, + @Default(false) bool isLoading, + String? errorMessage, + String? inputFieldError, + }) = _CalculatorState; + + const CalculatorState._(); + + bool get canCalculate => + fromCurrency != null && toCurrency != null && amount > 0; + + bool get canSwap => fromCurrency != null && toCurrency != null; +} diff --git a/lib/src/features/calculator/presentation/states/calculator_state.freezed.dart b/lib/src/features/calculator/presentation/states/calculator_state.freezed.dart new file mode 100644 index 00000000..1f37bef6 --- /dev/null +++ b/lib/src/features/calculator/presentation/states/calculator_state.freezed.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'calculator_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CalculatorState { + + Currency? get fromCurrency; Currency? get toCurrency; double get amount; double? get convertedAmount; double? get estimatedRate; bool get isLoading; String? get errorMessage; String? get inputFieldError; +/// Create a copy of CalculatorState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalculatorStateCopyWith get copyWith => _$CalculatorStateCopyWithImpl(this as CalculatorState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalculatorState&&(identical(other.fromCurrency, fromCurrency) || other.fromCurrency == fromCurrency)&&(identical(other.toCurrency, toCurrency) || other.toCurrency == toCurrency)&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.convertedAmount, convertedAmount) || other.convertedAmount == convertedAmount)&&(identical(other.estimatedRate, estimatedRate) || other.estimatedRate == estimatedRate)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.inputFieldError, inputFieldError) || other.inputFieldError == inputFieldError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,fromCurrency,toCurrency,amount,convertedAmount,estimatedRate,isLoading,errorMessage,inputFieldError); + +@override +String toString() { + return 'CalculatorState(fromCurrency: $fromCurrency, toCurrency: $toCurrency, amount: $amount, convertedAmount: $convertedAmount, estimatedRate: $estimatedRate, isLoading: $isLoading, errorMessage: $errorMessage, inputFieldError: $inputFieldError)'; +} + + +} + +/// @nodoc +abstract mixin class $CalculatorStateCopyWith<$Res> { + factory $CalculatorStateCopyWith(CalculatorState value, $Res Function(CalculatorState) _then) = _$CalculatorStateCopyWithImpl; +@useResult +$Res call({ + Currency? fromCurrency, Currency? toCurrency, double amount, double? convertedAmount, double? estimatedRate, bool isLoading, String? errorMessage, String? inputFieldError +}); + + + + +} +/// @nodoc +class _$CalculatorStateCopyWithImpl<$Res> + implements $CalculatorStateCopyWith<$Res> { + _$CalculatorStateCopyWithImpl(this._self, this._then); + + final CalculatorState _self; + final $Res Function(CalculatorState) _then; + +/// Create a copy of CalculatorState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? fromCurrency = freezed,Object? toCurrency = freezed,Object? amount = null,Object? convertedAmount = freezed,Object? estimatedRate = freezed,Object? isLoading = null,Object? errorMessage = freezed,Object? inputFieldError = freezed,}) { + return _then(_self.copyWith( +fromCurrency: freezed == fromCurrency ? _self.fromCurrency : fromCurrency // ignore: cast_nullable_to_non_nullable +as Currency?,toCurrency: freezed == toCurrency ? _self.toCurrency : toCurrency // ignore: cast_nullable_to_non_nullable +as Currency?,amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as double,convertedAmount: freezed == convertedAmount ? _self.convertedAmount : convertedAmount // ignore: cast_nullable_to_non_nullable +as double?,estimatedRate: freezed == estimatedRate ? _self.estimatedRate : estimatedRate // ignore: cast_nullable_to_non_nullable +as double?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,inputFieldError: freezed == inputFieldError ? _self.inputFieldError : inputFieldError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CalculatorState]. +extension CalculatorStatePatterns on CalculatorState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CalculatorState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CalculatorState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CalculatorState value) $default,){ +final _that = this; +switch (_that) { +case _CalculatorState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CalculatorState value)? $default,){ +final _that = this; +switch (_that) { +case _CalculatorState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Currency? fromCurrency, Currency? toCurrency, double amount, double? convertedAmount, double? estimatedRate, bool isLoading, String? errorMessage, String? inputFieldError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CalculatorState() when $default != null: +return $default(_that.fromCurrency,_that.toCurrency,_that.amount,_that.convertedAmount,_that.estimatedRate,_that.isLoading,_that.errorMessage,_that.inputFieldError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Currency? fromCurrency, Currency? toCurrency, double amount, double? convertedAmount, double? estimatedRate, bool isLoading, String? errorMessage, String? inputFieldError) $default,) {final _that = this; +switch (_that) { +case _CalculatorState(): +return $default(_that.fromCurrency,_that.toCurrency,_that.amount,_that.convertedAmount,_that.estimatedRate,_that.isLoading,_that.errorMessage,_that.inputFieldError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Currency? fromCurrency, Currency? toCurrency, double amount, double? convertedAmount, double? estimatedRate, bool isLoading, String? errorMessage, String? inputFieldError)? $default,) {final _that = this; +switch (_that) { +case _CalculatorState() when $default != null: +return $default(_that.fromCurrency,_that.toCurrency,_that.amount,_that.convertedAmount,_that.estimatedRate,_that.isLoading,_that.errorMessage,_that.inputFieldError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CalculatorState extends CalculatorState { + const _CalculatorState({this.fromCurrency, this.toCurrency, this.amount = 0.0, this.convertedAmount, this.estimatedRate, this.isLoading = false, this.errorMessage, this.inputFieldError}): super._(); + + +@override final Currency? fromCurrency; +@override final Currency? toCurrency; +@override@JsonKey() final double amount; +@override final double? convertedAmount; +@override final double? estimatedRate; +@override@JsonKey() final bool isLoading; +@override final String? errorMessage; +@override final String? inputFieldError; + +/// Create a copy of CalculatorState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CalculatorStateCopyWith<_CalculatorState> get copyWith => __$CalculatorStateCopyWithImpl<_CalculatorState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CalculatorState&&(identical(other.fromCurrency, fromCurrency) || other.fromCurrency == fromCurrency)&&(identical(other.toCurrency, toCurrency) || other.toCurrency == toCurrency)&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.convertedAmount, convertedAmount) || other.convertedAmount == convertedAmount)&&(identical(other.estimatedRate, estimatedRate) || other.estimatedRate == estimatedRate)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.inputFieldError, inputFieldError) || other.inputFieldError == inputFieldError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,fromCurrency,toCurrency,amount,convertedAmount,estimatedRate,isLoading,errorMessage,inputFieldError); + +@override +String toString() { + return 'CalculatorState(fromCurrency: $fromCurrency, toCurrency: $toCurrency, amount: $amount, convertedAmount: $convertedAmount, estimatedRate: $estimatedRate, isLoading: $isLoading, errorMessage: $errorMessage, inputFieldError: $inputFieldError)'; +} + + +} + +/// @nodoc +abstract mixin class _$CalculatorStateCopyWith<$Res> implements $CalculatorStateCopyWith<$Res> { + factory _$CalculatorStateCopyWith(_CalculatorState value, $Res Function(_CalculatorState) _then) = __$CalculatorStateCopyWithImpl; +@override @useResult +$Res call({ + Currency? fromCurrency, Currency? toCurrency, double amount, double? convertedAmount, double? estimatedRate, bool isLoading, String? errorMessage, String? inputFieldError +}); + + + + +} +/// @nodoc +class __$CalculatorStateCopyWithImpl<$Res> + implements _$CalculatorStateCopyWith<$Res> { + __$CalculatorStateCopyWithImpl(this._self, this._then); + + final _CalculatorState _self; + final $Res Function(_CalculatorState) _then; + +/// Create a copy of CalculatorState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? fromCurrency = freezed,Object? toCurrency = freezed,Object? amount = null,Object? convertedAmount = freezed,Object? estimatedRate = freezed,Object? isLoading = null,Object? errorMessage = freezed,Object? inputFieldError = freezed,}) { + return _then(_CalculatorState( +fromCurrency: freezed == fromCurrency ? _self.fromCurrency : fromCurrency // ignore: cast_nullable_to_non_nullable +as Currency?,toCurrency: freezed == toCurrency ? _self.toCurrency : toCurrency // ignore: cast_nullable_to_non_nullable +as Currency?,amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as double,convertedAmount: freezed == convertedAmount ? _self.convertedAmount : convertedAmount // ignore: cast_nullable_to_non_nullable +as double?,estimatedRate: freezed == estimatedRate ? _self.estimatedRate : estimatedRate // ignore: cast_nullable_to_non_nullable +as double?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,inputFieldError: freezed == inputFieldError ? _self.inputFieldError : inputFieldError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/src/features/calculator/presentation/widgets/amount_input_field.dart b/lib/src/features/calculator/presentation/widgets/amount_input_field.dart new file mode 100644 index 00000000..5726f9d3 --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/amount_input_field.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/theme/colors/app_colors.dart'; +import '../../../../core/utils/formatters/thousand_separator_input_formatter.dart'; +import '../providers/amount_input_field_controller.dart'; +import '../providers/calculator_controller.dart'; + +final class AmountInputField extends ConsumerStatefulWidget { + const AmountInputField({super.key}); + + @override + ConsumerState createState() => _AmountInputFieldState(); +} + +class _AmountInputFieldState extends ConsumerState { + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AmountInputFieldController controller = ref.watch( + amountInputFieldControllerProvider.notifier, + ); + + // Escuchar el error desde CalculatorState en lugar de AmountInputFieldState + final String? errorText = ref.watch( + calculatorControllerProvider.select((value) => value.inputFieldError), + ); + + final String currencySymbol = ref.watch( + calculatorControllerProvider.select( + (value) => value.fromCurrency?.symbol ?? '', + ), + ); + + ref.listen( + amountInputFieldControllerProvider.select((value) => value.amount), + (previous, next) { + ref.read(calculatorControllerProvider.notifier).setAmount(amount: next); + }, + ); + + return TextFormField( + controller: controller.textController, + focusNode: _focusNode, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ThousandSeparatorInputFormatter()], + decoration: InputDecoration( + hintText: StringConstants.amountPlaceholder, + prefixText: currencySymbol.isNotEmpty ? '$currencySymbol ' : null, + prefixStyle: TextStyle( + color: AppColors.primaryYellow, + fontSize: 16, + height: 1.0, + ), + errorText: errorText, + errorMaxLines: 3, + ), + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16, + height: 1.0, + color: Theme.of(context).colorScheme.onSurface, + ), + onChanged: (value) => controller.onTextChanged(value: value), + onTap: controller.selectAll, + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/currency_bottom_sheet.dart b/lib/src/features/calculator/presentation/widgets/currency_bottom_sheet.dart new file mode 100644 index 00000000..0db86bbf --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/currency_bottom_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/widgets/bottom_sheet_handle.dart'; +import '../../../../core/widgets/error_state_widget.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/currency_type.dart'; +import '../providers/filtered_currencies_provider.dart'; +import 'currency_bottomsheet_tile.dart'; + +final class CurrencyBottomSheet extends ConsumerWidget { + final Currency? selectedCurrency; + final void Function(Currency) onCurrencySelected; + final CurrencyType currencyType; + + const CurrencyBottomSheet({ + super.key, + required this.selectedCurrency, + required this.onCurrencySelected, + required this.currencyType, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + // Usar provider de currencies filtradas + final AsyncValue> filteredCurrenciesAsync = ref.watch( + filteredCurrenciesProvider(currencyType: currencyType), + ); + final String title = currencyType == CurrencyType.fiat + ? StringConstants.fiatLabel + : StringConstants.cryptoLabel; + + return Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + const BottomSheetHandle(), + Padding( + padding: const EdgeInsets.all(20), + child: Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: filteredCurrenciesAsync.when( + data: (List filteredCurrencies) { + return RadioGroup( + groupValue: selectedCurrency?.id, + onChanged: (String? currencyId) { + if (currencyId != null) { + final currency = filteredCurrencies.firstWhere( + (c) => c.id == currencyId, + ); + onCurrencySelected(currency); + Navigator.of(context).pop(); + } + }, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: filteredCurrencies.length, + itemBuilder: (context, index) { + final currency = filteredCurrencies[index]; + return CurrencyBottomSheetTile( + currency: currency, + isSelected: selectedCurrency?.id == currency.id, + onTap: () { + onCurrencySelected(currency); + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => ErrorStateWidget( + title: StringConstants.loadCurrenciesError, + message: error.toString(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/currency_bottomsheet_tile.dart b/lib/src/features/calculator/presentation/widgets/currency_bottomsheet_tile.dart new file mode 100644 index 00000000..8d809442 --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/currency_bottomsheet_tile.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/widgets/currency_image_widget.dart'; +import '../../domain/entities/currency.dart'; + +final class CurrencyBottomSheetTile extends StatelessWidget { + final Currency currency; + final bool isSelected; + final VoidCallback onTap; + + const CurrencyBottomSheetTile({ + super.key, + required this.currency, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: theme.colorScheme.primary, width: 2) + : null, + ), + child: Row( + children: [ + CurrencyImageWidget(assetPath: currency.assetPath, size: 40), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currency.symbol, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + currency.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + Radio( + value: currency.id, + activeColor: theme.colorScheme.primary, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/currency_exchange_bar.dart b/lib/src/features/calculator/presentation/widgets/currency_exchange_bar.dart new file mode 100644 index 00000000..8dca2545 --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/currency_exchange_bar.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/theme/colors/app_colors.dart'; +import '../../domain/entities/currency.dart'; +import 'currency_label.dart'; +import 'currency_selector_compact.dart'; + +final class CurrencyExchangeBar extends StatelessWidget { + final Currency? fromCurrency; + final Currency? toCurrency; + final VoidCallback onFromTap; + final VoidCallback onSwap; + final VoidCallback onToTap; + final bool canSwap; + + const CurrencyExchangeBar({ + super.key, + required this.fromCurrency, + required this.toCurrency, + required this.onFromTap, + required this.onSwap, + required this.onToTap, + this.canSwap = true, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: AppColors.primaryYellow, width: 2.5), + borderRadius: BorderRadius.horizontal( + left: Radius.circular(100), + right: Radius.circular(100), + ), + ), + height: 46, + ), + Positioned( + top: -6, + left: 30, + child: CurrencyLabel( + text: StringConstants.haveLabel, + color: theme.colorScheme.onSurface, + backgroundColor: theme.colorScheme.surface, + ), + ), + Positioned( + top: -6, + right: 30, + child: CurrencyLabel( + text: StringConstants.wantLabel, + color: theme.colorScheme.onSurface, + backgroundColor: theme.colorScheme.surface, + ), + ), + InkWell( + onTap: onSwap, + child: CircleAvatar( + backgroundColor: AppColors.primaryYellow, + radius: 27.0, + child: SvgPicture.asset( + 'assets/icons/swap.svg', + height: 30, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: CurrencySelectorCompact( + selectedCurrency: fromCurrency, + onTap: onFromTap, + isFromSelector: true, + ), + ), + CurrencySelectorCompact( + selectedCurrency: toCurrency, + onTap: onToTap, + isFromSelector: false, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/currency_label.dart b/lib/src/features/calculator/presentation/widgets/currency_label.dart new file mode 100644 index 00000000..322809ff --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/currency_label.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +final class CurrencyLabel extends StatelessWidget { + final String text; + final Color color; + final Color backgroundColor; + + const CurrencyLabel({ + super.key, + required this.text, + required this.color, + required this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/currency_selector_compact.dart b/lib/src/features/calculator/presentation/widgets/currency_selector_compact.dart new file mode 100644 index 00000000..236e7eba --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/currency_selector_compact.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/widgets/currency_image_widget.dart'; +import '../../domain/entities/currency.dart'; +import '../../domain/entities/enum/bottom_sheet_type.dart'; +import '../providers/currency_bottom_sheet_controller.dart'; + +final class CurrencySelectorCompact extends ConsumerWidget { + final Currency? selectedCurrency; + final VoidCallback onTap; + final bool isFromSelector; + + const CurrencySelectorCompact({ + super.key, + required this.selectedCurrency, + required this.onTap, + required this.isFromSelector, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final BottomSheetType currentBottomSheet = ref.watch( + currencyBottomSheetControllerProvider, + ); + + final bool isThisOpen = isFromSelector + ? currentBottomSheet == BottomSheetType.from + : currentBottomSheet == BottomSheetType.to; + + return InkWell( + onTap: onTap, + child: Row( + children: [ + if (selectedCurrency != null) + CurrencyImageWidget( + assetPath: selectedCurrency!.assetPath, + size: 24, + ), + const SizedBox(width: 10), + Text( + selectedCurrency != null + ? selectedCurrency!.symbol + : StringConstants.loadingText, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: selectedCurrency != null + ? FontWeight.w600 + : FontWeight.w400, + ), + overflow: TextOverflow.ellipsis, + ), + if (selectedCurrency != null) + AnimatedRotation( + turns: isThisOpen ? 0.75 : 0.25, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.chevron_right, + size: 30, + color: theme.colorScheme.outline, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/exchange_info_card.dart b/lib/src/features/calculator/presentation/widgets/exchange_info_card.dart new file mode 100644 index 00000000..1f573fda --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/exchange_info_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/string_constants.dart'; +import '../../../../core/utils/formatters/rate_formatter.dart'; +import '../../domain/entities/currency.dart'; +import '../providers/exchange_rate_display_provider.dart'; +import 'exchange_info_row.dart'; + +final class ExchangeInfoCard extends ConsumerWidget { + final double? estimatedRate; + final double? convertedAmount; + final Currency? fromCurrency; + final Currency? toCurrency; + final bool isLoading; + + const ExchangeInfoCard({ + super.key, + this.estimatedRate, + this.convertedAmount, + this.fromCurrency, + this.toCurrency, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + + // Usar provider para calcular la tasa de visualización + final double? displayRate = ref.watch( + exchangeRateDisplayProvider( + estimatedRate: estimatedRate, + fromCurrency: fromCurrency, + toCurrency: toCurrency, + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isLoading) ...[ + const Center(child: CircularProgressIndicator()), + ] else if (displayRate != null && convertedAmount != null) ...[ + ExchangeInfoRow( + label: StringConstants.estimatedRateLabel, + value: + '1 ${fromCurrency?.symbol ?? ''} ≈ ${RateFormatter.formatRate(rate: displayRate)} ${toCurrency?.symbol ?? ''}', + ), + const SizedBox(height: 12), + ExchangeInfoRow( + label: StringConstants.youWillReceiveLabel, + value: + '≈ ${convertedAmount!.toStringAsFixed(2)} ${toCurrency?.symbol ?? ''}', + ), + const SizedBox(height: 12), + ExchangeInfoRow( + label: StringConstants.estimatedTimeLabel, + value: StringConstants.estimatedTimeValue, + ), + ] else ...[ + Text( + StringConstants.exchangeInfoPlaceholder, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ); + } +} diff --git a/lib/src/features/calculator/presentation/widgets/exchange_info_row.dart b/lib/src/features/calculator/presentation/widgets/exchange_info_row.dart new file mode 100644 index 00000000..00bf580d --- /dev/null +++ b/lib/src/features/calculator/presentation/widgets/exchange_info_row.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +final class ExchangeInfoRow extends StatelessWidget { + final String label; + final String value; + + const ExchangeInfoRow({super.key, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ], + ); + } +} diff --git a/lib/src/routes/app_router.dart b/lib/src/routes/app_router.dart new file mode 100644 index 00000000..fcb7bec7 --- /dev/null +++ b/lib/src/routes/app_router.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/constants/string_constants.dart'; +import '../features/calculator/presentation/screens/calculator_screen.dart'; +import 'app_routes.dart'; + +/// Configuración de rutas de la aplicación usando GoRouter +final class AppRouter { + /// Router principal de la aplicación + static final GoRouter router = GoRouter( + debugLogDiagnostics: true, + initialLocation: AppRoutes.calculator.path, + routes: [ + GoRoute( + path: AppRoutes.calculator.path, + name: AppRoutes.calculator.name, + builder: (BuildContext context, GoRouterState state) { + return const CalculatorScreen(); + }, + ), + ], + errorBuilder: (BuildContext context, GoRouterState state) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + StringConstants.pageNotFound, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + state.uri.toString(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context.go(AppRoutes.calculator.path), + icon: const Icon(Icons.home), + label: const Text(StringConstants.goToHome), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/src/routes/app_routes.dart b/lib/src/routes/app_routes.dart new file mode 100644 index 00000000..3f621303 --- /dev/null +++ b/lib/src/routes/app_routes.dart @@ -0,0 +1,17 @@ +/// Enumeración de todas las rutas de la aplicación +/// +/// Cada ruta tiene un [path] y un [name] únicos. +/// El path es la URL de la ruta y el name es el identificador interno. +enum AppRoutes { + /// Ruta principal - Calculadora + calculator('/', 'calculator'); + + const AppRoutes(this.path, this.name); + + /// Path de la ruta (usado en la URL) + final String path; + + /// Nombre de la ruta (usado para navegación programática) + final String name; +} + diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..df25db42 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,41 @@ +name: crypto_calculator +description: "Crypto Calculator Challenge - Flutter app with Riverpod" +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.8.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + flutter_riverpod: ^3.0.3 + riverpod_annotation: ^3.0.3 + + dio: ^5.9.0 + go_router: ^16.3.0 + + cupertino_icons: ^1.0.8 + freezed: ^3.2.3 + freezed_annotation: ^3.1.0 + flutter_svg: ^2.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + build_runner: ^2.4.0 + riverpod_generator: ^3.0.3 + + # Linting + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/cripto_currencies/ + - assets/fiat_currencies/ + - assets/icons/ diff --git a/test/features/calculator/domain/usecases/calculate_crypto_exchange_usecase_test.dart b/test/features/calculator/domain/usecases/calculate_crypto_exchange_usecase_test.dart new file mode 100644 index 00000000..ddf132bb --- /dev/null +++ b/test/features/calculator/domain/usecases/calculate_crypto_exchange_usecase_test.dart @@ -0,0 +1,146 @@ +import 'package:crypto_calculator/src/features/calculator/domain/entities/calculate_exchange_params.dart'; +import 'package:crypto_calculator/src/features/calculator/domain/entities/currency.dart'; +import 'package:crypto_calculator/src/features/calculator/domain/entities/enum/currency_type.dart'; +import 'package:crypto_calculator/src/features/calculator/domain/repositories/calculator_repository.dart'; +import 'package:crypto_calculator/src/features/calculator/domain/usecases/calculate_crypto_exchange_usecase.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Mock simple del repository para los tests +class MockCalculatorRepository implements CalculatorRepository { + final double exchangeRateToReturn; + + MockCalculatorRepository({required this.exchangeRateToReturn}); + + @override + Future getExchangeRate({ + required String cryptoCurrencyId, + required String fiatCurrencyId, + required String amount, + required String amountCurrencyId, + required int type, + }) async { + return exchangeRateToReturn; + } +} + +void main() { + late CalculateCryptoExchangeUseCase useCase; + late MockCalculatorRepository mockRepository; + + const Currency cryptoCurrency = Currency( + id: 'TATUM-TRON-USDT', + name: 'Tether', + symbol: 'USDT', + assetPath: 'assets/cripto_currencies/TATUM-TRON-USDT.png', + type: CurrencyType.crypto, + ); + + const Currency fiatCurrency = Currency( + id: 'VES', + name: 'Bolívar', + symbol: 'Bs.', + assetPath: 'assets/fiat_currencies/VES.png', + type: CurrencyType.fiat, + ); + + setUp(() { + mockRepository = MockCalculatorRepository(exchangeRateToReturn: 50.0); + useCase = CalculateCryptoExchangeUseCase(repository: mockRepository); + }); + + group('CalculateCryptoExchangeUseCase', () { + test('debe calcular correctamente crypto a fiat', () async { + // Arrange + const double amount = 10.0; + const double expectedExchangeRate = 50.0; + const double expectedConvertedAmount = 500.0; // 10 * 50 + + final params = CalculateExchangeParams( + fromCurrency: cryptoCurrency, + toCurrency: fiatCurrency, + amount: amount, + ); + + // Act + final result = await useCase(params: params); + + // Assert + expect(result.exchangeRate, expectedExchangeRate); + expect(result.convertedAmount, expectedConvertedAmount); + }); + + test('debe calcular correctamente fiat a crypto', () async { + // Arrange + const double amount = 500.0; + const double expectedExchangeRate = 50.0; + const double expectedConvertedAmount = 10.0; // 500 / 50 + + final params = CalculateExchangeParams( + fromCurrency: fiatCurrency, + toCurrency: cryptoCurrency, + amount: amount, + ); + + // Act + final result = await useCase(params: params); + + // Assert + expect(result.exchangeRate, expectedExchangeRate); + expect(result.convertedAmount, expectedConvertedAmount); + }); + + test('debe lanzar ArgumentError si el monto es 0', () async { + // Arrange + final params = CalculateExchangeParams( + fromCurrency: cryptoCurrency, + toCurrency: fiatCurrency, + amount: 0.0, + ); + + // Act & Assert + expect( + () async => await useCase(params: params), + throwsA(isA()), + ); + }); + + test('debe lanzar ArgumentError si el monto es negativo', () async { + // Arrange + final params = CalculateExchangeParams( + fromCurrency: cryptoCurrency, + toCurrency: fiatCurrency, + amount: -10.0, + ); + + // Act & Assert + expect( + () async => await useCase(params: params), + throwsA(isA()), + ); + }); + + test('debe manejar correctamente tasas de cambio decimales', () async { + // Arrange + mockRepository = MockCalculatorRepository(exchangeRateToReturn: 0.025); + useCase = CalculateCryptoExchangeUseCase(repository: mockRepository); + + const double amount = 100.0; + const double expectedExchangeRate = 0.025; + const double expectedConvertedAmount = 2.5; // 100 * 0.025 + + final params = CalculateExchangeParams( + fromCurrency: cryptoCurrency, + toCurrency: fiatCurrency, + amount: amount, + ); + + // Act + final result = await useCase(params: params); + + // Assert + expect(result.exchangeRate, expectedExchangeRate); + expect(result.convertedAmount, expectedConvertedAmount); + }); + }); +} + diff --git a/test/features/calculator/domain/usecases/validate_exchange_amount_usecase_test.dart b/test/features/calculator/domain/usecases/validate_exchange_amount_usecase_test.dart new file mode 100644 index 00000000..c28e091d --- /dev/null +++ b/test/features/calculator/domain/usecases/validate_exchange_amount_usecase_test.dart @@ -0,0 +1,89 @@ +import 'package:crypto_calculator/src/core/constants/validation_constants.dart'; +import 'package:crypto_calculator/src/features/calculator/domain/usecases/validate_exchange_amount_usecase.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late ValidateExchangeAmountUseCase useCase; + + setUp(() { + useCase = const ValidateExchangeAmountUseCase(); + }); + + group('ValidateExchangeAmountUseCase', () { + test('debe retornar Success cuando el monto es válido', () { + // Arrange + const double validAmount = 100.0; + + // Act + final result = useCase(validAmount); + + // Assert + expect(result, isA()); + }); + + test('debe retornar Failure cuando el monto es 0', () { + // Arrange + const double invalidAmount = 0.0; + + // Act + final result = useCase(invalidAmount); + + // Assert + expect(result, isA()); + if (result is AmountValidationFailure) { + expect(result.errorMessage, isNotEmpty); + } + }); + + test('debe retornar Failure cuando el monto es negativo', () { + // Arrange + const double invalidAmount = -10.0; + + // Act + final result = useCase(invalidAmount); + + // Assert + expect(result, isA()); + if (result is AmountValidationFailure) { + expect(result.errorMessage, isNotEmpty); + } + }); + + test('debe retornar Failure cuando el monto excede el límite máximo', () { + // Arrange + final double invalidAmount = ValidationConstants.maxExchangeAmount + 1; + + // Act + final result = useCase(invalidAmount); + + // Assert + expect(result, isA()); + if (result is AmountValidationFailure) { + expect(result.errorMessage, isNotEmpty); + } + }); + + test('debe retornar Success cuando el monto está en el límite máximo', () { + // Arrange + final double validAmount = ValidationConstants.maxExchangeAmount; + + // Act + final result = useCase(validAmount); + + // Assert + expect(result, isA()); + }); + + test('debe retornar Success con un monto pequeño pero positivo', () { + // Arrange + const double validAmount = 0.01; + + // Act + final result = useCase(validAmount); + + // Assert + expect(result, isA()); + }); + }); +} +