commit 3616f84556ff0051e3c185afb253153bf59a52b5 Author: Polyanka Date: Sun May 10 19:11:31 2026 +0300 new project stable version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b451237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +.dart_tool +build + + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..bbb2321 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# 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: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: android + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: ios + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..da096de --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# be_happy + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..4bd5aa3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,63 @@ +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +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.sparkit.be_happy" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.sparkit.be_happy" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 26 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + dependencies { + implementation ("com.yandex.android:maps.mobile:4.5.1-full") + } +} + +flutter { + source = "../.." +} diff --git a/android/app/sparkit-key b/android/app/sparkit-key new file mode 100644 index 0000000..bce03bd Binary files /dev/null and b/android/app/sparkit-key differ diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8140cd5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/sparkit/be_happy/MainActivity.kt b/android/app/src/main/kotlin/com/sparkit/be_happy/MainActivity.kt new file mode 100644 index 0000000..d7c3975 --- /dev/null +++ b/android/app/src/main/kotlin/com/sparkit/be_happy/MainActivity.kt @@ -0,0 +1,13 @@ +package com.sparkit.be_happy + +import android.os.Bundle +import com.yandex.mapkit.MapKitFactory +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + + override protected fun onCreate(savedInstanceState: Bundle?) { + MapKitFactory.setApiKey("a0ef1404-2650-4f28-9891-c965ecc09174") + super.onCreate(savedInstanceState) + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /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 0000000..ac3b479 --- /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 0000000..ab39a10 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/ban.png b/assets/ban.png new file mode 100644 index 0000000..89f4dea Binary files /dev/null and b/assets/ban.png differ diff --git a/assets/error_promo.png b/assets/error_promo.png new file mode 100644 index 0000000..5d9bf6a Binary files /dev/null and b/assets/error_promo.png differ diff --git a/assets/first-logo.png b/assets/first-logo.png new file mode 100644 index 0000000..7b6a672 Binary files /dev/null and b/assets/first-logo.png differ diff --git a/assets/fonts/Roboto_Condensed-Bold.ttf b/assets/fonts/Roboto_Condensed-Bold.ttf new file mode 100644 index 0000000..a7c3cdf Binary files /dev/null and b/assets/fonts/Roboto_Condensed-Bold.ttf differ diff --git a/assets/fonts/Roboto_Condensed-Light.ttf b/assets/fonts/Roboto_Condensed-Light.ttf new file mode 100644 index 0000000..e70c357 Binary files /dev/null and b/assets/fonts/Roboto_Condensed-Light.ttf differ diff --git a/assets/fonts/Roboto_Condensed-Medium.ttf b/assets/fonts/Roboto_Condensed-Medium.ttf new file mode 100644 index 0000000..dd2842b Binary files /dev/null and b/assets/fonts/Roboto_Condensed-Medium.ttf differ diff --git a/assets/fonts/Roboto_Condensed-Regular.ttf b/assets/fonts/Roboto_Condensed-Regular.ttf new file mode 100644 index 0000000..5af42d4 Binary files /dev/null and b/assets/fonts/Roboto_Condensed-Regular.ttf differ diff --git a/assets/history.png b/assets/history.png new file mode 100644 index 0000000..4fff9c6 Binary files /dev/null and b/assets/history.png differ diff --git a/assets/icons/Vector (1).png b/assets/icons/Vector (1).png new file mode 100644 index 0000000..30addfc Binary files /dev/null and b/assets/icons/Vector (1).png differ diff --git a/assets/icons/Vector.png b/assets/icons/Vector.png new file mode 100644 index 0000000..389d2d3 Binary files /dev/null and b/assets/icons/Vector.png differ diff --git a/assets/icons/ava.png b/assets/icons/ava.png new file mode 100644 index 0000000..df96955 Binary files /dev/null and b/assets/icons/ava.png differ diff --git a/assets/icons/belcard.png b/assets/icons/belcard.png new file mode 100644 index 0000000..b54a022 Binary files /dev/null and b/assets/icons/belcard.png differ diff --git a/assets/icons/bolt.png b/assets/icons/bolt.png new file mode 100644 index 0000000..52e53ce Binary files /dev/null and b/assets/icons/bolt.png differ diff --git a/assets/icons/byn.png b/assets/icons/byn.png new file mode 100644 index 0000000..d73d096 Binary files /dev/null and b/assets/icons/byn.png differ diff --git a/assets/icons/call.png b/assets/icons/call.png new file mode 100644 index 0000000..b1b8ad0 Binary files /dev/null and b/assets/icons/call.png differ diff --git a/assets/icons/card-screen.png b/assets/icons/card-screen.png new file mode 100644 index 0000000..50d0d98 Binary files /dev/null and b/assets/icons/card-screen.png differ diff --git a/assets/icons/clichnik.png b/assets/icons/clichnik.png new file mode 100644 index 0000000..ef07b62 Binary files /dev/null and b/assets/icons/clichnik.png differ diff --git a/assets/icons/creditcard_icon.png b/assets/icons/creditcard_icon.png new file mode 100644 index 0000000..29d30ed Binary files /dev/null and b/assets/icons/creditcard_icon.png differ diff --git a/assets/icons/distance_icon.png b/assets/icons/distance_icon.png new file mode 100644 index 0000000..d66a2e1 Binary files /dev/null and b/assets/icons/distance_icon.png differ diff --git a/assets/icons/doc.png b/assets/icons/doc.png new file mode 100644 index 0000000..965dfaa Binary files /dev/null and b/assets/icons/doc.png differ diff --git a/assets/icons/doc_icon.png b/assets/icons/doc_icon.png new file mode 100644 index 0000000..ce664d2 Binary files /dev/null and b/assets/icons/doc_icon.png differ diff --git a/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png b/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png new file mode 100644 index 0000000..528ab6c Binary files /dev/null and b/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png differ diff --git a/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png b/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png new file mode 100644 index 0000000..37914f7 Binary files /dev/null and b/assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png differ diff --git a/assets/icons/edit.png b/assets/icons/edit.png new file mode 100644 index 0000000..0f17cd5 Binary files /dev/null and b/assets/icons/edit.png differ diff --git a/assets/icons/flashlight.png b/assets/icons/flashlight.png new file mode 100644 index 0000000..f49c419 Binary files /dev/null and b/assets/icons/flashlight.png differ diff --git a/assets/icons/headphones_icon.png b/assets/icons/headphones_icon.png new file mode 100644 index 0000000..f2a15e1 Binary files /dev/null and b/assets/icons/headphones_icon.png differ diff --git a/assets/icons/info_icon.png b/assets/icons/info_icon.png new file mode 100644 index 0000000..ae17d68 Binary files /dev/null and b/assets/icons/info_icon.png differ diff --git a/assets/icons/list_star_icon.png b/assets/icons/list_star_icon.png new file mode 100644 index 0000000..12a1bd7 Binary files /dev/null and b/assets/icons/list_star_icon.png differ diff --git a/assets/icons/location.png b/assets/icons/location.png new file mode 100644 index 0000000..4f2c648 Binary files /dev/null and b/assets/icons/location.png differ diff --git a/assets/icons/lock.png b/assets/icons/lock.png new file mode 100644 index 0000000..ee8681c Binary files /dev/null and b/assets/icons/lock.png differ diff --git a/assets/icons/logout_icon.png b/assets/icons/logout_icon.png new file mode 100644 index 0000000..7969086 Binary files /dev/null and b/assets/icons/logout_icon.png differ diff --git a/assets/icons/magazine_icon.png b/assets/icons/magazine_icon.png new file mode 100644 index 0000000..6ae738e Binary files /dev/null and b/assets/icons/magazine_icon.png differ diff --git a/assets/icons/mastercard.png b/assets/icons/mastercard.png new file mode 100644 index 0000000..1691b59 Binary files /dev/null and b/assets/icons/mastercard.png differ diff --git a/assets/icons/money_icon.png b/assets/icons/money_icon.png new file mode 100644 index 0000000..4c382c2 Binary files /dev/null and b/assets/icons/money_icon.png differ diff --git a/assets/icons/news_icon.png b/assets/icons/news_icon.png new file mode 100644 index 0000000..7f93ac4 Binary files /dev/null and b/assets/icons/news_icon.png differ diff --git a/assets/icons/person.png b/assets/icons/person.png new file mode 100644 index 0000000..cf415cc Binary files /dev/null and b/assets/icons/person.png differ diff --git a/assets/icons/person_icon.png b/assets/icons/person_icon.png new file mode 100644 index 0000000..18c4df7 Binary files /dev/null and b/assets/icons/person_icon.png differ diff --git a/assets/icons/plans_icon.png b/assets/icons/plans_icon.png new file mode 100644 index 0000000..08af963 Binary files /dev/null and b/assets/icons/plans_icon.png differ diff --git a/assets/icons/points_icon.png b/assets/icons/points_icon.png new file mode 100644 index 0000000..9aba7b4 Binary files /dev/null and b/assets/icons/points_icon.png differ diff --git a/assets/icons/promo_icon.png b/assets/icons/promo_icon.png new file mode 100644 index 0000000..a7eb147 Binary files /dev/null and b/assets/icons/promo_icon.png differ diff --git a/assets/icons/qr_icon.png b/assets/icons/qr_icon.png new file mode 100644 index 0000000..e3eedd0 Binary files /dev/null and b/assets/icons/qr_icon.png differ diff --git a/assets/icons/scooter_icon.png b/assets/icons/scooter_icon.png new file mode 100644 index 0000000..76c0905 Binary files /dev/null and b/assets/icons/scooter_icon.png differ diff --git a/assets/icons/scooter_placemark.png b/assets/icons/scooter_placemark.png new file mode 100644 index 0000000..30addfc Binary files /dev/null and b/assets/icons/scooter_placemark.png differ diff --git a/assets/icons/scooter_placemark_fill.png b/assets/icons/scooter_placemark_fill.png new file mode 100644 index 0000000..1639539 Binary files /dev/null and b/assets/icons/scooter_placemark_fill.png differ diff --git a/assets/icons/speed.png b/assets/icons/speed.png new file mode 100644 index 0000000..2659207 Binary files /dev/null and b/assets/icons/speed.png differ diff --git a/assets/icons/telegram.png b/assets/icons/telegram.png new file mode 100644 index 0000000..fa83199 Binary files /dev/null and b/assets/icons/telegram.png differ diff --git a/assets/icons/time.png b/assets/icons/time.png new file mode 100644 index 0000000..5785fc7 Binary files /dev/null and b/assets/icons/time.png differ diff --git a/assets/icons/viber.png b/assets/icons/viber.png new file mode 100644 index 0000000..cbb92f6 Binary files /dev/null and b/assets/icons/viber.png differ diff --git a/assets/icons/visa.png b/assets/icons/visa.png new file mode 100644 index 0000000..96d26d5 Binary files /dev/null and b/assets/icons/visa.png differ diff --git a/assets/icons/whatsapp.png b/assets/icons/whatsapp.png new file mode 100644 index 0000000..af04ee1 Binary files /dev/null and b/assets/icons/whatsapp.png differ diff --git a/assets/logo_color.png b/assets/logo_color.png new file mode 100644 index 0000000..7bb41bd Binary files /dev/null and b/assets/logo_color.png differ diff --git a/assets/logo_outline.png b/assets/logo_outline.png new file mode 100644 index 0000000..ea83a89 Binary files /dev/null and b/assets/logo_outline.png differ diff --git a/assets/main-logo.png b/assets/main-logo.png new file mode 100644 index 0000000..01f10cd Binary files /dev/null and b/assets/main-logo.png differ diff --git a/assets/news_empty.png b/assets/news_empty.png new file mode 100644 index 0000000..240de31 Binary files /dev/null and b/assets/news_empty.png differ diff --git a/assets/onboard_1.jpg b/assets/onboard_1.jpg new file mode 100644 index 0000000..d6af570 Binary files /dev/null and b/assets/onboard_1.jpg differ diff --git a/assets/onboard_2.jpg b/assets/onboard_2.jpg new file mode 100644 index 0000000..7017e59 Binary files /dev/null and b/assets/onboard_2.jpg differ diff --git a/assets/onboard_3.png b/assets/onboard_3.png new file mode 100644 index 0000000..68e85de Binary files /dev/null and b/assets/onboard_3.png differ diff --git a/assets/onboard_4.jpg b/assets/onboard_4.jpg new file mode 100644 index 0000000..b35cfa0 Binary files /dev/null and b/assets/onboard_4.jpg differ diff --git a/assets/onboard_5.jpg b/assets/onboard_5.jpg new file mode 100644 index 0000000..a267f80 Binary files /dev/null and b/assets/onboard_5.jpg differ diff --git a/assets/onboard_6.jpg b/assets/onboard_6.jpg new file mode 100644 index 0000000..8f2af62 Binary files /dev/null and b/assets/onboard_6.jpg differ diff --git a/assets/onboard_7.png b/assets/onboard_7.png new file mode 100644 index 0000000..7af20e3 Binary files /dev/null and b/assets/onboard_7.png differ diff --git a/assets/promo_bottom.png b/assets/promo_bottom.png new file mode 100644 index 0000000..95242ed Binary files /dev/null and b/assets/promo_bottom.png differ diff --git a/assets/qr_phone_img.png b/assets/qr_phone_img.png new file mode 100644 index 0000000..ecae797 Binary files /dev/null and b/assets/qr_phone_img.png differ diff --git a/assets/second-logo.png b/assets/second-logo.png new file mode 100644 index 0000000..751a211 Binary files /dev/null and b/assets/second-logo.png differ diff --git a/assets/support_bottom.jpg b/assets/support_bottom.jpg new file mode 100644 index 0000000..4f06536 Binary files /dev/null and b/assets/support_bottom.jpg differ diff --git a/assets/support_bottom.png b/assets/support_bottom.png new file mode 100644 index 0000000..42c1b02 Binary files /dev/null and b/assets/support_bottom.png differ diff --git a/assets/support_bottom.svg b/assets/support_bottom.svg new file mode 100644 index 0000000..a847e08 --- /dev/null +++ b/assets/support_bottom.svg @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/wave.png b/assets/wave.png new file mode 100644 index 0000000..4f7455d Binary files /dev/null and b/assets/wave.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /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 + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..09b8034 --- /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 = 12.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.sparkit.beHappy; + 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.sparkit.beHappy.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.sparkit.beHappy.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.sparkit.beHappy.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 = 12.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 = 12.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.sparkit.beHappy; + 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.sparkit.beHappy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /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 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..e6e2e1f --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Be Happy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + be_happy + 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 0000000..308a2a5 --- /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 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/app_colors.dart b/lib/core/app_colors.dart new file mode 100644 index 0000000..6da88aa --- /dev/null +++ b/lib/core/app_colors.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // -------------------------- + // ФОНЫ ЭКРАНОВ + // -------------------------- + + /// Градиент PhoneScreen (ввод телефона) + static const LinearGradient phoneScreenBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF293A69), + Color(0xFF202741), + ], + ); + + /// Градиент PhoneLoginScreen & PinLoginScreen + static const LinearGradient authScreenBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0D1A44), + Color(0xFF091233), + ], + ); + + // -------------------------- + // КНОПКИ + // -------------------------- + + /// Активная кнопка — бирюзово-зелёный градиент + static const LinearGradient activeButtonGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0xFF75FBF0), + Color(0xFF8BFFAA), + ], + ); + + /// Неактивная кнопка — тёмно-синий + static const Color disabledButtonColor = Color(0xFF2A3A6A); + + /// Активный текст кнопки + static const Color activeButtonText = Color(0xFF000032); + + /// Неактивный текст кнопки + static const Color disabledButtonText = Color(0x99FFFFFF); + + // -------------------------- + // ЦВЕТА ДЛЯ TOGGLE / CHECKBOX + // -------------------------- + + /// Бордер неактивного чекбокса + static const Color checkboxBorder = Colors.white70; + + /// Бордер чекбокса с ошибкой + static const Color checkboxErrorBorder = Color(0xFFBA1A1A); + + /// Цвет заливки активного чекбокса + static const Color checkboxFill = Color(0xFF8BFFAA); + + // -------------------------- + // PIN / CODE INPUT + // -------------------------- + + /// Цвет цифры в SMS-коде (PhoneLoginScreen) + static const Color smsDigit = Color(0xFF75FBF0); + + /// Цвет точки-плейсхолдера + static const Color digitPlaceholder = Color(0x66FFFFFF); + + /// Цвет правильного PIN + static const Color pinSuccess = Color(0xFF66FF99); + + /// Цвет неверного PIN + static const Color pinError = Color(0xFFFF4F4F); + + // -------------------------- + // ТЕКСТ + // -------------------------- + + static const Color whiteText = Colors.white; + static const Color white70 = Colors.white70; + static const Color hint = Colors.white54; + + // -------------------------- + // ПРОЧЕЕ + // -------------------------- + + static const Color darkBlue = Color(0xFF000032); +} diff --git a/lib/core/failures.dart b/lib/core/failures.dart new file mode 100644 index 0000000..4ea4b22 --- /dev/null +++ b/lib/core/failures.dart @@ -0,0 +1,29 @@ +sealed class EntityFailure { + final String? message; + const EntityFailure(this.message); +} + +class AuthFailure extends EntityFailure { + final int attemptsLeft; + const AuthFailure(this.attemptsLeft, {String? message}) : super(message); +} + +class AuthBlockFailure extends EntityFailure { + const AuthBlockFailure(super.message); +} + +class WrongZoneFailure extends EntityFailure { + const WrongZoneFailure(super.message); +} + +class ScooterNotFoundFailure extends EntityFailure { + const ScooterNotFoundFailure(super.message); +} + +class RouteHistoryNotFoundFailure extends EntityFailure { + const RouteHistoryNotFoundFailure(super.message); +} + +class UnknownFailure extends EntityFailure { + const UnknownFailure(super.message); +} \ No newline at end of file diff --git a/lib/core/result.dart b/lib/core/result.dart new file mode 100644 index 0000000..ea900ed --- /dev/null +++ b/lib/core/result.dart @@ -0,0 +1,13 @@ +import 'failures.dart'; + +sealed class Result {} + +class Success extends Result { + final T? data; + Success(this.data); +} + +class Failure extends Result { + final EntityFailure failure; + Failure(this.failure); +} \ No newline at end of file diff --git a/lib/data/exceptions/auth_block_exception.dart b/lib/data/exceptions/auth_block_exception.dart new file mode 100644 index 0000000..d0fa2ea --- /dev/null +++ b/lib/data/exceptions/auth_block_exception.dart @@ -0,0 +1,8 @@ +class AuthBlockException implements Exception { + String description = "Неверный код. Вы временно заблокированы, попробуйте позже."; + + AuthBlockException(); + + @override + String toString() => description; +} diff --git a/lib/data/exceptions/auth_exception.dart b/lib/data/exceptions/auth_exception.dart new file mode 100644 index 0000000..1d29e14 --- /dev/null +++ b/lib/data/exceptions/auth_exception.dart @@ -0,0 +1,9 @@ +class AuthException implements Exception { + int attemptsLeft; + String? description; + + AuthException(this.description, this.attemptsLeft); + + @override + String toString() => description ?? "Ошибка авторизации"; +} diff --git a/lib/data/exceptions/route_history_not_found_exception.dart b/lib/data/exceptions/route_history_not_found_exception.dart new file mode 100644 index 0000000..ff94da5 --- /dev/null +++ b/lib/data/exceptions/route_history_not_found_exception.dart @@ -0,0 +1,8 @@ +class RouteHistoryNotFoundException implements Exception { + final String message; + + RouteHistoryNotFoundException({this.message = "История маршрута не найдена"}); + + @override + String toString() => message; +} diff --git a/lib/data/exceptions/scooter_not_found_exception.dart b/lib/data/exceptions/scooter_not_found_exception.dart new file mode 100644 index 0000000..a44ad82 --- /dev/null +++ b/lib/data/exceptions/scooter_not_found_exception.dart @@ -0,0 +1,8 @@ +class ScooterNotFoundException implements Exception { + final String message; + + ScooterNotFoundException({this.message = "Самокат не найден"}); + + @override + String toString() => message; +} diff --git a/lib/data/exceptions/unauthorized_exception.dart b/lib/data/exceptions/unauthorized_exception.dart new file mode 100644 index 0000000..45aec02 --- /dev/null +++ b/lib/data/exceptions/unauthorized_exception.dart @@ -0,0 +1,5 @@ +class UnauthorizedException implements Exception { + + @override + String toString() => "Ошибка авторизации. Пользователь не авторизован"; +} diff --git a/lib/data/exceptions/wrong_zone_exception.dart b/lib/data/exceptions/wrong_zone_exception.dart new file mode 100644 index 0000000..f5cec64 --- /dev/null +++ b/lib/data/exceptions/wrong_zone_exception.dart @@ -0,0 +1,6 @@ +class WrongZoneException implements Exception { + final String message; + + WrongZoneException({required this.message}); + +} diff --git a/lib/data/models/auth_response_dto.dart b/lib/data/models/auth_response_dto.dart new file mode 100644 index 0000000..9703de1 --- /dev/null +++ b/lib/data/models/auth_response_dto.dart @@ -0,0 +1,13 @@ +class AuthResponseDto { + final String accessToken; + final String refreshToken; + + AuthResponseDto({required this.accessToken, required this.refreshToken}); + + factory AuthResponseDto.fromJson(Map json) { + return AuthResponseDto( + accessToken: json["accessToken"] ?? "", + refreshToken: json["refreshToken"] ?? "", + ); + } +} \ No newline at end of file diff --git a/lib/data/models/client_notification_dto.dart b/lib/data/models/client_notification_dto.dart new file mode 100644 index 0000000..317e830 --- /dev/null +++ b/lib/data/models/client_notification_dto.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import '../../domain/entities/client_notification.dart'; + +class ClientNotificationDto { + final int id; + final String content; + final int clientId; + final String type; + final String category; + final String createdAt; + final String? canceledAt; + final String? readAt; + + ClientNotificationDto({ + required this.id, + required this.content, + required this.clientId, + required this.type, + required this.category, + required this.createdAt, + this.canceledAt, + this.readAt, + }); + + factory ClientNotificationDto.fromJson(Map json) { + return ClientNotificationDto( + id: json['id'] as int, + content: json['content'] as String, + clientId: json['clientId'] as int, + type: json['type'] as String, + category: json['category'] as String, + createdAt: json['createdAt'] as String, + canceledAt: json['canceledAt'] as String?, + readAt: json['readAt'] as String?, + ); + } + + ClientNotification toEntity() { + return ClientNotification( + id: id, + content: content, + clientId: clientId, + type: _parseType(type), + category: _parseCategory(category), + createdAt: DateTime.parse(createdAt), + canceledAt: canceledAt != null ? DateTime.parse(canceledAt!) : null, + readAt: readAt != null ? DateTime.parse(readAt!) : null, + ); + } + + NotificationType _parseType(String type) { + switch (type.toLowerCase()) { + case 'info': + return NotificationType.info; + case 'attention': + return NotificationType.attention; + case 'warning': + return NotificationType.warning; + default: + return NotificationType.info; + } + } + + NotificationCategory _parseCategory(String category) { + switch (category.toLowerCase()) { + case 'auth': + return NotificationCategory.auth; + case 'zone': + return NotificationCategory.zone; + case 'payment': + return NotificationCategory.payment; + case 'companyinfo': + return NotificationCategory.companyInfo; + case 'admininfo': + return NotificationCategory.adminInfo; + case 'scooter': + return NotificationCategory.scooter; + default: + return NotificationCategory.companyInfo; + } + } +} diff --git a/lib/data/models/login_request_dto.dart b/lib/data/models/login_request_dto.dart new file mode 100644 index 0000000..057d37c --- /dev/null +++ b/lib/data/models/login_request_dto.dart @@ -0,0 +1,9 @@ +class LoginRequestDto { + final String phone; + + LoginRequestDto({required this.phone}); + + Map toJson() { + return {"phone": phone}; + } +} \ No newline at end of file diff --git a/lib/data/models/payment_card_request_dto.dart b/lib/data/models/payment_card_request_dto.dart new file mode 100644 index 0000000..8c0b02e --- /dev/null +++ b/lib/data/models/payment_card_request_dto.dart @@ -0,0 +1,25 @@ +class PaymentCardRequestDto { + final String cardNumber; + final String cardHolder; + final int expirationMonth; + final int expirationYear; + final String cvv; + + PaymentCardRequestDto({ + required this.cardNumber, + required this.cardHolder, + required this.expirationMonth, + required this.expirationYear, + required this.cvv, + }); + + Map toJson() { + return { + 'cardNumber': cardNumber.replaceAll(' ', ''), + 'cardHolder': cardHolder, + 'expirationMonth': expirationMonth, + 'expirationYear': expirationYear, + 'cvv': cvv, + }; + } +} \ No newline at end of file diff --git a/lib/data/models/payment_card_response_dto.dart b/lib/data/models/payment_card_response_dto.dart new file mode 100644 index 0000000..bb7b685 --- /dev/null +++ b/lib/data/models/payment_card_response_dto.dart @@ -0,0 +1,49 @@ +import '../../domain/entities/payment_card.dart'; + +class PaymentCardResponseDto { + final int id; + final int clientId; + final int expirationMonth; + final int expirationYear; + final String cardHolder; + final String cardLastNumber; + final String type; + final bool isMain; + + PaymentCardResponseDto({ + required this.id, + required this.clientId, + required this.expirationMonth, + required this.expirationYear, + required this.cardHolder, + required this.cardLastNumber, + required this.type, + required this.isMain, + }); + + factory PaymentCardResponseDto.fromJson(Map json) { + return PaymentCardResponseDto( + id: json['id'] as int, + clientId: json['clientId'] as int, + expirationMonth: json['expirationMonth'] as int, + expirationYear: json['expirationYear'] as int, + cardHolder: json['cardHolder'] as String, + cardLastNumber: json['cardLastNumber'] as String, + isMain: json['isMain'] as bool, + type: json['type'] as String, + ); + } + + PaymentCard toEntity(String? fullCardNumber) { + return PaymentCard( + id: id, + clientId: clientId, + expirationMonth: expirationMonth, + expirationYear: expirationYear, + cardHolder: cardHolder, + cardLastNumber: cardLastNumber, + isMain: isMain, + type: type, + ); + } +} diff --git a/lib/data/models/scooter_order_history_response.dart b/lib/data/models/scooter_order_history_response.dart new file mode 100644 index 0000000..1348d44 --- /dev/null +++ b/lib/data/models/scooter_order_history_response.dart @@ -0,0 +1,21 @@ +import '../../domain/entities/scooter_order.dart'; +import '../../domain/entities/pagination.dart'; + +class ScooterOrderHistoryResponse { + final List orders; + final Pagination pagination; + + ScooterOrderHistoryResponse({ + required this.orders, + required this.pagination, + }); + + factory ScooterOrderHistoryResponse.fromJson(Map json) { + return ScooterOrderHistoryResponse( + orders: (json['data'] as List) + .map((e) => ScooterOrder.fromJson(e as Map)) + .toList(), + pagination: Pagination.fromJson(json['pagination']), + ); + } +} \ No newline at end of file diff --git a/lib/data/models/scooters_response.dart b/lib/data/models/scooters_response.dart new file mode 100644 index 0000000..9c863b8 --- /dev/null +++ b/lib/data/models/scooters_response.dart @@ -0,0 +1,21 @@ +import '../../domain/entities/pagination.dart'; +import '../../domain/entities/scooter.dart'; + +class ScootersResponse { + final List scooters; + final Pagination pagination; + + ScootersResponse({ + required this.scooters, + required this.pagination, + }); + + factory ScootersResponse.fromJson(Map json) { + return ScootersResponse( + scooters: (json['data'] as List) + .map((e) => Scooter.fromJson(e as Map)) + .toList(), + pagination: Pagination.fromJson(json['pagination']), + ); + } +} \ No newline at end of file diff --git a/lib/data/models/subscriptions_response.dart b/lib/data/models/subscriptions_response.dart new file mode 100644 index 0000000..a9319eb --- /dev/null +++ b/lib/data/models/subscriptions_response.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/domain/entities/subscription.dart'; + +class SubscriptionsResponse { + final List subscriptions; + + SubscriptionsResponse({required this.subscriptions}); + + factory SubscriptionsResponse.fromJson(Map json) { + return SubscriptionsResponse( + subscriptions: (json['data'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/lib/data/models/tariffs_response.dart b/lib/data/models/tariffs_response.dart new file mode 100644 index 0000000..0043cf9 --- /dev/null +++ b/lib/data/models/tariffs_response.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/domain/entities/tariff.dart'; + +class TariffsResponse { + final List tariffs; + + TariffsResponse({required this.tariffs}); + + factory TariffsResponse.fromJson(Map json) { + return TariffsResponse( + tariffs: (json['data'] as List) + .map((e) => Tariff.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/lib/data/models/user_check_response_dto.dart b/lib/data/models/user_check_response_dto.dart new file mode 100644 index 0000000..8ca073e --- /dev/null +++ b/lib/data/models/user_check_response_dto.dart @@ -0,0 +1,22 @@ +class UserCheckResponseDto { + final bool success; + final bool hasFine; + final bool hasUnpaidOrder; + final bool hasCard; + + UserCheckResponseDto({ + required this.success, + required this.hasFine, + required this.hasUnpaidOrder, + required this.hasCard, + }); + + factory UserCheckResponseDto.fromJson(Map json) { + return UserCheckResponseDto( + success: json['success'] ?? false, + hasFine: json['hasFine'] ?? false, + hasUnpaidOrder: json['hasUnpaidOrder'] ?? false, + hasCard: json['hasCard'] ?? false, + ); + } +} diff --git a/lib/data/models/verify_code_request_dto.dart b/lib/data/models/verify_code_request_dto.dart new file mode 100644 index 0000000..54d0a7f --- /dev/null +++ b/lib/data/models/verify_code_request_dto.dart @@ -0,0 +1,10 @@ +class VerifyCodeRequestDto { + final String code; + final String token; + + VerifyCodeRequestDto({required this.code, required this.token}); + + Map toJson() { + return {"code": code, "token": token}; + } +} \ No newline at end of file diff --git a/lib/data/models/zones_response.dart b/lib/data/models/zones_response.dart new file mode 100644 index 0000000..acce322 --- /dev/null +++ b/lib/data/models/zones_response.dart @@ -0,0 +1,22 @@ +import '../../domain/entities/pagination.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/zone.dart'; + +class ZonesResponse { + final List zones; + final Pagination pagination; + + ZonesResponse({ + required this.zones, + required this.pagination, + }); + + factory ZonesResponse.fromJson(Map json) { + return ZonesResponse( + zones: (json['data'] as List) + .map((e) => Zone.fromJson(e as Map)) + .toList(), + pagination: Pagination.fromJson(json['pagination']), + ); + } +} \ No newline at end of file diff --git a/lib/data/network/api_service.dart b/lib/data/network/api_service.dart new file mode 100644 index 0000000..1422b37 --- /dev/null +++ b/lib/data/network/api_service.dart @@ -0,0 +1,1072 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:async/async.dart'; +import 'package:be_happy/data/exceptions/auth_block_exception.dart'; +import 'package:be_happy/data/exceptions/auth_exception.dart'; +import 'package:be_happy/data/exceptions/route_history_not_found_exception.dart'; +import 'package:be_happy/data/exceptions/scooter_not_found_exception.dart'; +import 'package:be_happy/data/exceptions/unauthorized_exception.dart'; +import 'package:be_happy/data/exceptions/wrong_zone_exception.dart'; +import 'package:be_happy/domain/entities/active_scooter_order.dart'; +import 'package:be_happy/domain/entities/scooter.dart'; +import 'package:be_happy/domain/service/security_service.dart'; +import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:intl/intl.dart'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart'; +import 'package:flutter_client_sse/constants/sse_request_type_enum.dart'; +import 'package:flutter_client_sse/flutter_client_sse.dart'; + +import '../../domain/entities/point.dart'; +import '../../domain/entities/user_profile.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/entities/scooter_order.dart'; +import '../../domain/entities/subscription.dart'; +import '../../domain/entities/user_check_flags.dart'; +import '../../domain/entities/certificate.dart'; +import '../models/scooters_response.dart'; +import '../models/tariffs_response.dart'; +import '../models/zones_response.dart'; +import '../models/subscriptions_response.dart'; +import '../models/user_check_response_dto.dart'; + +class ApiService { + static const String baseUrl = "https://sharing-api.sparkit.by/api/v1"; + static const String fileBaseUrl = "https://sharing-api.sparkit.by"; + + final SecurityService _securityService; + final Dio _dio; + + ApiService(this._securityService, this._dio); + + Future _getAuthOptions() async { + final accessToken = await _securityService.getAccessToken(); + return Options(headers: {"Authorization": "Bearer $accessToken"}); + } + + Future sendPhone(String phone, String model, String systemId) async { + try { + final response = await _dio.post( + "$baseUrl/auth/client/login", + data: {"phone": phone, "model": model, "systemId": systemId}, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return response.data["token"]; + } + return null; + } catch (e) { + return null; + } + } + + Future?> verifyCode(String code, String token) async { + try { + final response = await _dio.post( + "$baseUrl/auth/client/verify", + data: {"code": code, "token": token}, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return { + "accessToken": response.data["accessToken"], + "refreshToken": response.data["refreshToken"], + }; + } + return null; + } on DioException catch (e) { + if (e.response?.statusCode == 400) { + final attempts = + int.tryParse(e.response?.data["message"]?.toString() ?? "0") ?? 0; + if (attempts == 0) throw AuthBlockException(); + throw AuthException("Ошибка. Неверный код.", attempts); + } else if (e.response?.statusCode == 403) { + throw AuthBlockException(); + } + rethrow; + } + } + + Future?> refresh() async { + final refreshToken = await _securityService.getRefreshToken(); + try { + final response = await _dio.get( + "$baseUrl/auth/client/refresh", + options: Options(headers: {"Authorization": "Bearer $refreshToken"}), + ); + + if (response.statusCode == 200) { + return { + "accessToken": response.data["accessToken"], + "refreshToken": response.data["refreshToken"], + }; + } + return null; + } catch (e) { + return null; + } + } + + Future getProfile() async { + try { + final response = await _dio.get( + "$baseUrl/client/me", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200) { + final data = response.data; + final profileData = data["profile"]; + if (profileData == null) return null; + + String? parsedDate; + if (profileData["dob"] != null) { + try { + parsedDate = DateFormat( + 'dd.MM.yyyy', + ).format(DateTime.parse(profileData["dob"])); + } catch (_) {} + } + + final int? avatarId = profileData["avatarId"]; + String? avatarUrl; + + if (avatarId != null && profileData["avatar"] != null) { + final String? avatarPath = profileData["avatar"]["path"]; + if (avatarPath != null && avatarPath.isNotEmpty) { + avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString(); } + } + + dynamic balanceRaw = profileData["balance"]; + int? balance; + if (balanceRaw is int) { + balance = balanceRaw; + } else if (balanceRaw is String) { + balance = int.tryParse(balanceRaw); + } + + return UserProfile( + name: profileData["name"] ?? "", + birthDate: parsedDate ?? "", + phone: data["phone"] ?? "", + balance: balance, + email: profileData["email"] ?? "", + avatarId: avatarId, + avatarUrl: avatarUrl, + ); + } + return null; + } catch (e) { + return null; + } + } + + Future> getPaymentCards() async { + final response = await _dio.get( + "$baseUrl/client/me", + options: await _getAuthOptions(), + ); + if (response.statusCode == 200) { + final List cardsJson = response.data["clientCards"] ?? []; + return Future.wait( + cardsJson.map((cardJson) async { + return PaymentCard( + id: cardJson['id'], + clientId: cardJson['clientId'], + expirationMonth: cardJson['expirationMonth'], + expirationYear: cardJson['expirationYear'], + cardHolder: cardJson['cardHolder'], + cardLastNumber: cardJson['cardLastNumber'], + isMain: cardJson['isMain'], + type: cardJson['cardType'], + ); + }), + ); + } + return []; + } + + Future updateProfile(UserProfile profile) async { + const url = "$baseUrl/client/me"; + final accessToken = await _securityService.getAccessToken(); + + final Map body = { + if (profile.email != null && profile.email!.isNotEmpty) + "email": profile.email, + if (profile.name != null && profile.name!.isNotEmpty) + "name": profile.name, + if (profile.birthDate.isNotEmpty) "dob": profile.birthDate, + if (profile.avatarId != null) "avatarId": profile.avatarId, + }; + + if (body.isEmpty) return null; + + try { + final response = await _dio.patch( + url, + data: body, + options: Options(headers: {"Authorization": "Bearer $accessToken"}), + ); + + if (response.statusCode == 200) { + final data = response.data; + final profileData = data["profile"]; + if (profileData == null) return null; + + String? parsedDate; + if (profileData["dob"] != null) { + try { + parsedDate = DateFormat( + 'dd.MM.yyyy', + ).format(DateTime.parse(profileData["dob"])); + } catch (_) {} + } + + final int? avatarId = profileData["avatarId"]; + String? avatarUrl; + + if (avatarId != null && profileData["avatar"] != null) { + final String? avatarPath = profileData["avatar"]["path"]; + if (avatarPath != null && avatarPath.isNotEmpty) { + avatarUrl = "$fileBaseUrl/${avatarPath.replaceFirst('/', '')}"; + } + } + + dynamic balanceRaw = data["balance"]; + int? balance; + if (balanceRaw is int) { + balance = balanceRaw; + } else if (balanceRaw is String) { + balance = int.tryParse(balanceRaw); + } + + return UserProfile( + name: profileData["name"] ?? "", + birthDate: parsedDate ?? "", + phone: data["phone"] ?? "", + balance: balance, + email: profileData["email"] ?? "", + avatarId: avatarId, + avatarUrl: avatarUrl, + ); + } + return null; + } on DioException catch (e) { + print( + "ERROR: updateProfile failed: ${e.response?.statusCode} - ${e.message}", + ); + rethrow; + } + } + + Future checkUser() async { + const url = "$baseUrl/scooterorder/check"; + final accessToken = await _securityService.getAccessToken(); + + try { + final response = await _dio.post( + url, + options: Options(headers: {"Authorization": "Bearer $accessToken"}), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final dto = UserCheckResponseDto.fromJson(response.data); + return UserCheckFlags( + hasFine: dto.hasFine, + hasUnpaidOrder: dto.hasUnpaidOrder, + hasCard: dto.hasCard, + ); + } + return null; + } catch (e) { + print("ERROR: checkUser failed: $e"); + return null; + } + } + + Future uploadPhoto(File imageFile) async { + try { + final mimeType = lookupMimeType(imageFile.path) ?? 'image/jpeg'; + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + imageFile.path, + filename: basename(imageFile.path), + contentType: MediaType.parse(mimeType), + ), + }); + + final response = await _dio.post( + "$baseUrl/client/upload", + data: formData, + options: await _getAuthOptions(), + ); + + return response.data['id']; + } catch (e) { + rethrow; + } + } + + Future> uploadScooterPhotos(List images) async { + try { + final formData = FormData(); + for (var file in images) { + final mimeType = lookupMimeType(file.path) ?? 'image/jpeg'; + formData.files.add( + MapEntry( + 'files', + await MultipartFile.fromFile( + file.path, + filename: basename(file.path), + contentType: MediaType.parse(mimeType), + ), + ), + ); + } + + final response = await _dio.post( + "$baseUrl/scooterorder/upload", + data: formData, + options: await _getAuthOptions(), + ); + + final List list = response.data['data'] ?? []; + return list.map((item) => item['id'] as int).toList(); + } catch (e) { + rethrow; + } + } + + Future getScooters({ + required List area, + int page = 1, + int pageSize = 500, + }) async { + const path = "$baseUrl/scooter/available"; + + final queryParams = { + 'readAll': 'true', + 'area': area.map((coord) => coord.toString()).toList(), + 'page': page, + 'pageSize': pageSize, + }; + + final accessToken = await _securityService.getAccessToken(); + if (accessToken == null) return null; + + try { + final response = await _dio.get( + path, + queryParameters: queryParams, + options: Options(headers: {"Authorization": "Bearer $accessToken"}), + ); + + if (response.statusCode == 200) { + return ScootersResponse.fromJson(response.data); + } + return null; + } catch (e) { + print("ERROR: getScooters failed: $e"); + return null; + } + } + + Future getScooterById({required int id}) async { + try { + final response = await _dio.get( + "$baseUrl/scooter/$id/client", + options: await _getAuthOptions(), + ); + if (response.statusCode == 200 || response.statusCode == 201) { + return Scooter.fromJson(response.data); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + } + return null; + } + + Future getScooterByTitle({required String title}) async { + final url = "$baseUrl/scooter/$title/code"; + + try { + final response = await _dio.get( + url, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return Scooter.fromJson(response.data); + } + + if (response.statusCode == 404) { + throw ScooterNotFoundException(message: "Самокат не найден"); + } + + return null; + } on DioException catch (e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + if (e.response?.statusCode == 404) throw ScooterNotFoundException(); + return null; + } + } + + Future getZones({ + required List area, + int page = 1, + int pageSize = 500, + }) async { + const path = "$baseUrl/zone/available"; + + final queryParams = { + 'readAll': 'true', + 'area': area.map((coord) => coord.toString()).toList(), + 'page': page, + 'pageSize': pageSize, + }; + + try { + final response = await _dio.get( + path, + queryParameters: queryParams, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200) { + return ZonesResponse.fromJson(response.data); + } + return null; + } catch (e) { + print("ERROR: getZones failed: $e"); + return null; + } + } + + Future getAvailableTariffs({required int scooterId}) async { + final url = "$baseUrl/scooterplan/$scooterId/available"; + + try { + final response = await _dio.get(url, options: await _getAuthOptions()); + + if (response.statusCode == 200) { + return TariffsResponse.fromJson(response.data); + } + return null; + } catch (e) { + print("ERROR: getAvailableTariffs failed: $e"); + return null; + } + } + + Future> getClientSubscriptions() async { + const url = "$baseUrl/scootersubscription/client"; + + try { + final response = await _dio.get(url, options: await _getAuthOptions()); + + if (response.statusCode == 200) { + final Map responseData = response.data; + final List items = responseData['data'] ?? []; + + return items.map((item) { + final Map subscriptionMap = + Map.from(item['subscription'] ?? {}); + + if (item['expiredAt'] != null) { + subscriptionMap['activeTo'] = item['expiredAt']; + } + + return Subscription.fromJson(subscriptionMap); + }).toList(); + } + return []; + } catch (e) { + print("APISERVICE (getClientSubscriptions) Error: $e"); + return []; + } + } + + Future getAvailableSubscriptions() async { + const url = "$baseUrl/scootersubscription/available"; + + try { + final response = await _dio.get(url, options: await _getAuthOptions()); + + if (response.statusCode == 200) { + return SubscriptionsResponse.fromJson(response.data); + } + return null; + } catch (e) { + print("APISERVICE (getAvailableSubscriptions) Error: $e"); + return null; + } + } + + Future getSubscriptionById({required int id}) async { + final url = "$baseUrl/scootersubscription/$id/client"; + + try { + final response = await _dio.get(url, options: await _getAuthOptions()); + + if (response.statusCode == 200 || response.statusCode == 201) { + return Subscription.fromJson(response.data); + } + return null; + } on DioException catch (e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + print("APISERVICE (getSubscriptionById) Error: $e"); + return null; + } + } + + Future activateSubscription({required int optionId}) async { + const url = "$baseUrl/scootersubscription/client"; + + try { + final response = await _dio.post( + url, + data: {"optionId": optionId}, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 201) { + return response.data["success"] == true; + } + return false; + } on DioException catch (e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + print("APISERVICE (activateSubscription) Error: $e"); + return false; + } + } + + Future addPaymentCard({ + required String cardNumber, + required String cardHolder, + required int expirationMonth, + required int expirationYear, + required String cvv, + }) async { + const url = "$baseUrl/client/card"; + final cleanCardNumber = cardNumber.replaceAll(' ', ''); + + try { + final response = await _dio.post( + url, + data: { + "cardNumber": cleanCardNumber, + "cardHolder": cardHolder, + "expirationMonth": expirationMonth, + "expirationYear": expirationYear, + "cvv": cvv, + }, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return response.data['id'] as int; + } + throw AuthException('Непредвиденный статус: ${response.statusCode}', 0); + } on DioException catch (e) { + final data = e.response?.data; + final statusCode = e.response?.statusCode; + + if (statusCode == 400) { + final message = _parseErrorMessage(data); + throw AuthException(message ?? 'Неверные данные карты', 0); + } else if (statusCode == 401) { + throw UnauthorizedException(); + } else if (statusCode == 403) { + throw AuthBlockException(); + } else if (statusCode == 404) { + throw AuthException('Пользователь не найден', 0); + } + throw AuthException('Ошибка сервера: $statusCode', 0); + } + } + + Future setMainPaymentCard(int cardId) async { + final url = "$baseUrl/client/card/$cardId"; + + try { + await _dio.put( + url, + data: {"isMain": true}, + options: await _getAuthOptions(), + ); + } on DioException catch (e) { + final statusCode = e.response?.statusCode; + + if (statusCode == 400) { + final message = _parseErrorMessage(e.response?.data); + throw AuthException( + message ?? 'Ошибка при установке основной карты', + 0, + ); + } else if (statusCode == 401) { + throw UnauthorizedException(); + } else if (statusCode == 403) { + throw AuthBlockException(); + } else if (statusCode == 404) { + throw AuthException('Карта не найдена', 0); + } + throw AuthException('Ошибка сервера: $statusCode', 0); + } + } + + Future removePaymentCard(int cardId) async { + final url = "$baseUrl/client/card/$cardId"; + + try { + await _dio.delete(url, options: await _getAuthOptions()); + } on DioException catch (e) { + final statusCode = e.response?.statusCode; + + if (statusCode == 400) { + final message = _parseErrorMessage(e.response?.data); + throw AuthException(message ?? 'Ошибка при удалении карты', 0); + } else if (statusCode == 401) { + throw UnauthorizedException(); + } else if (statusCode == 403) { + throw AuthBlockException(); + } else if (statusCode == 404) { + throw AuthException('Карта не найдена', 0); + } + throw AuthException('Ошибка сервера: $statusCode', 0); + } + } + + Future bookScooter({ + required int scooterId, + required int planId, + int? subscriptionId, + int? cardId, + required bool isBalance, + required bool isInsurance, + }) async { + try { + final response = await _dio.post( + "$baseUrl/scooterorder/booking", + data: { + "scooterId": scooterId, + "planId": planId, + "subscriptionId": subscriptionId, + "cardId": cardId, + "isBalance": isBalance, + "isInsurance": isInsurance, + }, + options: await _getAuthOptions(), + ); + + return ScooterOrder.fromJson(response.data); + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future startRide(int orderId) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/start", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future cancelRide(int orderId) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/cancel", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future pauseRide(int orderId) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/pause", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future resumeRide(int orderId) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/resume", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future finishRide({ + required int orderId, + required List filesId, + }) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/finish", + data: {"files": filesId}, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + if (e.response?.statusCode == 400) { + final data = e.response?.data; + + if (data is Map && data['message'] is List) { + final firstError = data['message'][0]['message'].toString(); + + if (firstError.contains("Wrong zone")) { + throw WrongZoneException(message: firstError); + } + } + + if (data is Map && data['message'] is String) {} + } else { + _handleDioError(e); + } + rethrow; + } + } + + Future payRide(int orderId) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/pay", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future> getClientOrders() async { + try { + final response = await _dio.get( + "$baseUrl/scooterorder/active", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200) { + final data = response.data; + if (data is Map && data['data'] is List) { + final List ordersList = data['data']; + return ordersList.map((json) => ScooterOrder.fromJson(json)).toList(); + } + } + return []; + } on DioException catch (e) { + _handleDioError(e); + return []; + } + } + + Future updateScooterOrderData({ + required int orderId, + Map? data, + }) async { + try { + final response = await _dio.get( + "$baseUrl/scooterorder/$orderId/data", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ActiveScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future getScooterOrderById({required int id}) async { + try { + final response = await _dio.get( + "$baseUrl/scooterorder/$id/client", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future payScooterOrderWithPhotos({ + required int orderId, + required int? cardId, + required bool isBalance, + }) async { + try { + final response = await _dio.put( + "$baseUrl/scooterorder/$orderId/pay", + data: {"cardId": cardId, "isBalance": isBalance}, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return ScooterOrder.fromJson(response.data); + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future> getScooterOrderHistory({ + int page = 1, + int pageSize = 20, + }) async { + try { + final response = await _dio.get( + "$baseUrl/scooterorder/history", + queryParameters: {"page": page, "pageSize": pageSize}, + options: await _getAuthOptions(), + ); + + return response.data; + } on DioException catch (e) { + _handleDioError(e); + return {}; + } + } + + + Future> getScooterOrderRouteHistory({required int id}) async { + try { + final response = await _dio.get( + "$baseUrl/scooterorder/$id/routehistory", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200) { + final String routeString = response.data['route'] ?? '[]'; + final List routeList = json.decode(routeString); + + return routeList.map((item) => Point( + (item[1] as num).toDouble(), + (item[0] as num).toDouble(), + )).toList(); + } + + throw RouteHistoryNotFoundException(message: "История маршрута не найдена"); + } on DioException catch (e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + if (e.response?.statusCode == 404) throw RouteHistoryNotFoundException(); + rethrow; + } + } + + Future> getNews() async { + try { + final response = await _dio.get( + "$baseUrl/news", + options: await _getAuthOptions(), + ); + return response.data; + } on DioException catch (e) { + _handleDioError(e); + return {}; + } + } + + Future?> getNewsById(int newsId) async { + try { + final response = await _dio.get( + '$baseUrl/news/$newsId', + options: await _getAuthOptions(), + ); + return response.data; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Future?> cancelNotification(int id) async { + try { + final response = await _dio.put( + "$baseUrl/notification/client/$id/cancel", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return response.data; + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + Stream> getNotificationsStream() { + final url = "$baseUrl/notification/client/stream"; + + final controller = StreamController>(); + + _securityService.getAccessToken().then((accessToken) { + if (accessToken == null) { + controller.addError(UnauthorizedException()); + return; + } + + print("[SSE] Subscribing via flutter_client_sse..."); + + SSEClient.subscribeToSSE( + method: SSERequestType.GET, + url: url, + header: { + "Authorization": "Bearer $accessToken", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + }, + ).listen( + (event) { + print("Received line [SSE]: id=${event.id}, event=${event.event}"); + print("Data [SSE]: ${event.data}"); + + if (event.data != null && event.data!.isNotEmpty) { + try { + final Map jsonData = jsonDecode(event.data!); + print("✅ [SSE] Parsed JSON: $jsonData"); + controller.add(jsonData); + } catch (e) { + print("❌ [SSE] JSON Parse Error: $e | Raw: ${event.data}"); + } + } + }, + onError: (error) { + print(" [SSE] Library Error: $error"); + controller.addError(error); + }, + onDone: () { + print(" [SSE] Library Stream Closed"); + controller.close(); + }, + ); + }); + + return controller.stream; + } + + Future> getCertificates() async { + try { + final response = await _dio.get( + "$baseUrl/certificate", + options: await _getAuthOptions(), + ); + + if (response.statusCode == 200) { + final data = response.data; + final dataList = data['data'] as List; + return dataList.map((json) => Certificate.fromJson(json)).toList(); + } + return []; + } on DioException catch (e) { + _handleDioError(e); + return []; + } + } + + Future?> purchaseCertificate({ + required int certificateId, + required int cardId, + }) async { + try { + final response = await _dio.post( + "$baseUrl/certificate/$certificateId", + data: {"cardId": cardId}, + options: await _getAuthOptions(), + ); + + if (response.statusCode == 201) { + return response.data; + } + return null; + } on DioException catch (e) { + _handleDioError(e); + return null; + } + } + + void _handleDioError(DioException e) { + if (e.response?.statusCode == 401) throw UnauthorizedException(); + if (e.response?.statusCode == 403) throw AuthBlockException(); + + final message = _parseErrorMessage(e.response?.data); + throw AuthException(message ?? 'Ошибка сервера', 0); + } + + String? _parseErrorMessage(dynamic data) { + if (data is Map) { + final messages = data['message'] as List?; + if (messages != null && messages.isNotEmpty) { + final firstError = messages.first as Map?; + return firstError?['message'] as String?; + } + return data['message'] as String?; + } + return null; + } +} diff --git a/lib/data/network/geocoding_remote_datasource.dart b/lib/data/network/geocoding_remote_datasource.dart new file mode 100644 index 0000000..0a43698 --- /dev/null +++ b/lib/data/network/geocoding_remote_datasource.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:yandex_mapkit/yandex_mapkit.dart'; + +class GeocodingRemoteDataSource { + + Future getAddressFromPoint({ + required double latitude, + required double longitude, + }) async { + final point = Point( + latitude: latitude, + longitude: longitude, + ); + + final (session, resultFuture) = await YandexSearch.searchByPoint( + point: point, + zoom: 16, + searchOptions: const SearchOptions( + searchType: SearchType.geo, + resultPageSize: 1, + ), + ); + + try { + final result = await resultFuture; + + if (result.items == null || result.items!.isEmpty) { + throw Exception("Адрес не найден"); + } + + final item = result.items!.first; + + print("ADDRESS FETCH RESULT ${item.name}"); + + final toponymAddress = + item.toponymMetadata?.address?.formattedAddress; + + if (toponymAddress != null && toponymAddress.isNotEmpty) { + return toponymAddress; + } + + final businessAddress = + item.businessMetadata?.address?.formattedAddress; + + if (businessAddress != null && businessAddress.isNotEmpty) { + return businessAddress; + } + + return item.name; + } catch (e) { + throw Exception("Ошибка получения адреса: $e"); + } finally { + await session.close(); + } + } + + Future?> getPedestrianRoutes(Point userPosition, + Point targetPosition) async { + final (session, resultFuture) = await YandexPedestrian.requestRoutes( + points: [ + RequestPoint( + point: userPosition, requestPointType: RequestPointType.wayPoint), + RequestPoint(point: targetPosition, + requestPointType: RequestPointType.wayPoint) + ], + fitnessOptions: FitnessOptions(avoidSteep: false, avoidStairs: false), + timeOptions: TimeOptions() + ); + + try { + final result = await resultFuture; + + final distance = result.routes?.first.metadata.weight.walkingDistance.value; + + print("Дистанция до самоката: $distance"); + + return result.routes; + + } catch (e) { + print('Error: $e'); + } + return null; + + } +} \ No newline at end of file diff --git a/lib/data/repositories/app_settings_repository_impl.dart b/lib/data/repositories/app_settings_repository_impl.dart new file mode 100644 index 0000000..8aa3d52 --- /dev/null +++ b/lib/data/repositories/app_settings_repository_impl.dart @@ -0,0 +1,42 @@ +import 'package:be_happy/data/service/app_setting_service.dart'; +import 'package:be_happy/domain/entities/map_settings.dart'; +import 'package:be_happy/domain/repositories/app_settings_repository.dart'; + +class AppSettingsRepositoryImpl extends AppSettingsRepository { + static const String SHOW_ALL_PLACEMARKS = "all_placemarks"; + static const String SHOW_ALL_ZONES = "all_zones"; + static const String SHOW_PARKING_ZONES = "parking_zones"; + static const String SHOW_RESTRICTED_PARKING_ZONES = "restricted_parking_zones"; + static const String SHOW_RESTRICTED_DRIVING_ZONES = "restricted_driving_zones"; + + final AppSettingsService appSettingsService; + + AppSettingsRepositoryImpl(this.appSettingsService); + + @override + Future getMapSettings() async { + MapSettings settings = MapSettings( + all_placemarks: appSettingsService.getMapSettingsFlag( + SHOW_ALL_PLACEMARKS), + all_zones: appSettingsService.getMapSettingsFlag( + SHOW_ALL_ZONES), + parking_zones: appSettingsService.getMapSettingsFlag( + SHOW_PARKING_ZONES), + restricted_parking_zones: appSettingsService.getMapSettingsFlag( + SHOW_RESTRICTED_PARKING_ZONES), + restricted_driving_zones: appSettingsService.getMapSettingsFlag( + SHOW_RESTRICTED_DRIVING_ZONES) + ); + + return settings; + } + + @override + Future saveMapSettings(MapSettings settings) async { + appSettingsService.saveMapSettingsFlag(SHOW_ALL_PLACEMARKS, settings.all_placemarks); + appSettingsService.saveMapSettingsFlag(SHOW_ALL_ZONES, settings.all_zones); + appSettingsService.saveMapSettingsFlag(SHOW_PARKING_ZONES, settings.parking_zones); + appSettingsService.saveMapSettingsFlag(SHOW_RESTRICTED_PARKING_ZONES, settings.restricted_parking_zones); + appSettingsService.saveMapSettingsFlag(SHOW_RESTRICTED_DRIVING_ZONES, settings.restricted_driving_zones); + } +} diff --git a/lib/data/repositories/auth_repository_impl.dart b/lib/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..a461d32 --- /dev/null +++ b/lib/data/repositories/auth_repository_impl.dart @@ -0,0 +1,85 @@ +import 'package:be_happy/core/failures.dart'; +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/data/exceptions/auth_block_exception.dart'; +import 'package:be_happy/data/exceptions/auth_exception.dart'; +import 'package:be_happy/data/repositories/pin_repository_impl.dart'; +import 'package:be_happy/domain/service/device_info_service.dart'; +import 'package:be_happy/domain/service/security_service.dart'; + +import '../../domain/repositories/auth_repository.dart'; +import '../../domain/entities/user_auth_data.dart'; + +import '../network/api_service.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final ApiService _apiService; + final DeviceInfoService _deviceInfoService; + final SecurityService _securityService; + + String tempToken = ""; + + AuthRepositoryImpl( + this._apiService, + this._deviceInfoService, + this._securityService, + ); + + @override + Future login(String phone) async { + final systemId = await _deviceInfoService.getSystemId() ?? "UnknownId"; + final deviceModel = await _deviceInfoService.getDeviceModel(); + + final response = await _apiService.sendPhone(phone, deviceModel, systemId); + if (response != null) { + tempToken = response; + print("TEMP TOKEN IS $tempToken"); + return response; + } else { + throw Exception("Login failed"); + } + } + + @override + Future> verifyCode(String code, String token) async { + late final Result result; + try { + final response = await _apiService.verifyCode(code, tempToken); + if (response != null) { + final authData = UserAuthData( + accessToken: response["accessToken"]!, + refreshToken: response["refreshToken"]!, + ); + await _securityService.saveTokens(authData); + result = Success(null); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException { + result = Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future refreshToken() async { + final response = await _apiService.refresh(); + print("REFRESH: $response"); + if (response != null) { + final authData = UserAuthData( + accessToken: response["accessToken"]!, + refreshToken: response["refreshToken"]!, + ); + await _securityService.saveTokens(authData); + return authData; + } else { + throw Exception("Refresh token failed"); + } + } + + @override + Future logout() async { + await _securityService.removeTokens(); + } +} diff --git a/lib/data/repositories/certificate_repository_impl.dart b/lib/data/repositories/certificate_repository_impl.dart new file mode 100644 index 0000000..23557fe --- /dev/null +++ b/lib/data/repositories/certificate_repository_impl.dart @@ -0,0 +1,59 @@ +import '../../core/failures.dart'; +import '../../core/result.dart'; +import '../../domain/entities/certificate.dart'; +import '../../domain/repositories/certificate_repository.dart'; +import '../../domain/service/security_service.dart'; +import '../network/api_service.dart'; +import '../exceptions/auth_exception.dart'; +import '../exceptions/auth_block_exception.dart'; +import '../exceptions/unauthorized_exception.dart'; + +class CertificateRepositoryImpl implements CertificateRepository { + final ApiService apiService; + final SecurityService securityService; + + CertificateRepositoryImpl(this.apiService, this.securityService); + + @override + Future>> getCertificates() async { + try { + final certificates = await apiService.getCertificates(); + return Success(certificates); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } + + @override + Future>> purchaseCertificate({ + required int certificateId, + required int cardId, + }) async { + try { + final result = await apiService.purchaseCertificate( + certificateId: certificateId, + cardId: cardId, + ); + + if (result != null) { + return Success(result); + } else { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } +} diff --git a/lib/data/repositories/news_repository_impl.dart b/lib/data/repositories/news_repository_impl.dart new file mode 100644 index 0000000..62aeca2 --- /dev/null +++ b/lib/data/repositories/news_repository_impl.dart @@ -0,0 +1,46 @@ +import '../../domain/entities/news.dart'; +import '../../domain/repositories/news_repository.dart'; +import '../service/news_api_service.dart'; +import 'dart:developer' as dev; + +class NewsRepositoryImpl implements NewsRepository { + final NewsApiService _apiService; + + NewsRepositoryImpl(this._apiService); + + @override + Future> getNews() async { + try { + dev.log('NewsRepository: Загрузка новостей...'); + + final response = await _apiService.getNews(); + final List data = response['data'] ?? []; + + final newsList = data.map((json) => NewsEntity.fromJson(json)).toList(); + + dev.log('NewsRepository: Загружено ${newsList.length} новостей'); + + return newsList; + } catch (e, stackTrace) { + dev.log('NewsRepository: Ошибка: $e', stackTrace: stackTrace); + throw Exception('Не удалось загрузить новости: $e'); + } + } + + @override + Future getNewsById(int id) async { + try { + dev.log('NewsRepository: Загрузка новости с ID: $id'); + + final response = await _apiService.getNewsById(id); + + final news = NewsEntity.fromJson(response); + + dev.log('NewsRepository: Успешно загружена новость с ID: $id'); + return news; + } catch (e, stackTrace) { + dev.log('NewsRepository: Ошибка: $e', stackTrace: stackTrace); + throw Exception('Не удалось загрузить новость: $e'); + } + } +} \ No newline at end of file diff --git a/lib/data/repositories/notification_repository_impl.dart b/lib/data/repositories/notification_repository_impl.dart new file mode 100644 index 0000000..e75d066 --- /dev/null +++ b/lib/data/repositories/notification_repository_impl.dart @@ -0,0 +1,36 @@ +import 'package:be_happy/domain/entities/client_notification.dart'; + +import '../../data/network/api_service.dart'; +import '../../domain/repositories/notification_repository.dart'; +import '../models/client_notification_dto.dart'; + +class NotificationRepositoryImpl implements NotificationRepository { + final ApiService _apiService; + + NotificationRepositoryImpl(this._apiService); + + @override + Stream getNotificationsStream() { + return _apiService.getNotificationsStream().map((data) { + // Создаем DTO из данных API + final dto = ClientNotificationDto.fromJson(data); + // Преобразуем в entity + return dto.toEntity(); + }); + } + + @override + Future cancelNotification(int id) async { + final data = await _apiService.cancelNotification(id); + if (data == null) { + throw Exception("Failed to cancel notification"); + } + final dto = ClientNotificationDto.fromJson(data); + return dto.toEntity(); + } + + @override + void closeStream() { + // соединение закрывается автоматически при отписке от stream + } +} diff --git a/lib/data/repositories/payment_repository_impl.dart b/lib/data/repositories/payment_repository_impl.dart new file mode 100644 index 0000000..cd53cf0 --- /dev/null +++ b/lib/data/repositories/payment_repository_impl.dart @@ -0,0 +1,116 @@ +import '../../core/failures.dart'; +import '../../core/result.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/repositories/payment_repository.dart'; +import '../../domain/service/security_service.dart'; +import '../network/api_service.dart'; +import '../exceptions/auth_exception.dart'; +import '../exceptions/auth_block_exception.dart'; +import '../exceptions/unauthorized_exception.dart'; +import '../service/security_service_impl.dart'; + +class PaymentRepositoryImpl implements PaymentRepository { + final ApiService apiService; + final SecurityService securityService; + + PaymentRepositoryImpl(this.apiService, this.securityService); + + @override + Future>> getPaymentCards() async { + try { + final cards = await apiService.getPaymentCards(); + return Success(cards); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } + + @override + Future> addPaymentCard({ + required String cardNumber, + required String cardHolder, + required String expiryMonth, + required String expiryYear, + required String cvv, + }) async { + try { + final cardId = await apiService.addPaymentCard( + cardNumber: cardNumber, + cardHolder: cardHolder, + expirationMonth: int.parse(expiryMonth), + expirationYear: int.parse(expiryYear), + cvv: cvv, + ); + + // Сохраняем полный номер карты локально + await securityService.saveCardFullNumber(cardId, cardNumber); + + return Success(null); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } on FormatException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } + + @override + Future> setMainPaymentCard(int cardId) async { + try { + await apiService.setMainPaymentCard(cardId); + return Success(null); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } + + @override + Future> removePaymentCard(int cardId) async { + try { + await apiService.removePaymentCard(cardId); + await securityService.removeCardFullNumber(cardId); + return Success(null); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } + + @override + Future> activateSubscription(int optionId) async { + try { + final success = await apiService.activateSubscription(optionId: optionId); + return Success(success); + } on AuthException catch (e) { + return Failure(AuthFailure(e.attemptsLeft)); + } on AuthBlockException catch (_) { + return Failure(AuthBlockFailure("Ошибка. Вы заблокированы.")); + } on UnauthorizedException catch (_) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } catch (e) { + return Failure(UnknownFailure("Неизвестная ошибка")); + } + } +} diff --git a/lib/data/repositories/pin_repository_impl.dart b/lib/data/repositories/pin_repository_impl.dart new file mode 100644 index 0000000..60e27e5 --- /dev/null +++ b/lib/data/repositories/pin_repository_impl.dart @@ -0,0 +1,24 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class PinRepositoryImpl implements PinRepository { + final FlutterSecureStorage _storage; + + PinRepositoryImpl(this._storage); + + @override + Future getSavedPin() { + return _storage.read(key: "user_pin"); + } + + @override + Future savePin(String? pin) async { + await _storage.write(key: "user_pin", value: pin); + } + + @override + Future removePin() async { + await _storage.delete(key: "user_pin"); + } +} + diff --git a/lib/data/repositories/profile_repository_impl.dart b/lib/data/repositories/profile_repository_impl.dart new file mode 100644 index 0000000..185263a --- /dev/null +++ b/lib/data/repositories/profile_repository_impl.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:be_happy/data/network/api_service.dart'; + +import '../../domain/entities/user_profile.dart'; +import '../../domain/entities/user_check_flags.dart'; +import '../../domain/repositories/profile_repository.dart'; + +class UserProfileRepositoryImpl implements UserProfileRepository { + final ApiService _apiService; + + static const String kAccessToken = "access_token"; + static const String kRefreshToken = "refresh_token"; + + final UserProfile _cachedProfile = UserProfile( + name: 'Иванов Антон', + birthDate: '12-03-2005', + phone: '+375 00 000-00-00', + balance: null, + email: 'почта@gmail.com', + ); + + UserProfileRepositoryImpl(this._apiService); + + @override + Future getProfile() async { + return await _apiService.getProfile() ?? _cachedProfile; + } + + @override + Future updateProfile(UserProfile profile) async { + //await Future.delayed(const Duration(milliseconds: 300)); + print("UPDATE PROFILE DATA: $profile"); + return await _apiService.updateProfile(profile); + } + + @override + Future uploadProfilePhoto(File imageFile) async { + return await _apiService.uploadPhoto(imageFile); + } + + @override + Future checkUser() async { + return await _apiService.checkUser(); + } + + +} diff --git a/lib/data/repositories/scooter_repository_impl.dart b/lib/data/repositories/scooter_repository_impl.dart new file mode 100644 index 0000000..5cab688 --- /dev/null +++ b/lib/data/repositories/scooter_repository_impl.dart @@ -0,0 +1,444 @@ +import 'dart:io'; +import 'package:be_happy/data/exceptions/auth_block_exception.dart'; +import 'package:be_happy/data/exceptions/route_history_not_found_exception.dart'; +import 'package:be_happy/data/exceptions/scooter_not_found_exception.dart'; +import 'package:be_happy/data/exceptions/unauthorized_exception.dart'; +import 'package:be_happy/data/exceptions/wrong_zone_exception.dart'; +import 'package:be_happy/domain/entities/point.dart'; +import 'package:be_happy/domain/repositories/scooter_repository.dart'; + +import '../../core/failures.dart'; +import '../../core/result.dart'; +import '../../domain/entities/active_scooter_order.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/tariff.dart'; +import '../../domain/entities/subscription.dart'; +import '../../domain/entities/scooter_order.dart'; +import '../exceptions/auth_exception.dart'; +import '../network/api_service.dart'; + +class ScooterRepositoryImpl extends ScooterRepository { + final ApiService _apiService; + + final scooter = Scooter( + id: 123, + title: "Unnamed", + status: "Available", + latitude: 55.178960, + longitude: 30.222316, + batteryLevel: 89, + isOnline: true, + maxSpeed: 25, + number: "Unnamed", + ); + + ScooterRepositoryImpl(this._apiService); + + @override + Future> getScooters( + List area, + int page, + int pageSize, + ) async { + final responce = await _apiService.getScooters( + area: area, + page: page, + pageSize: pageSize, + ); + + final List? list = responce?.scooters; + // list?.add(scooter); + + // print("Scooters: ${list?.first.status}"); + + if (responce != null) { + return list ?? List.empty(); + } + + return List.empty(); + } + + @override + Future> getScooter(int id) async { + late final Result result; + try { + final scooter = await _apiService.getScooterById(id: id); + if (scooter != null) { + result = Success(scooter); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getAvailableTariffs(int scooterId) async { + late final Result> result; + try { + final response = await _apiService.getAvailableTariffs( + scooterId: scooterId, + ); + if (response != null) { + result = Success(response.tariffs); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getAvailableSubscriptions() async { + late final Result> result; + try { + final response = await _apiService.getAvailableSubscriptions(); + if (response != null) { + result = Success(response.subscriptions); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> getSubscriptionById(int id) async { + late final Result result; + try { + final subscription = await _apiService.getSubscriptionById(id: id); + if (subscription != null) { + result = Success(subscription); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getClientSubscriptions() async { + late final Result> result; + try { + final subscriptions = await _apiService.getClientSubscriptions(); + result = Success(subscriptions); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> bookScooter({ + required int scooterId, + required int planId, + int? subscriptionId, + int? cardId, + required bool isBalance, + required bool isInsurance, + }) async { + late final Result result; + try { + final order = await _apiService.bookScooter( + scooterId: scooterId, + planId: planId, + subscriptionId: subscriptionId, + cardId: cardId, + isBalance: isBalance, + isInsurance: isInsurance, + ); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> startRide(int orderId) async { + late final Result result; + try { + final order = await _apiService.startRide(orderId); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> cancelRide(int orderId) async { + late final Result result; + try { + final order = await _apiService.cancelRide(orderId); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> pauseRide(int orderId) async { + late final Result result; + try { + final order = await _apiService.pauseRide(orderId); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> resumeRide(int orderId) async { + late final Result result; + try { + final order = await _apiService.resumeRide(orderId); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> finishRide(int orderId, List files) async { + late final Result result; + try { + final order = await _apiService.finishRide( + orderId: orderId, + filesId: files, + ); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } on WrongZoneException catch (e) { + result = Failure(WrongZoneFailure(e.message)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> payRide(int orderId) async { + late final Result result; + try { + final order = await _apiService.payRide(orderId); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getClientOrders() async { + late final Result> result; + try { + final orders = await _apiService.getClientOrders(); + result = Success(orders); + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> uploadScooterPhotos(List images) async { + late final Result> result; + try { + final filesId = await _apiService.uploadScooterPhotos(images); + result = Success(filesId); + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> updateScooterOrderData({ + required int orderId, + }) async { + late final Result result; + try { + final order = await _apiService.updateScooterOrderData(orderId: orderId); + if (order != null) { + print("ORDER DATA FROM REPOSITORY: $order"); + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> payScooterOrderWithPhotos({ + required int orderId, + required int? cardId, + required bool isBalance, + }) async { + late final Result result; + try { + final order = await _apiService.payScooterOrderWithPhotos( + orderId: orderId, + cardId: cardId, + isBalance: isBalance, + ); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> getScooterOrderById(int id) async { + late final Result result; + try { + final order = await _apiService.getScooterOrderById(id: id); + if (order != null) { + result = Success(order); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getScooterOrderHistory({ + int page = 1, + int pageSize = 20, + }) async { + late final Result> result; + try { + final response = await _apiService.getScooterOrderHistory( + page: page, + pageSize: pageSize, + ); + final List data = response['data'] ?? []; + final orders = data.map((json) => ScooterOrder.fromJson(json)).toList(); + result = Success(orders); + } on AuthException catch (e) { + result = Failure(AuthFailure(e.attemptsLeft)); + } catch (e) { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future> getScooterByTitle(String title) async { + late final Result result; + try { + final scooter = await _apiService.getScooterByTitle(title: title); + if (scooter != null) { + result = Success(scooter); + } else { + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + } on ScooterNotFoundException catch (e) { + result = Failure(ScooterNotFoundFailure(e.message)); + } on UnauthorizedException catch (e) { + result = Failure(AuthFailure(0, message: e.toString())); + } on AuthBlockException catch (e) { + result = Failure(AuthBlockFailure(e.toString())); + } catch (e, stacktrace) { + print("REPOSITORY ERROR: $e"); + print("STACKTRACE: $stacktrace"); + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } + + @override + Future>> getScooterOrderRouteHistory(int id) async { + late final Result> result; + try { + final route = await _apiService.getScooterOrderRouteHistory(id: id); + result = Success(route); + } on RouteHistoryNotFoundException catch (e) { + result = Failure(RouteHistoryNotFoundFailure(e.message)); + } on UnauthorizedException catch (e) { + result = Failure(AuthFailure(0, message: e.toString())); + } on AuthBlockException catch (e) { + result = Failure(AuthBlockFailure(e.toString())); + } catch (e, stacktrace) { + print("REPOSITORY ERROR: $e"); + print("STACKTRACE: $stacktrace"); + result = Failure(UnknownFailure("Неизвестная ошибка")); + } + return result; + } +} diff --git a/lib/data/repositories/zone_repository_impl.dart b/lib/data/repositories/zone_repository_impl.dart new file mode 100644 index 0000000..a6e860f --- /dev/null +++ b/lib/data/repositories/zone_repository_impl.dart @@ -0,0 +1,69 @@ +import 'package:be_happy/domain/entities/zone.dart'; +import 'package:be_happy/domain/repositories/zone_repository.dart'; + +import '../../domain/entities/point.dart'; +import '../network/api_service.dart'; + +class ZoneRepositoryImpl extends ZoneRepository { + final ApiService _apiService; + + ZoneRepositoryImpl(this._apiService); + + @override + Future> getZones(List area, + int page, + int pageSize,) async { + + final List list = []; + final List list2 = []; + + /*list.add(Point(55.182372,30.203347)); + list.add(Point(55.173746,30.200257)); + list.add(Point(55.172153,30.212531)); + list.add(Point(55.179946,30.215964)); + + list2.add(Point(55.178304,30.227380)); + list2.add(Point(55.178059,30.229654)); + list2.add(Point(55.191339,30.234718)); + list2.add(Point(55.194352,30.234890)); + list2.add(Point(55.194377,30.231972)); + list2.add(Point(55.192123,30.232744)); + + final zone = Zone(id: 123, + title: "Zone 01", + description: "description", + type: "Finish", + isActive: true, + shapeType: "polygon", + points: list, + speedLimit: "speedLimit"); + + final zone2 = Zone(id: 124, + title: "Zone 02", + description: "description", + type: "NotDrive", + isActive: true, + shapeType: "polygon", + points: list2, + speedLimit: "speedLimit"); +*/ + + final responce = await _apiService.getZones( + area: area, + page: page, + pageSize: pageSize, + ); + + final List? zones = responce?.zones; + // zones?.add(zone); + // zones?.add(zone2); + + print("Scooters: ${zones?.first.title}"); + + if (responce != null) { + return zones ?? List.empty(); + } + + return List.empty(); + } +} diff --git a/lib/data/service/app_setting_service.dart b/lib/data/service/app_setting_service.dart new file mode 100644 index 0000000..023729b --- /dev/null +++ b/lib/data/service/app_setting_service.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class AppSettingsService { + final SharedPreferences sharedPreferences; + + AppSettingsService(this.sharedPreferences); + + Future saveMapSettingsFlag(String key, bool value) async { + sharedPreferences.setBool(key, value); + } + + bool getMapSettingsFlag(String key) { + return sharedPreferences.getBool(key) ?? false; + } +} diff --git a/lib/data/service/device_info_service_impl.dart b/lib/data/service/device_info_service_impl.dart new file mode 100644 index 0000000..643dff8 --- /dev/null +++ b/lib/data/service/device_info_service_impl.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:android_id/android_id.dart'; +import 'package:device_info_plus/device_info_plus.dart'; + +import '../../domain/service/device_info_service.dart'; + +class DeviceInfoServiceImpl extends DeviceInfoService { + final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin(); + final AndroidId _androidId = const AndroidId(); + + @override + Future getDeviceModel() async { + try { + if (Platform.isAndroid) { + final androidInfo = await _deviceInfo.androidInfo; + return '${androidInfo.manufacturer} ${androidInfo.model}'; + } else if (Platform.isIOS) { + final iosInfo = await _deviceInfo.iosInfo; + return iosInfo.utsname.machine; + } + } catch (e) { + print("ERROR: $e"); + } + return 'Unknown'; + } + + @override + Future getSystemId() async { + try { + if (Platform.isAndroid) { + return await _androidId.getId(); + } else if (Platform.isIOS) { + final iosInfo = await _deviceInfo.iosInfo; + return iosInfo.identifierForVendor; + } + } catch (e) { + print("ERROR: $e"); + return null; + } + return null; + } +} diff --git a/lib/data/service/news_api_service.dart b/lib/data/service/news_api_service.dart new file mode 100644 index 0000000..8e5d887 --- /dev/null +++ b/lib/data/service/news_api_service.dart @@ -0,0 +1,36 @@ +import 'dart:developer' as dev; +import '../network/api_service.dart'; + +class NewsApiService { + final ApiService _apiService; + + NewsApiService(this._apiService); + + Future> getNews() async { + try { + dev.log('NewsApiService: Запрос GET /news'); + + final response = await _apiService.getNews(); + + dev.log('NewsApiService: Успешно получено ${response['data']?.length ?? 0} новостей'); + return response; + } catch (e, stackTrace) { + dev.log('NewsApiService: Ошибка: $e', stackTrace: stackTrace); + throw Exception('Не удалось загрузить новости: $e'); + } + } + + Future> getNewsById(int newsId) async { + try { + dev.log('NewsApiService: Запрос GET /news/$newsId'); + + final response = await _apiService.getNewsById(newsId); + + dev.log('NewsApiService: Успешно получена новость с ID: $newsId'); + return response!; + } catch (e, stackTrace) { + dev.log('NewsApiService: Ошибка: $e', stackTrace: stackTrace); + throw Exception('Не удалось загрузить новость: $e'); + } + } +} \ No newline at end of file diff --git a/lib/data/service/security_service_impl.dart b/lib/data/service/security_service_impl.dart new file mode 100644 index 0000000..462f797 --- /dev/null +++ b/lib/data/service/security_service_impl.dart @@ -0,0 +1,80 @@ +import 'package:be_happy/domain/entities/user_auth_data.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../../domain/service/security_service.dart'; + +class SecurityServiceImpl extends SecurityService { + static const String kAccessToken = "access_token"; + static const String kRefreshToken = "refresh_token"; + static const String kCardNumberPrefix = "card_number_"; + + final FlutterSecureStorage _secureStorage; + + SecurityServiceImpl(this._secureStorage); + + @override + Future saveTokens(UserAuthData data) async { + await _secureStorage.write(key: kAccessToken, value: data.accessToken); + await _secureStorage.write(key: kRefreshToken, value: data.refreshToken); + } + + @override + Future removeTokens() async { + await _secureStorage.delete(key: kRefreshToken); + await _secureStorage.delete(key: kAccessToken); + } + + @override + Future getAccessToken() async { + return await _secureStorage.read(key: kAccessToken); + } + + @override + Future getRefreshToken() async { + return await _secureStorage.read(key: kRefreshToken); + } + + @override + Future saveCardFullNumber(int cardId, String fullCardNumber) async { + await _secureStorage.write( + key: '${kCardNumberPrefix}_$cardId', + value: fullCardNumber, + ); + } + + @override + Future getCardFullNumber(int cardId) async { + return await _secureStorage.read(key: '${kCardNumberPrefix}_$cardId'); + } + + @override + Future removeCardFullNumber(int cardId) async { + await _secureStorage.delete(key: '${kCardNumberPrefix}_$cardId'); + } + + @override + Future clearAllCardsNumbers() async { + // Получаем все ключи и удаляем те, что начинаются с префикса карты + final allKeys = await _secureStorage.readAll(); + for (final key in allKeys.keys) { + if (key.startsWith(kCardNumberPrefix)) { + await _secureStorage.delete(key: key); + } + } + } + + // @override + // Future getTokens() async { + // final accessToken = await _secureStorage.read(key: kAccessToken); + // final refreshToken = await _secureStorage.read(key: kRefreshToken); + // + // if(accessToken != null && refreshToken != null) { + // return UserAuthData( + // accessToken: accessToken, + // refreshToken: refreshToken, + // ); + // } + // + // return null; + // } +} diff --git a/lib/di/service_locator.dart b/lib/di/service_locator.dart new file mode 100644 index 0000000..7aecde4 --- /dev/null +++ b/lib/di/service_locator.dart @@ -0,0 +1,374 @@ +import 'package:be_happy/data/repositories/app_settings_repository_impl.dart'; +import 'package:be_happy/data/repositories/certificate_repository_impl.dart'; +import 'package:be_happy/data/repositories/notification_repository_impl.dart'; +import 'package:be_happy/data/repositories/payment_repository_impl.dart'; // ← новый +import 'package:be_happy/data/repositories/scooter_repository_impl.dart'; +import 'package:be_happy/data/repositories/zone_repository_impl.dart'; +import 'package:be_happy/data/service/app_setting_service.dart'; +import 'package:be_happy/data/service/device_info_service_impl.dart'; +import 'package:be_happy/data/service/security_service_impl.dart'; +import 'package:be_happy/domain/repositories/app_settings_repository.dart'; +import 'package:be_happy/domain/repositories/certificate_repository.dart'; +import 'package:be_happy/domain/repositories/notification_repository.dart'; +import 'package:be_happy/domain/repositories/payment_repository.dart'; // ← новый +import 'package:be_happy/domain/repositories/pin_repository.dart'; +import 'package:be_happy/domain/repositories/profile_repository.dart'; +import 'package:be_happy/domain/repositories/scooter_repository.dart'; +import 'package:be_happy/domain/repositories/zone_repository.dart'; +import 'package:be_happy/domain/service/security_service.dart'; +import 'package:be_happy/domain/usecase/add_payment_card_usecase.dart'; // ← новый +import 'package:be_happy/domain/usecase/get_certificates_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart'; +import 'package:be_happy/domain/usecase/is_pin_set_usecase.dart'; +import 'package:be_happy/domain/usecase/purchase_certificate_usecase.dart'; +import 'package:be_happy/domain/usecase/book_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/cancel_notification_usecase.dart'; +import 'package:be_happy/domain/usecase/create_pin_usecase.dart'; +import 'package:be_happy/domain/usecase/get_address_by_point_usecase.dart'; +import 'package:be_happy/domain/usecase/get_available_scooters_usecase.dart'; +import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart'; +import 'package:be_happy/domain/usecase/get_available_tariffs_usecase.dart'; +import 'package:be_happy/domain/usecase/get_available_zones_usecase.dart'; +import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart'; +import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; +import 'package:be_happy/domain/usecase/get_notifications_stream_usecase.dart'; +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; +import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; +import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; +import 'package:be_happy/domain/usecase/login_usecase.dart'; +import 'package:be_happy/domain/usecase/logout_usecase.dart'; +import 'package:be_happy/domain/usecase/pay_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/refresh_token_usecase.dart'; +import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart'; +import 'package:be_happy/domain/usecase/update_profile_usecase.dart'; +import 'package:be_happy/domain/usecase/upload_profile_photo_usecase.dart'; +import 'package:be_happy/domain/usecase/verify_code_usecase.dart'; +import 'package:be_happy/domain/usecase/upload_scooter_photos_usecase.dart'; +import 'package:be_happy/domain/usecase/update_scooter_order_data_usecase.dart'; +import 'package:be_happy/domain/usecase/pay_scooter_order_with_photos_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_order_by_id_usecase.dart'; +import 'package:be_happy/domain/usecase/finish_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/pause_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/resume_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/verify_pin_usecase.dart'; +import 'package:be_happy/presentation/viewmodel/active_ride_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/add_card_bloc.dart'; // ← новый +import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/payment_confirm_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/payment_method_sheet_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/pin_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/profile_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/scooter_detail_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/scooter_code_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/send_photo_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart'; +import 'package:be_happy/domain/usecase/cancel_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/start_ride_usecase.dart'; +import 'package:be_happy/domain/usecase/check_user_usecase.dart'; +import 'package:be_happy/presentation/viewmodel/reserved_ride_bloc.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../data/network/api_service.dart'; +import '../data/network/geocoding_remote_datasource.dart'; +import '../data/repositories/auth_repository_impl.dart'; +import '../data/repositories/news_repository_impl.dart'; +import '../data/repositories/pin_repository_impl.dart'; +import '../data/repositories/profile_repository_impl.dart'; +import '../data/service/news_api_service.dart'; +import '../domain/repositories/auth_repository.dart'; +import '../domain/repositories/news_repository.dart'; +import '../domain/service/device_info_service.dart'; +import '../domain/usecase/activate_subscription_usecase.dart'; +import '../domain/usecase/get_client_subscriptions_usecase.dart'; +import '../domain/usecase/get_news_by_id_usecase.dart'; +import '../domain/usecase/get_scooter_by_title_usecase.dart'; +import '../domain/usecase/get_scooter_order_history_usecase.dart'; +import '../domain/usecase/remove_payment_card_usecase.dart'; +import '../domain/usecase/set_main_payment_card_usecase.dart'; +import '../presentation/viewmodel/auth_bloc.dart'; +import '../presentation/viewmodel/edit_profile_bloc.dart'; +import '../presentation/viewmodel/map_bloc.dart'; +import '../presentation/viewmodel/news_bloc.dart'; +import '../presentation/viewmodel/order_history_bloc.dart'; +import '../presentation/viewmodel/scooter_detail_modal_bloc.dart'; +import '../presentation/viewmodel/subscription_list_bloc.dart'; +import '../presentation/viewmodel/verify_code_bloc.dart'; + +final getIt = GetIt.instance; + +Future setupDependencies() async { + final sharedPreferences = await SharedPreferences.getInstance(); + final dio = Dio(); + dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true)); + // HTTP + getIt.registerSingleton(http.Client()); + //SecureStorage + getIt.registerSingleton(FlutterSecureStorage()); + + //SharedPrefs + getIt.registerSingleton(sharedPreferences); + + // Service + getIt.registerSingleton(SecurityServiceImpl(getIt())); + + getIt.registerLazySingleton(() => ApiService(getIt(), dio)); + + getIt.registerSingleton( + GeocodingRemoteDataSource(), + ); + + getIt.registerSingleton(DeviceInfoServiceImpl()); + + getIt.registerSingleton(AppSettingsService(getIt())); + + getIt.registerLazySingleton( + () => NewsApiService(getIt()), + ); + + // Repository + getIt.registerSingleton( + AuthRepositoryImpl(getIt(), getIt(), getIt()), + ); + + getIt.registerSingleton(PinRepositoryImpl(getIt())); + + getIt.registerSingleton( + UserProfileRepositoryImpl(getIt()), + ); + + getIt.registerSingleton(ScooterRepositoryImpl(getIt())); + + getIt.registerSingleton(ZoneRepositoryImpl(getIt())); + + getIt.registerSingleton( + AppSettingsRepositoryImpl(getIt()), + ); + + getIt.registerSingleton( + PaymentRepositoryImpl(getIt(), getIt()), + ); + + getIt.registerSingleton( + CertificateRepositoryImpl(getIt(), getIt()), + ); + + getIt.registerLazySingleton( + () => NewsRepositoryImpl(getIt()), + ); + + getIt.registerSingleton( + NotificationRepositoryImpl(getIt()), + ); + + // Use Cases + getIt.registerSingleton(LoginUseCase(getIt())); + + getIt.registerSingleton(LogoutUseCase(getIt(), getIt())); + + getIt.registerSingleton(VerifyCodeUseCase(getIt())); + + getIt.registerSingleton(RefreshTokenUseCase(getIt())); + + getIt.registerSingleton(CreatePinUseCase(getIt())); + + getIt.registerSingleton(VerifyPinUseCase(getIt())); + + getIt.registerSingleton(IsPinSetUsecase(getIt())); + + getIt.registerSingleton(GetProfileUseCase(getIt())); + + getIt.registerSingleton(UpdateProfileUseCase(getIt())); + + getIt.registerSingleton( + UploadProfilePhotoUsecase(getIt()), + ); + + getIt.registerSingleton( + GetAvailableScootersUsecase(getIt()), + ); + + getIt.registerSingleton( + GetNewsByIdUsecase(getIt()), + ); + + getIt.registerSingleton(GetScooterUsecase(getIt())); + getIt.registerSingleton( + GetAvailableTariffsUsecase(getIt()), + ); + getIt.registerSingleton( + GetAddressByPointUsecase(getIt()), + ); + getIt.registerSingleton( + GetPedestrianRoutesUsecase(getIt()), + ); + getIt.registerSingleton( + GetScooterOrderRouteHistoryUsecase(getIt()), + ); + getIt.registerSingleton( + GetAvailableZonesUsecase(getIt()), + ); + getIt.registerSingleton( + GetMapSettingsUsecase(getIt()), + ); + getIt.registerSingleton( + SaveMapSettingsUsecase(getIt()), + ); + + getIt.registerSingleton( + AddPaymentCardUsecase(getIt()), + ); + getIt.registerSingleton( + GetPaymentCardsUsecase(getIt(), getIt()), + ); + getIt.registerSingleton( + SetMainPaymentCardUsecase(getIt()), + ); + getIt.registerSingleton( + RemovePaymentCardUsecase(getIt()), + ); + + getIt.registerSingleton( + GetCertificatesUsecase(getIt()), + ); + getIt.registerSingleton( + PurchaseCertificateUsecase(getIt()), + ); + + getIt.registerSingleton( + GetClientOrdersUsecase(getIt()), + ); + getIt.registerSingleton(BookScooterUsecase(getIt())); + + getIt.registerSingleton(StartRideUsecase(getIt())); + getIt.registerSingleton(CancelRideUsecase(getIt())); + + getIt.registerSingleton( + UploadScooterPhotosUsecase(getIt()), + ); + getIt.registerSingleton( + UpdateScooterOrderDataUsecase(getIt()), + ); + getIt.registerSingleton( + PayScooterOrderWithPhotosUsecase(getIt()), + ); + getIt.registerSingleton( + GetScooterOrderByIdUsecase(getIt()), + ); + + getIt.registerSingleton(FinishRideUsecase(getIt())); + getIt.registerSingleton(PauseRideUsecase(getIt())); + getIt.registerSingleton(ResumeRideUsecase(getIt())); + getIt.registerSingleton(PayRideUsecase(getIt())); + getIt.registerSingleton( + GetAvailableSubscriptionsUsecase(getIt()), + ); + getIt.registerSingleton( + GetSubscriptionByIdUsecase(getIt()), + ); + getIt.registerSingleton( + ActivateSubscriptionUsecase(getIt()), + ); + getIt.registerSingleton( + GetClientSubscriptionsUsecase(getIt()), + ); + getIt.registerSingleton( + GetScooterByTitleUsecase(getIt()), + ); + + // Blocs + getIt.registerLazySingleton(() => SplashBloc(getIt())); + + getIt.registerFactory(() => PhoneAuthBloc(getIt())); + + getIt.registerFactory(() => VerifyCodeBloc(getIt())); + + getIt.registerFactory(() => ProfileBloc(getIt(), getIt(), getIt())); + + getIt.registerFactory( + () => EditProfileBloc(getIt(), getIt()), + ); + + getIt.registerFactory( + () => MapBloc( + getIt(), + getIt(), + getIt(), + getIt(), + getIt(), + getIt(), + getIt(), + getIt(), + ), + ); + + getIt.registerFactory(() => ScooterDetailBloc(getIt())); + + getIt.registerFactory( + () => MapSettingsModalBloc(getIt(), getIt()), + ); + + getIt.registerFactory(() => AddCardBloc(getIt())); + + + getIt.registerFactory( + () => PaymentMethodSheetBloc(getIt()), + ); + + getIt.registerFactory(() => CurrentRidesBloc(getIt())); + + getIt.registerFactory( + () => ActiveRideBloc(getIt(), getIt(), getIt(), getIt(), getIt()), + ); + + getIt.registerFactory( + () => ReservedRideBloc(getIt(), getIt()), + ); + + getIt.registerFactory(() => NewsBloc(getIt())); + + + getIt.registerFactory(() => SendPhotoBloc(getIt(), getIt())); + + getIt.registerFactory( + () => SubscriptionListBloc( + getAvailableSubscriptionsUsecase: getIt(), + getClientSubscriptionsUsecase: getIt(), + ), + ); + + // UseCase + getIt.registerSingleton( + GetScooterOrderHistoryUsecase(getIt()), + ); + + getIt.registerSingleton( + CheckUserUseCase(getIt()), + ); + + getIt.registerSingleton( + GetNotificationsStreamUseCase(getIt()), + ); + + getIt.registerSingleton( + CancelNotificationUseCase(getIt()), + ); + + // Bloc + getIt.registerFactory( + () => OrderHistoryBloc(getIt()), + ); + + getIt.registerFactory( + () => ScooterCodeBloc(getScooterByTitleUsecase: getIt()), + ); +} diff --git a/lib/domain/entities/active_scooter_order.dart b/lib/domain/entities/active_scooter_order.dart new file mode 100644 index 0000000..b896bcc --- /dev/null +++ b/lib/domain/entities/active_scooter_order.dart @@ -0,0 +1,50 @@ +import 'scooter.dart'; + +class ActiveScooterOrder { + final int orderId; + final int scooterId; + final double latitude; + final double longitude; + final double mileage; + final double speed; + final double price; + final double time; + final bool zone; + + ActiveScooterOrder({ + required this.orderId, + required this.scooterId, + required this.latitude, + required this.longitude, + required this.mileage, + required this.speed, + required this.price, + required this.time, + required this.zone, + }); + + factory ActiveScooterOrder.fromJson(Map json) { + return ActiveScooterOrder( + orderId: json['orderId'] ?? 0, + scooterId: json['scooterId'] ?? 0, + latitude: (json['latitude'] ?? 0).toDouble(), + longitude: (json['longitude'] ?? 0).toDouble(), + mileage: (json['mileage'] ?? 0).toDouble(), + speed: (json['speed'] ?? 0).toDouble(), + price: (json['price'] ?? 0).toDouble(), + time: (json['time'] ?? 0.0).toDouble(), + zone: json['zone'], + ); + } + Map toJson() { + return { + 'orderId': orderId, + 'scooterId': scooterId, + 'latitude': latitude, + 'longitude': longitude, + 'mileage': mileage, + 'speed': speed, + 'price': price, + }; + } +} diff --git a/lib/domain/entities/certificate.dart b/lib/domain/entities/certificate.dart new file mode 100644 index 0000000..1d540df --- /dev/null +++ b/lib/domain/entities/certificate.dart @@ -0,0 +1,94 @@ +class Currency { + final int id; + final String title; + final String currency; + final String code; + final bool isBase; + final int denomination; + final double exchangeRate; + final String formatString; + final String floatSeparator; + final int decimals; + final bool isHideZero; + + Currency({ + required this.id, + required this.title, + required this.currency, + required this.code, + required this.isBase, + required this.denomination, + required this.exchangeRate, + required this.formatString, + required this.floatSeparator, + required this.decimals, + required this.isHideZero, + }); + + factory Currency.fromJson(Map json) { + return Currency( + id: json['id'] as int, + title: json['title'] as String, + currency: json['currency'] as String, + code: json['code'] as String, + isBase: json['isBase'] as bool, + denomination: json['denomination'] as int, + exchangeRate: (json['exchangeRate'] as num).toDouble(), + formatString: json['formatString'] as String, + floatSeparator: json['floatSeparator'] as String, + decimals: json['decimals'] as int, + isHideZero: json['isHideZero'] as bool, + ); + } +} + +class Certificate { + final int id; + final String title; + final String? description; + final double price; + final int currencyId; + final int value; + final double discount; + final DateTime createdAt; + final DateTime updatedAt; + final int sort; + final bool isActive; + final Currency? currency; + final String? pricePrint; + + Certificate({ + required this.id, + required this.title, + this.description, + required this.price, + required this.currencyId, + required this.value, + required this.discount, + required this.createdAt, + required this.updatedAt, + required this.sort, + required this.isActive, + this.currency, + this.pricePrint, + }); + + factory Certificate.fromJson(Map json) { + return Certificate( + id: json['id'] as int, + title: json['title'] as String, + description: json['description'] as String?, + price: (json['price'] as num).toDouble(), + currencyId: json['currencyId'] as int, + value: json['value'] as int, + discount: (json['discount'] as num).toDouble(), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + sort: json['sort'] as int, + isActive: json['isActive'] as bool, + currency: json['currency'] != null ? Currency.fromJson(json['currency']) : null, + pricePrint: json['pricePrint'] as String?, + ); + } + +} diff --git a/lib/domain/entities/client_notification.dart b/lib/domain/entities/client_notification.dart new file mode 100644 index 0000000..59d4c7e --- /dev/null +++ b/lib/domain/entities/client_notification.dart @@ -0,0 +1,58 @@ +enum NotificationType { + info, + attention, + warning, +} + +enum NotificationCategory { + auth, + zone, + payment, + companyInfo, + adminInfo, + scooter, +} + +class ClientNotification { + final int id; + final String content; + final int clientId; + final NotificationType type; + final NotificationCategory category; + final DateTime createdAt; + final DateTime? canceledAt; + final DateTime? readAt; + + ClientNotification({ + required this.id, + required this.content, + required this.clientId, + required this.type, + required this.category, + required this.createdAt, + this.canceledAt, + this.readAt, + }); + + ClientNotification copyWith({ + int? id, + String? content, + int? clientId, + NotificationType? type, + NotificationCategory? category, + DateTime? createdAt, + DateTime? canceledAt, + DateTime? readAt, + }) { + return ClientNotification( + id: id ?? this.id, + content: content ?? this.content, + clientId: clientId ?? this.clientId, + type: type ?? this.type, + category: category ?? this.category, + createdAt: createdAt ?? this.createdAt, + canceledAt: canceledAt ?? this.canceledAt, + readAt: readAt ?? this.readAt, + ); + } +} diff --git a/lib/domain/entities/map_settings.dart b/lib/domain/entities/map_settings.dart new file mode 100644 index 0000000..6afbb28 --- /dev/null +++ b/lib/domain/entities/map_settings.dart @@ -0,0 +1,15 @@ +class MapSettings { + final bool all_placemarks; + final bool all_zones; + final bool parking_zones; + final bool restricted_parking_zones; + final bool restricted_driving_zones; + + MapSettings({ + required this.all_placemarks, + required this.all_zones, + required this.parking_zones, + required this.restricted_parking_zones, + required this.restricted_driving_zones, + }); +} diff --git a/lib/domain/entities/news.dart b/lib/domain/entities/news.dart new file mode 100644 index 0000000..30c742b --- /dev/null +++ b/lib/domain/entities/news.dart @@ -0,0 +1,62 @@ +class NewsEntity { + final int id; + final String title; + final String previewText; + final String text; + final DateTime createdAt; + final DateTime publishedAt; + final bool isActive; + final String? imageUrl; + + final String? textJson; + final int? userId; + final int? pictureId; + final dynamic user; + final dynamic picture; + + NewsEntity({ + required this.id, + required this.title, + required this.previewText, + required this.text, + required this.createdAt, + required this.publishedAt, + required this.isActive, + this.imageUrl, + + this.textJson, + this.userId, + this.pictureId, + this.user, + this.picture, + }); + + factory NewsEntity.fromJson(Map json) { + DateTime _parseDate(String? dateStr) { + try { + return dateStr != null ? DateTime.parse(dateStr) : DateTime.now(); + } catch (_) { + return DateTime.now(); + } + } + + return NewsEntity( + id: json['id'] ?? 0, + title: json['title'] ?? '', + previewText: json['previewText'] ?? '', + text: json['text'] ?? '', + createdAt: _parseDate(json['createdAt']), + publishedAt: _parseDate(json['publishedAt']), + isActive: json['isActive'] ?? false, + imageUrl: json['picture'] != null + ? 'https://sharing-api.sparkit.by/${json['picture']['path']}' + : null, + + textJson: json['textJson'], + userId: json['userId'], + pictureId: json['pictureId'], + user: json['user'], + picture: json['picture'], + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/pagination.dart b/lib/domain/entities/pagination.dart new file mode 100644 index 0000000..80c10e2 --- /dev/null +++ b/lib/domain/entities/pagination.dart @@ -0,0 +1,19 @@ +class Pagination { + final int total; + final int currentPage; + final int lastPage; + + Pagination({ + required this.total, + required this.currentPage, + required this.lastPage, + }); + + factory Pagination.fromJson(Map json) { + return Pagination( + total: json['total'] ?? 0, + currentPage: json['currentPage'] ?? 0, + lastPage: json['lastPage'] ?? 0, + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/payment_card.dart b/lib/domain/entities/payment_card.dart new file mode 100644 index 0000000..15033e6 --- /dev/null +++ b/lib/domain/entities/payment_card.dart @@ -0,0 +1,43 @@ +class PaymentCard { + final int id; + final int clientId; + final int expirationMonth; + final int expirationYear; + final String cardHolder; + final String cardLastNumber; + final bool isMain; + final String type; + + PaymentCard({ + required this.id, + required this.clientId, + required this.expirationMonth, + required this.expirationYear, + required this.cardHolder, + required this.cardLastNumber, + required this.isMain, + required this.type, + }); + + PaymentCard copyWith({ + int? id, + int? clientId, + int? expirationMonth, + int? expirationYear, + String? cardHolder, + String? cardLastNumber, + bool? isMain, + String? type, + }) { + return PaymentCard( + id: id ?? this.id, + clientId: clientId ?? this.clientId, + expirationMonth: expirationMonth ?? this.expirationMonth, + expirationYear: expirationYear ?? this.expirationYear, + cardHolder: cardHolder ?? this.cardHolder, + cardLastNumber: cardLastNumber ?? this.cardLastNumber, + isMain: isMain ?? this.isMain, + type: type ?? this.type, + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/point.dart b/lib/domain/entities/point.dart new file mode 100644 index 0000000..c76a021 --- /dev/null +++ b/lib/domain/entities/point.dart @@ -0,0 +1,12 @@ + +class Point { + final double latitude; + final double longitude; + + Point(this.latitude, this.longitude); + + @override + String toString() { + return 'Point{latitude: $latitude, longitude: $longitude}'; + } +} diff --git a/lib/domain/entities/scooter.dart b/lib/domain/entities/scooter.dart new file mode 100644 index 0000000..3422e00 --- /dev/null +++ b/lib/domain/entities/scooter.dart @@ -0,0 +1,50 @@ + +class Scooter { + final int id; + final String title; + final String status; + final double latitude; + final double longitude; + final int batteryLevel; + final bool isOnline; + final int maxSpeed; + final String number; + double? distance; + double? timeToTravel; + + Scooter({ + required this.id, + required this.title, + required this.status, + required this.latitude, + required this.longitude, + required this.batteryLevel, + required this.isOnline, + required this.maxSpeed, + required this.number, + this.distance, + this.timeToTravel, + }); + + factory Scooter.fromJson(Map json) { + final scooterDetail = json['scooterDetail'] as Map? ?? {}; + final model = json['model'] as Map? ?? {}; + + return Scooter( + id: json['id'] ?? 0, + title: json['title'] ?? 'Unknown', + status: json['status'] ?? 'Unavailable', + latitude: (scooterDetail['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (scooterDetail['longitude'] as num?)?.toDouble() ?? 0.0, + batteryLevel: (scooterDetail['batteryLevel'] as num?)?.toInt() ?? 0, + isOnline: scooterDetail['isOnline'] ?? false, + maxSpeed: (json['maxSpeed'] as num?)?.toInt() ?? 25, + number: json['title'] ?? 'Unknown', + ); + } + + @override + String toString() { + return 'Scooter{id: $id, title: $title}'; + } +} diff --git a/lib/domain/entities/scooter_order.dart b/lib/domain/entities/scooter_order.dart new file mode 100644 index 0000000..85b62c0 --- /dev/null +++ b/lib/domain/entities/scooter_order.dart @@ -0,0 +1,143 @@ +import 'scooter.dart'; + +class ScooterOrder { + final int id; + final int scooterId; + final Scooter? scooter; + final int? planId; + final ScooterPlan? plan; + final int clientId; + final int? subscriptionId; + final int? cardId; + final bool isBalance; + final int decimals; + final bool isInsurance; + final double? insurancePrice; + final String? insurancePricePrint; + final double? holdPrice; + final String? holdPricePrint; + final double? totalPrice; + final String? totalPricePrint; + final int? currencyId; + final String status; + final DateTime createdAt; + final DateTime? updatedAt; + final DateTime? startAt; + final DateTime? finishAt; + final DateTime? expiresAt; + final DateTime? cancelAt; + final String? cancelDescription; + final double mileage; //эту + final double avgSpeed; //эту и снизу еще 4 есть + + ScooterOrder({ + required this.id, + required this.scooterId, + this.scooter, + this.planId, + this.plan, + required this.clientId, + this.subscriptionId, + this.cardId, + required this.isBalance, + required this.decimals, + required this.isInsurance, + this.insurancePrice, + this.insurancePricePrint, + this.holdPrice, + this.holdPricePrint, + this.totalPrice, + this.totalPricePrint, + this.currencyId, + required this.status, + required this.createdAt, + this.updatedAt, + this.startAt, + this.finishAt, + this.expiresAt, + this.cancelAt, + this.cancelDescription, + required this.mileage, //эту + required this.avgSpeed, //эту и снизу еще 2 есть + }); + + factory ScooterOrder.fromJson(Map json) { + return ScooterOrder( + id: json['id'] ?? 0, + scooterId: json['scooterId'] ?? 0, + scooter: json['scooter'] != null ? Scooter.fromJson(json['scooter']) : null, + planId: json['planId'], + plan: json['plan'] != null ? ScooterPlan.fromJson(json['plan']) : null, + clientId: json['clientId'] ?? 0, + subscriptionId: json['subscriptionId'], + cardId: json['cardId'], + isBalance: json['isBalance'] ?? false, + decimals: json['decimals'] ?? 0, + isInsurance: json['isInsurance'] ?? false, + insurancePrice: (json['insurancePrice'] as num?)?.toDouble(), + insurancePricePrint: json['insurancePricePrint'], + holdPrice: (json['holdPrice'] as num?)?.toDouble(), + holdPricePrint: json['holdPricePrint'], + totalPrice: (json['totalPrice'] as num?)?.toDouble(), + totalPricePrint: json['totalPricePrint'], + currencyId: json['currencyId'], + status: json['status'] ?? '', + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : null, + startAt: json['startAt'] != null + ? DateTime.parse(json['startAt']) + : null, + finishAt: json['finishAt'] != null + ? DateTime.parse(json['finishAt']) + : null, + expiresAt: json['expiresAt'] != null + ? DateTime.parse(json['expiresAt']) + : null, + cancelAt: json['cancelAt'] != null + ? DateTime.parse(json['cancelAt']) + : null, + cancelDescription: json['cancelDescription'], + mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0, //эту + avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0, //эту + ); + } + + Map toJson() { + return { + 'id': id, + 'scooterId': scooterId, + 'planId': planId, + 'subscriptionId': subscriptionId, + 'cardId': cardId, + 'isBalance': isBalance, + 'isInsurance': isInsurance, + }; + } +} + +class ScooterPlan { + final int id; + final String title; + final double price; + final String? description; + + ScooterPlan({ + required this.id, + required this.title, + required this.price, + this.description, + }); + + factory ScooterPlan.fromJson(Map json) { + return ScooterPlan( + id: json['id'] ?? 0, + title: json['title'] ?? '', + price: (json['price'] as num?)?.toDouble() ?? 0.0, + description: json['description'], + ); + } +} diff --git a/lib/domain/entities/subscription.dart b/lib/domain/entities/subscription.dart new file mode 100644 index 0000000..4f6ddba --- /dev/null +++ b/lib/domain/entities/subscription.dart @@ -0,0 +1,59 @@ + +import 'package:be_happy/domain/entities/subscription_period.dart'; + +class Subscription { + final int id; + final String title; + final String shortDescription; + final String fullDescription; + final int planId; + final bool isActive; + final String currency; + final DateTime? activeFrom; + final DateTime? activeTo; + final DateTime createdAt; + final DateTime updatedAt; + final List options; + + + Subscription({ + required this.id, + required this.title, + required this.shortDescription, + required this.fullDescription, + required this.planId, + required this.isActive, + required this.currency, + this.activeFrom, + this.activeTo, + required this.createdAt, + required this.updatedAt, + required this.options, + }); + + factory Subscription.fromJson(Map json) { + final currencyData = json['currency'] as Map? ?? {}; + final optionsData = json['options'] as List? ?? []; + + + return Subscription( + id: json['id'] ?? 0, + title: json['title'] ?? '', + shortDescription: json['shortDescription'] ?? '', + fullDescription: json['fullDescription'] ?? '', + planId: json['planId'] ?? 0, + isActive: json['isActive'] ?? false, + currency: currencyData['currency'] ?? 'BYN', + activeFrom: json['activeFrom'] != null ? DateTime.parse(json['activeFrom']) : null, + activeTo: json['activeTo'] != null ? DateTime.parse(json['activeTo']) : null, + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : DateTime.now(), + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : DateTime.now(), + options: optionsData.map((e) => SubscriptionPeriod.fromJson(e as Map)).toList(), + ); + } + + @override + String toString() { + return 'Subscription{id: $id, title: $title, isActive: $isActive}'; + } +} \ No newline at end of file diff --git a/lib/domain/entities/subscription_period.dart b/lib/domain/entities/subscription_period.dart new file mode 100644 index 0000000..aa03fc6 --- /dev/null +++ b/lib/domain/entities/subscription_period.dart @@ -0,0 +1,33 @@ +class SubscriptionPeriod { + final int id; + final int subscriptionId; + final int days; + final String title; + final double price; + final String pricePrint; + + SubscriptionPeriod({ + required this.id, + required this.subscriptionId, + required this.days, + required this.title, + required this.price, + required this.pricePrint, + }); + + factory SubscriptionPeriod.fromJson(Map json) { + return SubscriptionPeriod( + id: json['id'] ?? 0, + subscriptionId: json['subscriptionId'] ?? 0, + days: json['days'] ?? 0, + title: json['title'] ?? '', + price: (json['price'] ?? 0).toDouble(), + pricePrint: json['pricePrint'] ?? '', + ); + } + + @override + String toString() { + return 'SubscriptionPeriod{id: $id, title: $title, days: $days, price: $price}'; + } +} \ No newline at end of file diff --git a/lib/domain/entities/tariff.dart b/lib/domain/entities/tariff.dart new file mode 100644 index 0000000..a3ce44a --- /dev/null +++ b/lib/domain/entities/tariff.dart @@ -0,0 +1,51 @@ +class Tariff { + final int id; + final String title; + final String description; + final bool isActive; + final String currency; + final double holdPrice; // Старт / бронь + final double drivePrice; // Цена минуты + final double pausePrice; // Пауза + final double startPrice; // Старт цена + final double cashback; // Процент кэшбэка + final double insurance; // Страховка + + Tariff({ + required this.id, + required this.title, + required this.description, + required this.isActive, + required this.currency, + required this.holdPrice, + required this.drivePrice, + required this.pausePrice, + required this.startPrice, + required this.cashback, + required this.insurance, + }); + + factory Tariff.fromJson(Map json) { + final planPrice = json['planPrice'] as Map? ?? {}; + final currency = json['currency'] as Map? ?? {}; + + return Tariff( + id: json['id'] ?? 0, + title: json['title'] ?? 'Unknown', + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + currency: currency['currency'] ?? 'BYN', + holdPrice: (planPrice['hold'] as num?)?.toDouble() ?? 0.0, + drivePrice: (planPrice['drive'] as num?)?.toDouble() ?? 0.0, + pausePrice: (planPrice['pause'] as num?)?.toDouble() ?? 0.0, + startPrice: (planPrice['start'] as num?)?.toDouble() ?? 0.0, + cashback: (planPrice['cashback'] as num?)?.toDouble() ?? 0.0, + insurance: (planPrice['insurance'] as num?)?.toDouble() ?? 0.0, + ); + } + + @override + String toString() { + return 'Tariff{id: $id, title: $title, isActive: $isActive}'; + } +} diff --git a/lib/domain/entities/top_up_tariff.dart b/lib/domain/entities/top_up_tariff.dart new file mode 100644 index 0000000..8218fc2 --- /dev/null +++ b/lib/domain/entities/top_up_tariff.dart @@ -0,0 +1,17 @@ +class TopUpTariff { + final int id; + final int points; + final double price; + final double? discountPercent; + + TopUpTariff({ + required this.id, + required this.points, + required this.price, + this.discountPercent, + }); + + double get finalPrice => discountPercent != null + ? price * (1 - discountPercent! / 100) + : price; +} diff --git a/lib/domain/entities/user_auth_data.dart b/lib/domain/entities/user_auth_data.dart new file mode 100644 index 0000000..2df8ca3 --- /dev/null +++ b/lib/domain/entities/user_auth_data.dart @@ -0,0 +1,9 @@ +class UserAuthData { + final String accessToken; + final String refreshToken; + + UserAuthData({ + required this.accessToken, + required this.refreshToken, + }); +} \ No newline at end of file diff --git a/lib/domain/entities/user_check_flags.dart b/lib/domain/entities/user_check_flags.dart new file mode 100644 index 0000000..027e3f0 --- /dev/null +++ b/lib/domain/entities/user_check_flags.dart @@ -0,0 +1,23 @@ +class UserCheckFlags { + final bool hasFine; + final bool hasUnpaidOrder; + final bool hasCard; + + const UserCheckFlags({ + required this.hasFine, + required this.hasUnpaidOrder, + required this.hasCard, + }); + + UserCheckFlags copyWith({ + bool? hasFine, + bool? hasUnpaidOrder, + bool? hasCard, + }) { + return UserCheckFlags( + hasFine: hasFine ?? this.hasFine, + hasUnpaidOrder: hasUnpaidOrder ?? this.hasUnpaidOrder, + hasCard: hasCard ?? this.hasCard, + ); + } +} diff --git a/lib/domain/entities/user_profile.dart b/lib/domain/entities/user_profile.dart new file mode 100644 index 0000000..0e72db9 --- /dev/null +++ b/lib/domain/entities/user_profile.dart @@ -0,0 +1,38 @@ +class UserProfile { + String name; + String birthDate; + String phone; + String email; + int? balance; + int? avatarId; + String? avatarUrl; + + UserProfile({ + required this.name, + required this.birthDate, + required this.phone, + required this.email, + this.balance, + this.avatarId, + this.avatarUrl, + }); + + UserProfile copyWith({ + String? name, + String? birthDate, + String? email, + int? balance, + int? avatarId, + String? avatarUrl, + }) { + return UserProfile( + name: name ?? this.name, + birthDate: birthDate ?? this.birthDate, + phone: phone, // телефон не меняется + email: email ?? this.email, + balance: balance ?? this.balance, + avatarId: avatarId ?? this.avatarId, + avatarUrl: avatarUrl ?? this.avatarUrl, + ); + } +} diff --git a/lib/domain/entities/zone.dart b/lib/domain/entities/zone.dart new file mode 100644 index 0000000..ae57c59 --- /dev/null +++ b/lib/domain/entities/zone.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:be_happy/domain/entities/point.dart'; + +class Zone { + final int id; + final String title; + final String description; + final String type; + final bool isActive; + final String shapeType; + final List points; + final String speedLimit; + + Zone({ + required this.id, + required this.title, + required this.description, + required this.type, + required this.isActive, + required this.shapeType, + required this.points, + required this.speedLimit, + }); + + factory Zone.fromJson(Map json) { + final zoneCoordinates = json['coordinates'] as Map? ?? {}; + final String coordsString = zoneCoordinates['coordinates'] ?? '[]'; + final String shapeType = zoneCoordinates['type'] ?? 'Polygon'; + + List points = []; + + try { + final dynamic decoded = jsonDecode(coordsString); + + if (decoded is List && decoded.isNotEmpty) { + List targetList = []; + + if (shapeType == 'Polygon') { + // У полигона структура [[[lat, lon], ...]] -> уходим на 1 уровень вглубь + targetList = decoded[0] as List; + } else { + // У LineString структура [[lat, lon], ...] -> используем как есть + targetList = decoded; + } + + points = targetList.map((item) { + final List coords = item as List; + return Point( + (coords[1] as num).toDouble(), + (coords[0] as num).toDouble(), + ); + }).toList(); + } + } catch (e) { + print("PARSE ERROR for Zone ID ${json['id']}: $e"); + } + + return Zone( + id: json['id'] ?? 0, + title: json['title'] ?? 'Unknown', + description: json['description'] ?? '', + type: json['type'] ?? '', + isActive: json['isActive'] ?? false, + speedLimit: json['speedLimit'] ?? '', + shapeType: shapeType, + points: points, + ); + } + @override + String toString() { + return 'Zone{id: $id, title: $title, type: $type, points: $points}'; + } +} diff --git a/lib/domain/repositories/app_settings_repository.dart b/lib/domain/repositories/app_settings_repository.dart new file mode 100644 index 0000000..029c231 --- /dev/null +++ b/lib/domain/repositories/app_settings_repository.dart @@ -0,0 +1,7 @@ +import 'package:be_happy/domain/entities/map_settings.dart'; + +abstract class AppSettingsRepository { + + Future getMapSettings(); + Future saveMapSettings(MapSettings settings); +} \ No newline at end of file diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..18cc89f --- /dev/null +++ b/lib/domain/repositories/auth_repository.dart @@ -0,0 +1,14 @@ +import 'dart:ffi'; + +import '../../core/result.dart'; +import '../entities/user_auth_data.dart'; + +abstract class AuthRepository { + Future login(String phone); + + Future> verifyCode(String code, String token); + + Future refreshToken(); + + Future logout(); +} diff --git a/lib/domain/repositories/certificate_repository.dart b/lib/domain/repositories/certificate_repository.dart new file mode 100644 index 0000000..1a1a68e --- /dev/null +++ b/lib/domain/repositories/certificate_repository.dart @@ -0,0 +1,11 @@ +import '../../core/result.dart'; +import '../entities/certificate.dart'; + +abstract class CertificateRepository { + Future>> getCertificates(); + + Future>> purchaseCertificate({ + required int certificateId, + required int cardId, + }); +} diff --git a/lib/domain/repositories/launch_repository.dart b/lib/domain/repositories/launch_repository.dart new file mode 100644 index 0000000..863cd46 --- /dev/null +++ b/lib/domain/repositories/launch_repository.dart @@ -0,0 +1,3 @@ +abstract class LaunchRepository { + Future isFirstLaunch(); +} diff --git a/lib/domain/repositories/news_repository.dart b/lib/domain/repositories/news_repository.dart new file mode 100644 index 0000000..a9fadf1 --- /dev/null +++ b/lib/domain/repositories/news_repository.dart @@ -0,0 +1,6 @@ +import '../entities/news.dart'; + +abstract class NewsRepository { + Future> getNews(); + Future getNewsById(int id); +} \ No newline at end of file diff --git a/lib/domain/repositories/notification_repository.dart b/lib/domain/repositories/notification_repository.dart new file mode 100644 index 0000000..9f3b044 --- /dev/null +++ b/lib/domain/repositories/notification_repository.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/domain/entities/client_notification.dart'; + +abstract class NotificationRepository { + /// Устанавливает постоянное SSE-соединение и возвращает поток уведомлений + Stream getNotificationsStream(); + + /// Отменяет уведомление по ID + Future cancelNotification(int id); + + /// Закрывает SSE-соединение + void closeStream(); +} diff --git a/lib/domain/repositories/payment_repository.dart b/lib/domain/repositories/payment_repository.dart new file mode 100644 index 0000000..13903aa --- /dev/null +++ b/lib/domain/repositories/payment_repository.dart @@ -0,0 +1,20 @@ +import '../../core/result.dart'; +import '../entities/payment_card.dart'; + +abstract class PaymentRepository { + Future>> getPaymentCards(); + + Future> addPaymentCard({ + required String cardNumber, + required String cardHolder, + required String expiryMonth, + required String expiryYear, + required String cvv, + }); + + Future> setMainPaymentCard(int cardId); + + Future> removePaymentCard(int cardId); + + Future> activateSubscription(int optionId); +} \ No newline at end of file diff --git a/lib/domain/repositories/pin_repository.dart b/lib/domain/repositories/pin_repository.dart new file mode 100644 index 0000000..71ac5ce --- /dev/null +++ b/lib/domain/repositories/pin_repository.dart @@ -0,0 +1,9 @@ +import '../entities/user_auth_data.dart'; + +abstract class PinRepository { + Future getSavedPin(); + + Future savePin(String? pin); + + Future removePin(); +} \ No newline at end of file diff --git a/lib/domain/repositories/profile_repository.dart b/lib/domain/repositories/profile_repository.dart new file mode 100644 index 0000000..410c311 --- /dev/null +++ b/lib/domain/repositories/profile_repository.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import '../entities/user_check_flags.dart'; +import '../entities/user_profile.dart'; + +abstract class UserProfileRepository { + Future getProfile(); + Future updateProfile(UserProfile profile); + Future uploadProfilePhoto(File imageFile); + Future checkUser(); +} + diff --git a/lib/domain/repositories/scooter_repository.dart b/lib/domain/repositories/scooter_repository.dart new file mode 100644 index 0000000..239a008 --- /dev/null +++ b/lib/domain/repositories/scooter_repository.dart @@ -0,0 +1,55 @@ + +import 'dart:io'; + +import 'package:be_happy/domain/entities/active_scooter_order.dart'; + +import '../../core/result.dart'; +import '../entities/point.dart'; +import '../entities/scooter.dart'; +import '../entities/subscription.dart'; +import '../entities/tariff.dart'; +import '../entities/scooter_order.dart'; + +abstract class ScooterRepository { + Future> getScooters(List area, int page, int pageSize); + Future> getScooter(int id); + Future>> getAvailableTariffs(int scooterId); + Future>> getAvailableSubscriptions(); + Future> getSubscriptionById(int id); + Future>> getClientSubscriptions(); + Future> bookScooter({ + required int scooterId, + required int planId, + int? subscriptionId, + int? cardId, + required bool isBalance, + required bool isInsurance, + }); + Future> startRide(int orderId); + Future> cancelRide(int orderId); + Future> pauseRide(int orderId); + Future> resumeRide(int orderId); + Future> finishRide(int orderId, List files); + Future> payRide(int orderId); + Future>> getClientOrders(); + Future>> uploadScooterPhotos(List images); + Future> updateScooterOrderData({ + required int orderId, + }); + Future> payScooterOrderWithPhotos({ + required int orderId, + required int? cardId, + required bool isBalance, + }); + Future> getScooterOrderById(int id); + + Future>> getScooterOrderHistory({ + int page = 1, + int pageSize = 20, + }); + + Future> getScooterByTitle(String title); + + Future>> getScooterOrderRouteHistory(int id); + +} diff --git a/lib/domain/repositories/zone_repository.dart b/lib/domain/repositories/zone_repository.dart new file mode 100644 index 0000000..3acab73 --- /dev/null +++ b/lib/domain/repositories/zone_repository.dart @@ -0,0 +1,6 @@ +import '../entities/zone.dart'; + +abstract class ZoneRepository { + Future> getZones(List area, int page, int pageSize); +} + diff --git a/lib/domain/service/device_info_service.dart b/lib/domain/service/device_info_service.dart new file mode 100644 index 0000000..01eff6a --- /dev/null +++ b/lib/domain/service/device_info_service.dart @@ -0,0 +1,4 @@ +abstract class DeviceInfoService { + Future getSystemId(); //SSAID - Android, IDFV - iOS (Vendor ID - один идентификатор для всех приложений одного разработчика, уникальный для одного устройства) + Future getDeviceModel(); +} \ No newline at end of file diff --git a/lib/domain/service/security_service.dart b/lib/domain/service/security_service.dart new file mode 100644 index 0000000..66bd06b --- /dev/null +++ b/lib/domain/service/security_service.dart @@ -0,0 +1,16 @@ +import 'package:be_happy/domain/entities/user_auth_data.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +abstract class SecurityService { + Future saveTokens(UserAuthData data); + Future removeTokens(); + Future getRefreshToken(); + Future getAccessToken(); + + // Методы для работы с полными номерами карт + Future saveCardFullNumber(int cardId, String fullCardNumber); + Future getCardFullNumber(int cardId); + Future removeCardFullNumber(int cardId); + Future clearAllCardsNumbers(); + // Future getTokens(); +} \ No newline at end of file diff --git a/lib/domain/usecase/activate_subscription_usecase.dart b/lib/domain/usecase/activate_subscription_usecase.dart new file mode 100644 index 0000000..1d5d893 --- /dev/null +++ b/lib/domain/usecase/activate_subscription_usecase.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/repositories/payment_repository.dart'; + +class ActivateSubscriptionUsecase { + final PaymentRepository repository; + + ActivateSubscriptionUsecase(this.repository); + + Future> call(int optionId) { + return repository.activateSubscription(optionId); + } +} diff --git a/lib/domain/usecase/add_payment_card_usecase.dart b/lib/domain/usecase/add_payment_card_usecase.dart new file mode 100644 index 0000000..69af042 --- /dev/null +++ b/lib/domain/usecase/add_payment_card_usecase.dart @@ -0,0 +1,24 @@ +import '../repositories/payment_repository.dart'; +import '../../core/result.dart'; + +class AddPaymentCardUsecase { + final PaymentRepository repository; + + AddPaymentCardUsecase(this.repository); + + Future> call({ + required String cardNumber, + required String cardHolder, + required String expiryMonth, + required String expiryYear, + required String cvv, + }) { + return repository.addPaymentCard( + cardNumber: cardNumber, + cardHolder: cardHolder, + expiryMonth: expiryMonth, + expiryYear: expiryYear, + cvv: cvv, + ); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/book_scooter_usecase.dart b/lib/domain/usecase/book_scooter_usecase.dart new file mode 100644 index 0000000..cd4d5bc --- /dev/null +++ b/lib/domain/usecase/book_scooter_usecase.dart @@ -0,0 +1,27 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class BookScooterUsecase { + final ScooterRepository repository; + + BookScooterUsecase(this.repository); + + Future> call({ + required int scooterId, + required int planId, + int? subscriptionId, + int? cardId, + required bool isBalance, + required bool isInsurance, + }) { + return repository.bookScooter( + scooterId: scooterId, + planId: planId, + subscriptionId: subscriptionId, + cardId: cardId, + isBalance: isBalance, + isInsurance: isInsurance, + ); + } +} diff --git a/lib/domain/usecase/cancel_notification_usecase.dart b/lib/domain/usecase/cancel_notification_usecase.dart new file mode 100644 index 0000000..87cc8bc --- /dev/null +++ b/lib/domain/usecase/cancel_notification_usecase.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/domain/entities/client_notification.dart'; +import 'package:be_happy/domain/repositories/notification_repository.dart'; + +class CancelNotificationUseCase { + final NotificationRepository repository; + + CancelNotificationUseCase(this.repository); + + Future call(int id) async { + return await repository.cancelNotification(id); + } +} diff --git a/lib/domain/usecase/cancel_ride_usecase.dart b/lib/domain/usecase/cancel_ride_usecase.dart new file mode 100644 index 0000000..379ffd1 --- /dev/null +++ b/lib/domain/usecase/cancel_ride_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class CancelRideUsecase { + final ScooterRepository repository; + + CancelRideUsecase(this.repository); + + Future> call(int orderId) { + return repository.cancelRide(orderId); + } +} diff --git a/lib/domain/usecase/change_pin_usecase.dart b/lib/domain/usecase/change_pin_usecase.dart new file mode 100644 index 0000000..6619e4f --- /dev/null +++ b/lib/domain/usecase/change_pin_usecase.dart @@ -0,0 +1,27 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; + +import '../repositories/auth_repository.dart'; + +class ChangePinUseCase { + final PinRepository repository; + + ChangePinUseCase(this.repository); + + Future call({ + required String oldPin, + required String newPin, + }) async { + final savedPin = await repository.getSavedPin(); + + if (savedPin != oldPin) { + throw Exception('Wrong old PIN'); + } + + if (newPin.length != 6) { + throw Exception('Invalid new PIN'); + } + + await repository.savePin(newPin); + } +} + diff --git a/lib/domain/usecase/check_user_usecase.dart b/lib/domain/usecase/check_user_usecase.dart new file mode 100644 index 0000000..0321ff8 --- /dev/null +++ b/lib/domain/usecase/check_user_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/user_check_flags.dart'; +import '../repositories/profile_repository.dart'; + +class CheckUserUseCase { + final UserProfileRepository repository; + + CheckUserUseCase(this.repository); + + Future call() { + return repository.checkUser(); + } +} diff --git a/lib/domain/usecase/create_pin_usecase.dart b/lib/domain/usecase/create_pin_usecase.dart new file mode 100644 index 0000000..119468f --- /dev/null +++ b/lib/domain/usecase/create_pin_usecase.dart @@ -0,0 +1,27 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; + +import '../repositories/auth_repository.dart'; + +class CreatePinUseCase { + final PinRepository repository; + + CreatePinUseCase(this.repository); + + Future call(String pin) async { + _validate(pin); + + final hashed = _hash(pin); + await repository.savePin(hashed); + } + + void _validate(String pin) { + if (pin.length != 6) { + throw Exception('PIN must be 6 digits'); + } + } + + String _hash(String pin) { + // временно просто pin + return pin; + } +} diff --git a/lib/domain/usecase/finish_ride_usecase.dart b/lib/domain/usecase/finish_ride_usecase.dart new file mode 100644 index 0000000..edf892a --- /dev/null +++ b/lib/domain/usecase/finish_ride_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class FinishRideUsecase { + final ScooterRepository repository; + + FinishRideUsecase(this.repository); + + Future> call(int orderId, List files) { + return repository.finishRide(orderId, files); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_address_by_point_usecase.dart b/lib/domain/usecase/get_address_by_point_usecase.dart new file mode 100644 index 0000000..25db8f9 --- /dev/null +++ b/lib/domain/usecase/get_address_by_point_usecase.dart @@ -0,0 +1,11 @@ +import 'package:be_happy/data/network/geocoding_remote_datasource.dart'; + +class GetAddressByPointUsecase { + final GeocodingRemoteDataSource dataSource; + + GetAddressByPointUsecase(this.dataSource); + + Future call(double latitude, double longitude) { + return dataSource.getAddressFromPoint(latitude: latitude, longitude: longitude); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_available_scooters_usecase.dart b/lib/domain/usecase/get_available_scooters_usecase.dart new file mode 100644 index 0000000..9bc26ac --- /dev/null +++ b/lib/domain/usecase/get_available_scooters_usecase.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/domain/entities/scooter.dart'; + +import '../repositories/scooter_repository.dart'; + + +class GetAvailableScootersUsecase { + final ScooterRepository repository; + + GetAvailableScootersUsecase(this.repository); + + Future> call(List area, int page, int pageSize) { + return repository.getScooters(area, page, pageSize); + } +} + diff --git a/lib/domain/usecase/get_available_subscriptions_usecase.dart b/lib/domain/usecase/get_available_subscriptions_usecase.dart new file mode 100644 index 0000000..5bbc3e8 --- /dev/null +++ b/lib/domain/usecase/get_available_subscriptions_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/subscription.dart'; + +import '../repositories/scooter_repository.dart'; + +class GetAvailableSubscriptionsUsecase { + final ScooterRepository repository; + + GetAvailableSubscriptionsUsecase(this.repository); + + Future>> call() { + return repository.getAvailableSubscriptions(); + } +} diff --git a/lib/domain/usecase/get_available_tariffs_usecase.dart b/lib/domain/usecase/get_available_tariffs_usecase.dart new file mode 100644 index 0000000..3ed7083 --- /dev/null +++ b/lib/domain/usecase/get_available_tariffs_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/tariff.dart'; + +import '../repositories/scooter_repository.dart'; + +class GetAvailableTariffsUsecase { + final ScooterRepository repository; + + GetAvailableTariffsUsecase(this.repository); + + Future>> call(int scooterId) { + return repository.getAvailableTariffs(scooterId); + } +} diff --git a/lib/domain/usecase/get_available_zones_usecase.dart b/lib/domain/usecase/get_available_zones_usecase.dart new file mode 100644 index 0000000..4cc642d --- /dev/null +++ b/lib/domain/usecase/get_available_zones_usecase.dart @@ -0,0 +1,15 @@ + +import '../entities/zone.dart'; +import '../repositories/zone_repository.dart'; + + +class GetAvailableZonesUsecase { + final ZoneRepository repository; + + GetAvailableZonesUsecase(this.repository); + + Future?> call(List area, int page, int pageSize) { + return repository.getZones(area, page, pageSize); + } +} + diff --git a/lib/domain/usecase/get_certificates_usecase.dart b/lib/domain/usecase/get_certificates_usecase.dart new file mode 100644 index 0000000..bf3a481 --- /dev/null +++ b/lib/domain/usecase/get_certificates_usecase.dart @@ -0,0 +1,13 @@ +import '../entities/certificate.dart'; +import '../repositories/certificate_repository.dart'; +import '../../core/result.dart'; + +class GetCertificatesUsecase { + final CertificateRepository repository; + + GetCertificatesUsecase(this.repository); + + Future>> call() async { + return await repository.getCertificates(); + } +} diff --git a/lib/domain/usecase/get_client_orders_usecase.dart b/lib/domain/usecase/get_client_orders_usecase.dart new file mode 100644 index 0000000..1559afd --- /dev/null +++ b/lib/domain/usecase/get_client_orders_usecase.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; + +import '../repositories/scooter_repository.dart'; + + +class GetClientOrdersUsecase { + final ScooterRepository repository; + + GetClientOrdersUsecase(this.repository); + + Future>> call() { + return repository.getClientOrders(); + } +} diff --git a/lib/domain/usecase/get_client_subscriptions_usecase.dart b/lib/domain/usecase/get_client_subscriptions_usecase.dart new file mode 100644 index 0000000..0155332 --- /dev/null +++ b/lib/domain/usecase/get_client_subscriptions_usecase.dart @@ -0,0 +1,20 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; + +import '../repositories/scooter_repository.dart'; + + +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/subscription.dart'; + +import '../repositories/scooter_repository.dart'; + +class GetClientSubscriptionsUsecase { + final ScooterRepository repository; + + GetClientSubscriptionsUsecase(this.repository); + + Future>> call() { + return repository.getClientSubscriptions(); + } +} diff --git a/lib/domain/usecase/get_map_settings_usecase.dart b/lib/domain/usecase/get_map_settings_usecase.dart new file mode 100644 index 0000000..5d89e41 --- /dev/null +++ b/lib/domain/usecase/get_map_settings_usecase.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/domain/entities/map_settings.dart'; +import 'package:be_happy/domain/repositories/app_settings_repository.dart'; + +class GetMapSettingsUsecase { + AppSettingsRepository repository; + + GetMapSettingsUsecase(this.repository); + + Future call() { + return repository.getMapSettings(); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_news_by_id_usecase.dart b/lib/domain/usecase/get_news_by_id_usecase.dart new file mode 100644 index 0000000..c2855b1 --- /dev/null +++ b/lib/domain/usecase/get_news_by_id_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/news.dart'; +import '../repositories/news_repository.dart'; + +class GetNewsByIdUsecase { + final NewsRepository repository; + + GetNewsByIdUsecase(this.repository); + + Future call(int id) { + return repository.getNewsById(id); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_notifications_stream_usecase.dart b/lib/domain/usecase/get_notifications_stream_usecase.dart new file mode 100644 index 0000000..20dccc8 --- /dev/null +++ b/lib/domain/usecase/get_notifications_stream_usecase.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/domain/entities/client_notification.dart'; +import 'package:be_happy/domain/repositories/notification_repository.dart'; + +class GetNotificationsStreamUseCase { + final NotificationRepository repository; + + GetNotificationsStreamUseCase(this.repository); + + Stream call() { + return repository.getNotificationsStream(); + } +} diff --git a/lib/domain/usecase/get_payment_cards_usecase.dart b/lib/domain/usecase/get_payment_cards_usecase.dart new file mode 100644 index 0000000..fd7fd33 --- /dev/null +++ b/lib/domain/usecase/get_payment_cards_usecase.dart @@ -0,0 +1,31 @@ +import 'package:be_happy/domain/service/security_service.dart'; + +import '../entities/payment_card.dart'; +import '../repositories/payment_repository.dart'; +import '../../core/result.dart'; + +class GetPaymentCardsUsecase { + final PaymentRepository repository; + final SecurityService securityService; + + GetPaymentCardsUsecase(this.repository, this.securityService); + + Future>> call() async { + final result = await repository.getPaymentCards(); + + if (result is Failure) { + return result; + } + + final cards = (result as Success).data as List; + + // Для каждой карты получаем полный номер из локального хранилища + /*final cardsWithFullNumbers = []; + for (final card in cards) { + final fullNumber = await securityService.getCardFullNumber(card.id); + cardsWithFullNumbers.add(card.copyWith(fullCardNumber: fullNumber)); + }*/ + + return Success(cards); + } +} diff --git a/lib/domain/usecase/get_pedestrian_routes_usecase.dart b/lib/domain/usecase/get_pedestrian_routes_usecase.dart new file mode 100644 index 0000000..0e65031 --- /dev/null +++ b/lib/domain/usecase/get_pedestrian_routes_usecase.dart @@ -0,0 +1,12 @@ +import 'package:be_happy/data/network/geocoding_remote_datasource.dart'; +import 'package:yandex_mapkit/yandex_mapkit.dart'; + +class GetPedestrianRoutesUsecase { + final GeocodingRemoteDataSource dataSource; + + GetPedestrianRoutesUsecase(this.dataSource); + + Future?> call(Point userPosition, Point targetPosition) { + return dataSource.getPedestrianRoutes(userPosition, targetPosition); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_profile_usecase.dart b/lib/domain/usecase/get_profile_usecase.dart new file mode 100644 index 0000000..fd14f19 --- /dev/null +++ b/lib/domain/usecase/get_profile_usecase.dart @@ -0,0 +1,13 @@ +import '../entities/user_profile.dart'; +import '../repositories/profile_repository.dart'; + +class GetProfileUseCase { + final UserProfileRepository repository; + + GetProfileUseCase(this.repository); + + Future call() { + return repository.getProfile(); + } +} + diff --git a/lib/domain/usecase/get_scooter_by_title_usecase.dart b/lib/domain/usecase/get_scooter_by_title_usecase.dart new file mode 100644 index 0000000..03b5225 --- /dev/null +++ b/lib/domain/usecase/get_scooter_by_title_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter.dart'; + +import '../repositories/scooter_repository.dart'; + +class GetScooterByTitleUsecase { + final ScooterRepository repository; + + GetScooterByTitleUsecase(this.repository); + + Future> call(String title) { + return repository.getScooterByTitle(title); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_scooter_order_by_id_usecase.dart b/lib/domain/usecase/get_scooter_order_by_id_usecase.dart new file mode 100644 index 0000000..0b834d0 --- /dev/null +++ b/lib/domain/usecase/get_scooter_order_by_id_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import '../entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class GetScooterOrderByIdUsecase { + final ScooterRepository repository; + + GetScooterOrderByIdUsecase(this.repository); + + Future> call(int id) { + return repository.getScooterOrderById(id); + } +} diff --git a/lib/domain/usecase/get_scooter_order_history_usecase.dart b/lib/domain/usecase/get_scooter_order_history_usecase.dart new file mode 100644 index 0000000..e49dbcd --- /dev/null +++ b/lib/domain/usecase/get_scooter_order_history_usecase.dart @@ -0,0 +1,19 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import 'package:be_happy/domain/repositories/scooter_repository.dart'; + +class GetScooterOrderHistoryUsecase { + final ScooterRepository _repository; + + GetScooterOrderHistoryUsecase(this._repository); + + Future>> call({ + int page = 1, + int pageSize = 20, + }) async { + return await _repository.getScooterOrderHistory( + page: page, + pageSize: pageSize, + ); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/get_scooter_order_route_history_usecase.dart b/lib/domain/usecase/get_scooter_order_route_history_usecase.dart new file mode 100644 index 0000000..2b17a61 --- /dev/null +++ b/lib/domain/usecase/get_scooter_order_route_history_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/point.dart'; +import 'package:be_happy/domain/repositories/scooter_repository.dart'; + +class GetScooterOrderRouteHistoryUsecase { + final ScooterRepository _repository; + + GetScooterOrderRouteHistoryUsecase(this._repository); + + Future>> call(int id) async { + return await _repository.getScooterOrderRouteHistory(id); + } +} diff --git a/lib/domain/usecase/get_scooter_usecase.dart b/lib/domain/usecase/get_scooter_usecase.dart new file mode 100644 index 0000000..cc5ed73 --- /dev/null +++ b/lib/domain/usecase/get_scooter_usecase.dart @@ -0,0 +1,16 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter.dart'; + +import '../repositories/scooter_repository.dart'; + + +class GetScooterUsecase { + final ScooterRepository repository; + + GetScooterUsecase(this.repository); + + Future> call(int id) { + return repository.getScooter(id); + } +} + diff --git a/lib/domain/usecase/get_subscription_by_id_usecase.dart b/lib/domain/usecase/get_subscription_by_id_usecase.dart new file mode 100644 index 0000000..c5ca63b --- /dev/null +++ b/lib/domain/usecase/get_subscription_by_id_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/subscription.dart'; + +import '../repositories/scooter_repository.dart'; + +class GetSubscriptionByIdUsecase { + final ScooterRepository repository; + + GetSubscriptionByIdUsecase(this.repository); + + Future> call(int id) { + return repository.getSubscriptionById(id); + } +} diff --git a/lib/domain/usecase/is_pin_set_usecase.dart b/lib/domain/usecase/is_pin_set_usecase.dart new file mode 100644 index 0000000..126cffd --- /dev/null +++ b/lib/domain/usecase/is_pin_set_usecase.dart @@ -0,0 +1,17 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; + +import '../repositories/auth_repository.dart'; + +class IsPinSetUsecase { + final PinRepository repository; + + IsPinSetUsecase(this.repository); + + Future call() async { + if (await repository.getSavedPin() == null) { + return false; + } else { + return true; + } + } +} diff --git a/lib/domain/usecase/login_usecase.dart b/lib/domain/usecase/login_usecase.dart new file mode 100644 index 0000000..1cbe726 --- /dev/null +++ b/lib/domain/usecase/login_usecase.dart @@ -0,0 +1,11 @@ +import '../repositories/auth_repository.dart'; + +class LoginUseCase { + final AuthRepository _repository; + + LoginUseCase(this._repository); + + Future execute(String phone) async { + return await _repository.login(phone); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/logout_usecase.dart b/lib/domain/usecase/logout_usecase.dart new file mode 100644 index 0000000..959c1a4 --- /dev/null +++ b/lib/domain/usecase/logout_usecase.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; + +import '../repositories/auth_repository.dart'; + +class LogoutUseCase { + final AuthRepository _authRepository; + final PinRepository _pinRepository; + + LogoutUseCase(this._authRepository, this._pinRepository); + + Future call() async { + await _authRepository.logout(); + await _pinRepository.removePin(); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/pause_ride_usecase.dart b/lib/domain/usecase/pause_ride_usecase.dart new file mode 100644 index 0000000..619712e --- /dev/null +++ b/lib/domain/usecase/pause_ride_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class PauseRideUsecase { + final ScooterRepository repository; + + PauseRideUsecase(this.repository); + + Future> call(int orderId) { + return repository.pauseRide(orderId); + } +} diff --git a/lib/domain/usecase/pay_ride_usecase.dart b/lib/domain/usecase/pay_ride_usecase.dart new file mode 100644 index 0000000..a75b5b3 --- /dev/null +++ b/lib/domain/usecase/pay_ride_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class PayRideUsecase { + final ScooterRepository repository; + + PayRideUsecase(this.repository); + + Future> call(int orderId, int? cardId, + bool isBalance) { + return repository.payScooterOrderWithPhotos(orderId: orderId, cardId: cardId, isBalance: isBalance); + } +} diff --git a/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart b/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart new file mode 100644 index 0000000..8d51b42 --- /dev/null +++ b/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart @@ -0,0 +1,21 @@ +import 'package:be_happy/core/result.dart'; +import '../entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class PayScooterOrderWithPhotosUsecase { + final ScooterRepository repository; + + PayScooterOrderWithPhotosUsecase(this.repository); + + Future> call({ + required int orderId, + required int cardId, + required bool isBalance, + }) { + return repository.payScooterOrderWithPhotos( + orderId: orderId, + cardId: cardId, + isBalance: isBalance, + ); + } +} diff --git a/lib/domain/usecase/purchase_certificate_usecase.dart b/lib/domain/usecase/purchase_certificate_usecase.dart new file mode 100644 index 0000000..4a71cdc --- /dev/null +++ b/lib/domain/usecase/purchase_certificate_usecase.dart @@ -0,0 +1,19 @@ +import '../entities/certificate.dart'; +import '../repositories/certificate_repository.dart'; +import '../../core/result.dart'; + +class PurchaseCertificateUsecase { + final CertificateRepository repository; + + PurchaseCertificateUsecase(this.repository); + + Future>> call({ + required int certificateId, + required int cardId, + }) async { + return await repository.purchaseCertificate( + certificateId: certificateId, + cardId: cardId, + ); + } +} diff --git a/lib/domain/usecase/refresh_token_usecase.dart b/lib/domain/usecase/refresh_token_usecase.dart new file mode 100644 index 0000000..ce9549b --- /dev/null +++ b/lib/domain/usecase/refresh_token_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/user_auth_data.dart'; +import '../repositories/auth_repository.dart'; + +class RefreshTokenUseCase { + final AuthRepository _repository; + + RefreshTokenUseCase(this._repository); + + Future execute() async { + return await _repository.refreshToken(); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/remove_payment_card_usecase.dart b/lib/domain/usecase/remove_payment_card_usecase.dart new file mode 100644 index 0000000..b7bf654 --- /dev/null +++ b/lib/domain/usecase/remove_payment_card_usecase.dart @@ -0,0 +1,12 @@ +import '../repositories/payment_repository.dart'; +import '../../core/result.dart'; + +class RemovePaymentCardUsecase { + final PaymentRepository repository; + + RemovePaymentCardUsecase(this.repository); + + Future> call(int cardId) { + return repository.removePaymentCard(cardId); + } +} diff --git a/lib/domain/usecase/resume_ride_usecase.dart b/lib/domain/usecase/resume_ride_usecase.dart new file mode 100644 index 0000000..6cb349c --- /dev/null +++ b/lib/domain/usecase/resume_ride_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class ResumeRideUsecase { + final ScooterRepository repository; + + ResumeRideUsecase(this.repository); + + Future> call(int orderId) { + return repository.resumeRide(orderId); + } +} diff --git a/lib/domain/usecase/save_map_settings_usecase.dart b/lib/domain/usecase/save_map_settings_usecase.dart new file mode 100644 index 0000000..494ade3 --- /dev/null +++ b/lib/domain/usecase/save_map_settings_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/domain/entities/map_settings.dart'; + +import '../repositories/app_settings_repository.dart'; + +class SaveMapSettingsUsecase { + AppSettingsRepository repository; + + SaveMapSettingsUsecase(this.repository); + + Future call(MapSettings settings) { + return repository.saveMapSettings(settings); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/set_main_payment_card_usecase.dart b/lib/domain/usecase/set_main_payment_card_usecase.dart new file mode 100644 index 0000000..ffa6c8f --- /dev/null +++ b/lib/domain/usecase/set_main_payment_card_usecase.dart @@ -0,0 +1,12 @@ +import '../repositories/payment_repository.dart'; +import '../../core/result.dart'; + +class SetMainPaymentCardUsecase { + final PaymentRepository repository; + + SetMainPaymentCardUsecase(this.repository); + + Future> call(int cardId) { + return repository.setMainPaymentCard(cardId); + } +} diff --git a/lib/domain/usecase/start_ride_usecase.dart b/lib/domain/usecase/start_ride_usecase.dart new file mode 100644 index 0000000..5e47160 --- /dev/null +++ b/lib/domain/usecase/start_ride_usecase.dart @@ -0,0 +1,13 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class StartRideUsecase { + final ScooterRepository repository; + + StartRideUsecase(this.repository); + + Future> call(int orderId) { + return repository.startRide(orderId); + } +} diff --git a/lib/domain/usecase/update_profile_usecase.dart b/lib/domain/usecase/update_profile_usecase.dart new file mode 100644 index 0000000..6bd7a65 --- /dev/null +++ b/lib/domain/usecase/update_profile_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/user_profile.dart'; +import '../repositories/profile_repository.dart'; + +class UpdateProfileUseCase { + final UserProfileRepository repository; + + UpdateProfileUseCase(this.repository); + + Future call(UserProfile profile) { + return repository.updateProfile(profile); + } +} diff --git a/lib/domain/usecase/update_scooter_order_data_usecase.dart b/lib/domain/usecase/update_scooter_order_data_usecase.dart new file mode 100644 index 0000000..1a26047 --- /dev/null +++ b/lib/domain/usecase/update_scooter_order_data_usecase.dart @@ -0,0 +1,18 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/active_scooter_order.dart'; +import '../entities/scooter_order.dart'; +import '../repositories/scooter_repository.dart'; + +class UpdateScooterOrderDataUsecase { + final ScooterRepository repository; + + UpdateScooterOrderDataUsecase(this.repository); + + Future> call({ + required int orderId, + }) { + return repository.updateScooterOrderData( + orderId: orderId, + ); + } +} diff --git a/lib/domain/usecase/upload_profile_photo_usecase.dart b/lib/domain/usecase/upload_profile_photo_usecase.dart new file mode 100644 index 0000000..3e1176a --- /dev/null +++ b/lib/domain/usecase/upload_profile_photo_usecase.dart @@ -0,0 +1,12 @@ +import 'dart:io'; +import '../repositories/profile_repository.dart'; + +class UploadProfilePhotoUsecase { + final UserProfileRepository repository; + + UploadProfilePhotoUsecase(this.repository); + + Future call(File imageFile) { + return repository.uploadProfilePhoto(imageFile); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/upload_scooter_photos_usecase.dart b/lib/domain/usecase/upload_scooter_photos_usecase.dart new file mode 100644 index 0000000..aefba1b --- /dev/null +++ b/lib/domain/usecase/upload_scooter_photos_usecase.dart @@ -0,0 +1,13 @@ +import 'dart:io'; +import 'package:be_happy/core/result.dart'; +import '../repositories/scooter_repository.dart'; + +class UploadScooterPhotosUsecase { + final ScooterRepository repository; + + UploadScooterPhotosUsecase(this.repository); + + Future>> call(List images) { + return repository.uploadScooterPhotos(images); + } +} diff --git a/lib/domain/usecase/verify_code_usecase.dart b/lib/domain/usecase/verify_code_usecase.dart new file mode 100644 index 0000000..56ff553 --- /dev/null +++ b/lib/domain/usecase/verify_code_usecase.dart @@ -0,0 +1,14 @@ +import 'package:be_happy/core/result.dart'; + +import '../entities/user_auth_data.dart'; +import '../repositories/auth_repository.dart'; + +class VerifyCodeUseCase { + final AuthRepository _repository; + + VerifyCodeUseCase(this._repository); + + Future> execute(String code, String token) { + return _repository.verifyCode(code, token); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/verify_pin_usecase.dart b/lib/domain/usecase/verify_pin_usecase.dart new file mode 100644 index 0000000..d157f09 --- /dev/null +++ b/lib/domain/usecase/verify_pin_usecase.dart @@ -0,0 +1,15 @@ +import 'package:be_happy/domain/repositories/pin_repository.dart'; + +import '../repositories/auth_repository.dart'; + +class VerifyPinUseCase { + final PinRepository repository; + + VerifyPinUseCase(this.repository); + + Future call(String enteredPin) async { + final savedPin = await repository.getSavedPin(); + return enteredPin == savedPin; + } +} + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..ec36b07 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // 🔹 1. Добавлен импорт +import 'package:bot_toast/bot_toast.dart'; +import 'package:be_happy/presentation/event/edit_profile_event.dart'; +import 'package:be_happy/presentation/event/profile_event.dart'; +import 'package:be_happy/presentation/event/scooter_detail_event.dart'; +import 'package:be_happy/presentation/navigation/app_router.dart'; +import 'package:be_happy/presentation/screens/payment_confirm_screen.dart'; +import 'package:be_happy/presentation/screens/splash_screen.dart'; +import 'package:be_happy/presentation/state/splash_state.dart'; +import 'package:be_happy/presentation/viewmodel/auth_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/edit_profile_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/map_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/payment_confirm_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/pin_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/profile_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/scooter_detail_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/verify_code_bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'di/service_locator.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + + await setupDependencies(); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + final appRouter = AppRouter(getIt()); + + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + + BlocProvider(create: (context) => getIt()), + BlocProvider(create: (context) => getIt()), + BlocProvider(create: (context) => getIt()), + BlocProvider(create: (context) => getIt()), + ], + child: BlocListener( + listener: (context, state) { + print( + '!!! Состояние AuthBloc изменилось на: ${state.runtimeType} !!!', + ); + }, + child: MaterialApp.router( + title: 'BeHappy', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(), + builder: BotToastInit(), + routerConfig: appRouter.router, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/app_checkbox.dart b/lib/presentation/components/app_checkbox.dart new file mode 100644 index 0000000..cb474c3 --- /dev/null +++ b/lib/presentation/components/app_checkbox.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; + + +class AppCheckbox extends StatelessWidget { + final bool value; + final Function(bool?) onChanged; + final bool isError; + + + const AppCheckbox({ + super.key, + required this.value, + required this.onChanged, + this.isError = false, + }); + + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isError ? AppColors.checkboxErrorBorder : AppColors.checkboxBorder, + width: 2, + ), + color: value ? AppColors.checkboxFill : Colors.transparent, + ), + child: value + ? const Icon( + Icons.check, + size: 16, + color: Color(0xFF000032), + ) + : null, + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/arrow_button.dart b/lib/presentation/components/arrow_button.dart new file mode 100644 index 0000000..c15e5ab --- /dev/null +++ b/lib/presentation/components/arrow_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; + + +class GradientButton extends StatelessWidget { + final String text; + final VoidCallback? onTap; + final bool enabled; + + + const GradientButton({ + super.key, + required this.text, + required this.onTap, + this.enabled = true, + }); + + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + constraints: const BoxConstraints( + maxWidth: 350, // Максимальная ширина кнопки + ), + height: 66, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: enabled ? AppColors.activeButtonGradient : null, + color: enabled ? null : AppColors.disabledButtonColor, + ), + alignment: Alignment.center, + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/card_input_field.dart b/lib/presentation/components/card_input_field.dart new file mode 100644 index 0000000..c9d71cb --- /dev/null +++ b/lib/presentation/components/card_input_field.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CardInputField extends StatelessWidget { + final String hintText; + final IconData? icon; + final TextEditingController? controller; + final ValueChanged? onChanged; + final TextInputType keyboardType; + final List? inputFormatters; + final bool obscureText; + final int? maxLength; + final double letterSpacing; + final TextCapitalization textCapitalization; // ← новый параметр + + const CardInputField({ + super.key, + required this.hintText, + this.icon, + this.controller, + this.onChanged, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.obscureText = false, + this.maxLength, + this.letterSpacing = 0, + this.textCapitalization = TextCapitalization.none, // ← по умолчанию none + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 46, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + obscureText: obscureText, + maxLength: maxLength, + textCapitalization: textCapitalization, // ← передаём в TextField + style: TextStyle( + color: Colors.white, + fontSize: 16, + letterSpacing: letterSpacing, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.4), + fontSize: 16, + letterSpacing: letterSpacing, + ), + counterText: '', + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + if (icon != null) ...[ + const SizedBox(width: 12), + Icon( + icon, + color: Colors.white.withOpacity(0.6), + size: 22, + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/code_dots.dart b/lib/presentation/components/code_dots.dart new file mode 100644 index 0000000..e097267 --- /dev/null +++ b/lib/presentation/components/code_dots.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; + + +class CodeDots extends StatelessWidget { + final String code; + final int length; + final bool isError; + + + const CodeDots({ + super.key, + required this.code, + required this.length, + this.isError = false, + }); + + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(length, (i) { + bool filled = i < code.length; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + filled ? code[i] : '●', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: isError + ? AppColors.pinError + : filled + ? AppColors.smsDigit + : AppColors.digitPlaceholder, + ), + ), + ); + }), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/custom_app_bar.dart b/lib/presentation/components/custom_app_bar.dart new file mode 100644 index 0000000..99054ff --- /dev/null +++ b/lib/presentation/components/custom_app_bar.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class CustomAppBar extends StatelessWidget { + final String title; + + const CustomAppBar({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + InkWell( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon(Icons.arrow_back_ios_sharp, color: const Color(0x99FFFFFF), size: 20), + Icon(Icons.arrow_back_ios_sharp, color: const Color(0x66FFFFFF), size: 20), + Icon(Icons.arrow_back_ios_sharp, color: const Color(0x22FFFFFF), size: 20), + ], + ), + ), + const SizedBox(width: 20), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/dialog/cancel_booking_dialog.dart b/lib/presentation/components/dialog/cancel_booking_dialog.dart new file mode 100644 index 0000000..d980296 --- /dev/null +++ b/lib/presentation/components/dialog/cancel_booking_dialog.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +class CancelBookingDialog extends StatelessWidget { + const CancelBookingDialog({super.key}); + + + @override + Widget build(BuildContext context) { + bool result = false; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 40), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF2E3253).withOpacity(0.95), + borderRadius: BorderRadius.circular(28), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Отменить\nбронирование?", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + const SizedBox(height: 16), + const Text( + "Вы действительно хотите отменить\nбронирование", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 24), + + Container( + width: double.infinity, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + gradient: const LinearGradient( + colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)], + ), + ), + child: ElevatedButton( + onPressed: () => { + result = false, + Navigator.pop(context, result) + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + ), + child: const Text( + "Оставить", + style: TextStyle( + color: Color(0xFF1D273A), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: () { + result = true; + Navigator.pop(context, result); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0D1024), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + ), + child: const Text( + "Отменить", + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/components/dialog/cannot_finish_ride_dialog.dart b/lib/presentation/components/dialog/cannot_finish_ride_dialog.dart new file mode 100644 index 0000000..42e1475 --- /dev/null +++ b/lib/presentation/components/dialog/cannot_finish_ride_dialog.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +class CannotFinishRideDialog extends StatelessWidget { + const CannotFinishRideDialog({super.key}); + + + @override + Widget build(BuildContext context) { + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 40), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF2E3253).withOpacity(0.95), + borderRadius: BorderRadius.circular(28), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Завершить поездку невозможно", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + const SizedBox(height: 16), + const Text( + "Вы находитесь далеко от парковки.", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 24), + + Container( + width: double.infinity, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + gradient: const LinearGradient( + colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)], + ), + ), + child: ElevatedButton( + onPressed: () => { + Navigator.pop(context) + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + ), + child: const Text( + "Понятно", + style: TextStyle( + color: Color(0xFF1D273A), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/components/fine_notification_card.dart b/lib/presentation/components/fine_notification_card.dart new file mode 100644 index 0000000..06415c2 --- /dev/null +++ b/lib/presentation/components/fine_notification_card.dart @@ -0,0 +1,90 @@ +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class FineNotificationCard extends StatelessWidget { + final VoidCallback onClose; + + const FineNotificationCard({ + super.key, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(24, 10, 20, 24), + decoration: BoxDecoration( + color: const Color(0xFF2D2B4D), + borderRadius: BorderRadius.circular(32), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.only(left: 64), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'У вас имеются неоплаченные штрафы', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 12), + Text( + 'Оплатите штраф, чтобы начать поездку', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.2, + ), + ), + ], + ), + ), + ], + ), + ), + + Positioned( + top: -20, + left: -30, + child: Image.asset( + 'assets/icons/card-screen.png', + width: 120, + height: 120, + fit: BoxFit.contain, + ), + ), + + Positioned( + top: 15, + right: 15, + child: GestureDetector( + onTap: onClose, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white70, size: 20), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/components/gradient_button.dart b/lib/presentation/components/gradient_button.dart new file mode 100644 index 0000000..60abd19 --- /dev/null +++ b/lib/presentation/components/gradient_button.dart @@ -0,0 +1,119 @@ +// import 'package:flutter/material.dart'; +// import '../core/app_colors.dart'; +// +// +// class GradientButton extends StatelessWidget { +// final String text; +// final VoidCallback? onTap; +// final bool enabled; +// +// +// const GradientButton({ +// super.key, +// required this.text, +// required this.onTap, +// this.enabled = true, +// }); +// +// +// @override +// Widget build(BuildContext context) { +// return GestureDetector( +// onTap: enabled ? onTap : null, +// child: Container( +// constraints: const BoxConstraints( +// maxWidth: 350, // Максимальная ширина кнопки +// ), +// height: 66, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(24), +// gradient: enabled ? AppColors.activeButtonGradient : null, +// color: enabled ? null : AppColors.disabledButtonColor, +// ), +// alignment: Alignment.center, +// child: Text( +// text, +// textAlign: TextAlign.center, +// style: TextStyle( +// color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText, +// fontSize: 20, +// fontWeight: FontWeight.w600, +// ), +// ), +// ), +// ); +// } +// } + +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; + +class GradientButton extends StatelessWidget { + final String text; + final VoidCallback? onTap; + final bool enabled; + final bool showArrows; // Новый параметр для стрелок + final double height; // Параметр высоты + final double width; + final double fontSize; // Параметр размера шрифта + + const GradientButton({ + super.key, + required this.text, + required this.onTap, + this.enabled = true, + this.showArrows = false, + this.width = 220, + this.height = 50, + this.fontSize = 12, + }); + + @override + Widget build(BuildContext context) { + final Color arrow1 = enabled ? const Color(0x33000032) : const Color(0x33FFFFFF); + final Color arrow2 = enabled ? const Color(0x66000032) : const Color(0x66FFFFFF); + final Color arrow3 = enabled ? const Color(0x99000032) : const Color(0x99FFFFFF); + + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: enabled ? AppColors.activeButtonGradient : null, + color: enabled ? null : AppColors.disabledButtonColor, + ), + alignment: Alignment.center, + child: showArrows + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 20), + Icon(Icons.arrow_forward_ios_sharp, color: arrow1, size: 12), + Icon(Icons.arrow_forward_ios_sharp, color: arrow2, size: 12), + Icon(Icons.arrow_forward_ios_sharp, color: arrow3, size: 12), + ], + ) + : Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/link_row.dart b/lib/presentation/components/link_row.dart new file mode 100644 index 0000000..f7faf54 --- /dev/null +++ b/lib/presentation/components/link_row.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; // ✅ Добавлен + +class LinkRow extends StatelessWidget { + final String icon; + final String title; + final VoidCallback onTap; + + const LinkRow({ + super.key, + required this.icon, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + Image.asset( + icon, + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + Row( + children: [ + Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x99FFFFFF), size: 12), + Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x66FFFFFF), size: 12), + Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x33FFFFFF), size: 12), + ], + ), + ], + ), + ), + ); + } +} + +// ✅ Выносим метод открытия ссылки +void openLink(String url) async { + final Uri uri = Uri.parse(url); + + try { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + // TODO: Показать ошибку пользователю + print('Не удалось открыть ссылку: $e'); + } +} \ No newline at end of file diff --git a/lib/presentation/components/map_icon_painter/clusterized_icon_painter.dart b/lib/presentation/components/map_icon_painter/clusterized_icon_painter.dart new file mode 100644 index 0000000..a40a578 --- /dev/null +++ b/lib/presentation/components/map_icon_painter/clusterized_icon_painter.dart @@ -0,0 +1,97 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ClusterIconPainter { + final int clusterSize; + + static ui.Image? _scooterImageCache; + + const ClusterIconPainter(this.clusterSize); + + static Future initImage(String assetPath) async { + if (_scooterImageCache != null) return; + final data = await rootBundle.load(assetPath); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + _scooterImageCache = frame.image; + } + + Future getClusterIconBytes() async { + const size = Size(180, 180); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final mainCenter = Offset(size.width * 0.45, size.height * 0.55); + final mainRadius = size.width * 0.35; + + final greenPaint = Paint() + ..color = const Color(0xFF8BFFAA) + ..style = PaintingStyle.fill; + + canvas.drawCircle(mainCenter, mainRadius, greenPaint); + + if (_scooterImageCache != null) { + final imageSize = mainRadius * 1.3; + final rect = Rect.fromCenter( + center: mainCenter, + width: imageSize, + height: imageSize, + ); + paintImage( + canvas: canvas, + image: _scooterImageCache!, + rect: rect, + fit: BoxFit.contain, + ); + } + + if (clusterSize > 1) { + _drawBadge(canvas, mainCenter, mainRadius); + } + + final image = await recorder.endRecording().toImage( + size.width.toInt(), + size.height.toInt(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData!.buffer.asUint8List(); + } + + void _drawBadge(Canvas canvas, Offset mainCenter, double mainRadius) { + final badgeCenter = Offset( + mainCenter.dx + mainRadius * 0.7, + mainCenter.dy - mainRadius * 0.7, + ); + final badgeRadius = mainRadius * 0.45; + + final badgePaint = Paint()..color = Color(0xFF000032); + + + canvas.drawCircle(badgeCenter, badgeRadius, Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3)); + + canvas.drawCircle(badgeCenter, badgeRadius, badgePaint); + + final textPainter = TextPainter( + text: TextSpan( + text: clusterSize > 99 ? '99+' : clusterSize.toString(), + style: TextStyle( + color: Colors.white, + fontSize: badgeRadius * 1.1, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + textPainter.paint( + canvas, + Offset( + badgeCenter.dx - textPainter.width / 2, + badgeCenter.dy - textPainter.height / 2, + ), + ); + } +} diff --git a/lib/presentation/components/map_settings_sheet.dart b/lib/presentation/components/map_settings_sheet.dart new file mode 100644 index 0000000..c04c4dc --- /dev/null +++ b/lib/presentation/components/map_settings_sheet.dart @@ -0,0 +1,141 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/event/map_event.dart'; +import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; +import 'package:be_happy/presentation/state/map_settings_modal_state.dart'; +import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../viewmodel/map_bloc.dart'; + +class MapSettingsSheet extends StatelessWidget { + final VoidCallback? onClose; + + const MapSettingsSheet({super.key, this.onClose}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List<_SettingItemData> items = [ + _SettingItemData( + label: 'Геоточки', + icon: Icons.location_on_outlined, + color: const Color(0xFF66E3C4), + isActive: state.isAllGeomarksActive, + onChanged: (val) => context.read().add(AllGeomarksToggled(val)), + ), + _SettingItemData( + label: 'Геозоны', + icon: Icons.gps_fixed_outlined, + color: const Color(0xFF86EFAC), + isActive: state.isAllGeozonesActive, + onChanged: (val) => context.read().add(AllGeozonesToggled(val)), + ), + _SettingItemData( + label: 'Парковка', + icon: Icons.home_outlined, + color: const Color(0xFFA78BFA), + isActive: state.isParkingZoneActive, + onChanged: (val) => context.read().add(ParkingZonesToggled(val)), + ), + _SettingItemData( + label: 'Парковка запрещена', + icon: Icons.block_outlined, + color: const Color(0xFFF59E0B), + isActive: state.isRestrictedParkingZoneActive, + onChanged: (val) => context.read().add(RestrictedParkingZonesToggled(val)), + ), + _SettingItemData( + label: 'Запрещено кататься', + icon: Icons.warning_amber_outlined, + color: const Color(0xFFEF4444), + isActive: state.isRestrictedDrivingZoneActive, + onChanged: (val) => context.read().add(RestrictedDrivingZonesToggled(val)), + ), + ]; + + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 365, + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Параметры карты', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + TextButton( + onPressed: () { + context.read().add(ApllyButtonClick()); + context.read().add(UpdateMap()); + Navigator.of(context).pop(); + }, + child: const Text( + 'Готово', + style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 10), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + leading: Icon(item.icon, color: item.color), + title: Text(item.label, style: const TextStyle(color: Colors.white)), + trailing: Switch.adaptive( + value: item.isActive, + onChanged: item.onChanged, + activeTrackColor: const Color(0xFF66E3C4), + inactiveThumbColor: Colors.white, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +// Вспомогательный класс для описания строк +class _SettingItemData { + final String label; + final IconData icon; + final Color color; + final bool isActive; + final ValueChanged onChanged; + + _SettingItemData({ + required this.label, + required this.icon, + required this.color, + required this.isActive, + required this.onChanged, + }); +} diff --git a/lib/presentation/components/notification_toast.dart b/lib/presentation/components/notification_toast.dart new file mode 100644 index 0000000..2dadd20 --- /dev/null +++ b/lib/presentation/components/notification_toast.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +class NotificationToast extends StatelessWidget { + final String title; + final VoidCallback onClose; + + const NotificationToast({required this.title, required this.onClose}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 50), + child: Material( + elevation: 8, + shadowColor: Colors.black26, + color: Colors.red, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 60, + child: Image.asset( + 'assets/icons/clichnik.png', + fit: BoxFit.contain, + ), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Данная территория не предназначена для катания!", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + // maxLines: 1, + // overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + "Самокат 123-456! Вернитесь на разрешенный участок дороги", + style: TextStyle( + fontSize: 14, + ), + // maxLines: 2, + // overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + + Align( + alignment: Alignment.topRight, + child: IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onClose, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/components/payment_notification_card.dart b/lib/presentation/components/payment_notification_card.dart new file mode 100644 index 0000000..3ae39be --- /dev/null +++ b/lib/presentation/components/payment_notification_card.dart @@ -0,0 +1,131 @@ +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class PaymentNotificationCard extends StatelessWidget { + final VoidCallback onBindCard; + final VoidCallback onClose; + + const PaymentNotificationCard({ + super.key, + required this.onBindCard, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(24, 10, 20, 24), + decoration: BoxDecoration( + color: const Color(0xFF2D2B4D), + borderRadius: BorderRadius.circular(32), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 64), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Не привязана карта', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + const Text( + 'Привяжите карту, чтобы начать поездку', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.2, + ), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: onBindCard, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF0D0B26), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + child: const Row( + children: [ + const Icon( + Icons.credit_card, + color: Color(0xFF80FFD1), + size: 28, + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + 'Привязать карту', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Icons.add, + color: Colors.cyanAccent, + size: 20, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + Positioned( + top: -20, + left: -30, + child: Image.asset( + 'assets/icons/card-screen.png', + width: 120, + height: 120, + fit: BoxFit.contain, + ), + ), + + Positioned( + top: 15, + right: 15, + child: GestureDetector( + onTap: onClose, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white70, size: 20), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/components/payment_option.dart b/lib/presentation/components/payment_option.dart new file mode 100644 index 0000000..0defc95 --- /dev/null +++ b/lib/presentation/components/payment_option.dart @@ -0,0 +1,86 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class PaymentOption extends StatelessWidget { + final String title; + final String subtitle; + final bool isSelected; + final VoidCallback onTap; + + const PaymentOption({ + required this.title, + required this.subtitle, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(1), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Text( + subtitle, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + // 🔹 РАДИО-КНОПКА + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? const Color(0xFF66E3C4) + : Colors.white.withOpacity(0.4), + width: 2, + ), + ), + child: isSelected + ? const Center( + child: SizedBox( + width: 10, + height: 10, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFF66E3C4), + shape: BoxShape.circle, + ), + ), + ), + ) + : null, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/period_selector.dart b/lib/presentation/components/period_selector.dart new file mode 100644 index 0000000..7dcf94c --- /dev/null +++ b/lib/presentation/components/period_selector.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class PeriodSelector extends StatelessWidget { + final List periods; + final int currentIndex; + final Function(int) onSelect; + + PeriodSelector({required this.currentIndex, required this.onSelect, required this.periods}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(periods.length, (index) { + bool isSelected = currentIndex == index; + return GestureDetector( + onTap: () => onSelect(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF80FFD1) : const Color(0xFF1E2652), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + periods[index], + style: TextStyle( + color: isSelected ? Colors.black : Colors.white.withOpacity(0.5), + fontWeight: FontWeight.bold, + ), + ), + ), + ); + }), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/scooter/battery_indicator.dart b/lib/presentation/components/scooter/battery_indicator.dart new file mode 100644 index 0000000..f0c8549 --- /dev/null +++ b/lib/presentation/components/scooter/battery_indicator.dart @@ -0,0 +1,170 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class BatteryIndicator extends StatelessWidget { + final double percent; + + const BatteryIndicator({super.key, required this.percent}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 400, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: const Size(320, 320), + painter: _BatteryRingPainter(percent: percent), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Заряд батареи', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + '${(percent * 100).toInt()}%', + style: const TextStyle( + color: Colors.white, + fontSize: 48, + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _BatteryRingPainter extends CustomPainter { + final double percent; + + _BatteryRingPainter({required this.percent}); + + // 🔹 Возвращает цвета и stops для текущего диапазона + (List, List?) _getGradientForPercent() { + final p = percent * 100; + + if (p >= 51) { + return ( + const [ + Color(0xFF86EFAC), + Color(0xFF67E8F9), + Color(0xFF86EFAC), + ], + const [0.0, 0.5, 1.0], + ); + } else if (p >= 16) { + return ( + const [ + Color(0xFFF1FF8B), + Color(0xFF8BFFAA), + Color(0xFFF1FF8B), + ], + const [0.0, 0.5, 1.0], + ); + } else { + return ( + const [ + Color(0xFFFF5757), + Color(0xFFF1FF8B), + Color(0xFFFF5757), + ], + const [0.0, 0.5, 1.0], + ); + } + } + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.width / 2 - 20; + + // Фоновое кольцо + final backgroundPaint = Paint() + ..color = Colors.white.withOpacity(0.08) + ..style = PaintingStyle.stroke + ..strokeWidth = 40; + canvas.drawCircle(center, radius, backgroundPaint); + + // Деления + final tickPaint = Paint() + ..color = Colors.white.withOpacity(0.15) + ..strokeWidth = 2; + for (int i = 0; i < 100; i++) { + final angle = 2 * pi * i / 100 - pi / 2; + final isMajor = i % 10 == 0; + + final innerRadius = radius - 40; + final outerRadius = isMajor ? radius - 28 : radius - 32; + + final p1 = Offset( + center.dx + cos(angle) * innerRadius, + center.dy + sin(angle) * innerRadius, + ); + final p2 = Offset( + center.dx + cos(angle) * outerRadius, + center.dy + sin(angle) * outerRadius, + ); + + canvas.drawLine(p1, p2, tickPaint); + } + + // Получаем градиент по проценту + final (colors, stops) = _getGradientForPercent(); + + // Glow дуга + final glowPaint = Paint() + ..shader = SweepGradient( + colors: colors, + stops: stops, + ).createShader(Rect.fromCircle(center: center, radius: radius)) + ..style = PaintingStyle.stroke + ..strokeWidth = 25 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 85) + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -pi / 2, + 2 * pi * percent, + false, + glowPaint, + ); + + // Основная дуга + final progressPaint = Paint() + ..shader = SweepGradient( + colors: colors, + stops: stops, + ).createShader(Rect.fromCircle(center: center, radius: radius)) + ..style = PaintingStyle.stroke + ..strokeWidth = 20 + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -pi / 2, + 2 * pi * percent, + false, + progressPaint, + ); + + // Внутренний круг + final innerCircle = Paint() + ..color = const Color(0xFF16233F) + ..style = PaintingStyle.fill; + canvas.drawCircle(center, radius - 60, innerCircle); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/lib/presentation/components/scooter/mini_battery_indicator.dart b/lib/presentation/components/scooter/mini_battery_indicator.dart new file mode 100644 index 0000000..73564a0 --- /dev/null +++ b/lib/presentation/components/scooter/mini_battery_indicator.dart @@ -0,0 +1,95 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class MiniBatteryIndicator extends StatelessWidget { + final int percent; + + const MiniBatteryIndicator({super.key, required this.percent}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: CustomPaint( + painter: _MiniBatteryRingPainter(percent: percent), + ), + ); + } +} + +class _MiniBatteryRingPainter extends CustomPainter { + final int percent; + + _MiniBatteryRingPainter({required this.percent}); + + (List, List?) _getGradientForPercent() { + final p = percent; + + if (p >= 51) { + return ( + const [ + Color(0xFF86EFAC), + Color(0xFF67E8F9), + Color(0xFF86EFAC), + ], + const [0.0, 0.5, 1.0], + ); + } else if (p >= 16) { + return ( + const [ + Color(0xFFF1FF8B), + Color(0xFF8BFFAA), + Color(0xFFF1FF8B), + ], + const [0.0, 0.5, 1.0], + ); + } else { + return ( + const [ + Color(0xFFFF5757), + Color(0xFFF1FF8B), + Color(0xFFFF5757), + ], + const [0.0, 0.5, 1.0], + ); + } + } + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.width / 2 - 4; // поменьше + + // Фоновое кольцо + final backgroundPaint = Paint() + ..color = Colors.white.withOpacity(0.08) + ..style = PaintingStyle.stroke + ..strokeWidth = 4; + canvas.drawCircle(center, radius, backgroundPaint); + + // Получаем цвета + final (colors, stops) = _getGradientForPercent(); + + // Основная дуга + final progressPaint = Paint() + ..shader = SweepGradient( + colors: colors, + stops: stops, + ).createShader(Rect.fromCircle(center: center, radius: radius)) + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -pi / 2, + 2 * pi * (percent / 100), + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/presentation/components/scooter/scooter_info_item.dart b/lib/presentation/components/scooter/scooter_info_item.dart new file mode 100644 index 0000000..5e73a34 --- /dev/null +++ b/lib/presentation/components/scooter/scooter_info_item.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../../../core/app_colors.dart'; + +class ScooterInfoItem extends StatelessWidget { + final String icon; + final String text; + + const ScooterInfoItem({super.key, required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Image.asset( + icon, + height: 20, + width: 20, + ), + const SizedBox(width: 6), + Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/scooter/scooter_info_section.dart b/lib/presentation/components/scooter/scooter_info_section.dart new file mode 100644 index 0000000..0d94689 --- /dev/null +++ b/lib/presentation/components/scooter/scooter_info_section.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'scooter_info_item.dart'; + +class ScooterInfoSection extends StatelessWidget { + const ScooterInfoSection({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: const [ + ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'), + ScooterInfoItem(icon: 'assets/icons/speed.png', text: 'max = 25 км/ч'), + ScooterInfoItem(icon: 'assets/icons/location.png', text: 'пр. Московский, 33'), + Row( + children: [ + ScooterInfoItem(icon: 'assets/icons/person.png', text: '120 м'), + SizedBox(width: 16), + ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/scooter/slide_to_reserve_button.dart b/lib/presentation/components/scooter/slide_to_reserve_button.dart new file mode 100644 index 0000000..1307633 --- /dev/null +++ b/lib/presentation/components/scooter/slide_to_reserve_button.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +class SlideToReserveButton extends StatefulWidget { + final VoidCallback onSlideComplete; + + const SlideToReserveButton({ + super.key, + required this.onSlideComplete, + }); + + @override + State createState() => _SlideToReserveButtonState(); +} + +class _SlideToReserveButtonState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _dragAnimation; + double _dragOffset = 0; + final double _maxDrag = 240; // ширина кнопки - ширина круга + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _dragAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDragUpdate(DragUpdateDetails details) { + setState(() { + _dragOffset += details.delta.dx; + if (_dragOffset < 0) _dragOffset = 0; + if (_dragOffset > _maxDrag) _dragOffset = _maxDrag; + }); + } + + void _onDragEnd(DragEndDetails details) { + if (_dragOffset >= _maxDrag * 0.8) { + _controller.forward(); + Future.delayed(const Duration(milliseconds: 300), () { + widget.onSlideComplete(); + }); + } else { + _controller.reverse(); + setState(() { + _dragOffset = 0; + }); + } + } + + @override + Widget build(BuildContext context) { + final isSlided = _dragOffset >= _maxDrag * 0.8; + final progress = _dragOffset / _maxDrag; + + return SizedBox( + height: 64, + child: Stack( + children: [ + // Фон кнопки: темный → градиент + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: isSlided + ? Colors.transparent + : const Color(0xFF141530), + ), + ), + + // Градиентный фон (появляется при свайпе) + if (isSlided) + ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + colors: [Color(0xFF67E8F9), Color(0xFF86EFAC)], + ).createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: Colors.white, + ), + ), + ), + + // Текст «Забронировать» — виден только в начальном состоянии + if (!isSlided) + Center( + child: Text( + 'Забронировать', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + + // Три стрелки — справа от текста (только когда текст виден) + if (!isSlided) + Positioned( + right: 40, + child: Row( + children: [ + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white), + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.6)), + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.3)), + ], + ), + ), + + // Круг с иконкой самоката — двигается слева направо + Transform.translate( + offset: Offset(_dragOffset, 0), + child: SizedBox( + width: 56, + height: 56, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.electric_scooter, + color: Color(0xFF141530), + size: 24, + ), + ), + ), + ), + ), + + // Иконка самоката справа — появляется при полном свайпе + if (isSlided) + Positioned( + right: 16, + child: SizedBox( + width: 32, + height: 32, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.electric_scooter, + color: Colors.white, + size: 16, + ), + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/scooter_bottom_sheet.dart b/lib/presentation/components/scooter_bottom_sheet.dart new file mode 100644 index 0000000..5c749ce --- /dev/null +++ b/lib/presentation/components/scooter_bottom_sheet.dart @@ -0,0 +1,267 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/components/scooter/mini_battery_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/entities/scooter.dart'; +import '../state/scooter_detail_modal_state.dart'; +import '../viewmodel/scooter_detail_modal_bloc.dart'; +import 'gradient_button.dart'; + +class ScooterData { + final String distance; + final String number; + final double batteryPercent; + + ScooterData({ + required this.distance, + required this.number, + required this.batteryPercent, + }); +} + +class ScooterBottomSheet extends StatefulWidget { + + const ScooterBottomSheet({super.key}); + + @override + State createState() => _ScooterBottomSheetState(); +} + +class _ScooterBottomSheetState extends State { + + final PageController _pageController = PageController(viewportFraction: 0.5); + double _currentPage = 0; + + _ScooterBottomSheetState(); + + @override + void initState() { + super.initState(); + _pageController.addListener(() { + setState(() { + _currentPage = _pageController.page ?? 0; + }); + }); + } + + + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.status == ScooterDetailModalStatus.loading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.status == ScooterDetailModalStatus.success) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 320, + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header с адресом (без изменений) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon(Icons.arrow_back_ios, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + state.address ?? "Unknown address", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // PageView с динамическим списком + SizedBox( + height: 220, + child: PageView.builder( + controller: _pageController, + padEnds: false, // Оставляем false, чтобы первый элемент прилипал к левому краю + itemCount: state.scooters!.length, + itemBuilder: (context, index) { + final scooter = state.scooters![index]; + final diff = (_currentPage - index).abs(); + final isActive = diff < 0.5; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + // 2. Добавляем левый отступ ТОЛЬКО для первого элемента, + // чтобы он совпадал с заголовком + margin: EdgeInsets.only( + left: index == 0 ? 20 : 0, + right: 16, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Transform.scale( + scale: isActive ? 1.0 : 0.9, + child: _ScooterCard( + scooter: scooter, + isActive: isActive, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + return Center(child: Text("Error")); + }, + ); + } +} + +class _ScooterCard extends StatelessWidget { + final Scooter scooter; + final bool isActive; + + const _ScooterCard({required this.scooter, required this.isActive}); + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 220, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + colors: [ + Colors.white.withOpacity(isActive ? 0.35 : 0.25), + Colors.white.withOpacity(isActive ? 0.25 : 0.18), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: Colors.white.withOpacity(0.4), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.location_on_outlined, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 6), + Text( + "${(scooter.distance?.toInt()) ?? 0}m", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + + const SizedBox(height: 12), + + Row( + children: [ + const Icon(Icons.qr_code_2, color: Colors.white, size: 18), + const SizedBox(width: 6), + Text( + scooter.number, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + + const SizedBox(height: 20), + + Row( + children: [ + MiniBatteryIndicator(percent: scooter.batteryLevel), + const SizedBox(width: 8), + Transform.translate( + offset: const Offset(-40, 0), + child: Text( + '${(scooter.batteryLevel)}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + + const Spacer(), + + GestureDetector( + onTap: () { + context.go('/home/scooter/${scooter.id}'); + }, + child: Container( + height: 42, + alignment: Alignment.center, + child: GradientButton( + text: "Подробнеe", + showArrows: true, + height: 32, + width: double.infinity, + fontSize: 12, + onTap: () { + context.go('/home/scooter/${scooter.id}'); + }, + ), + ), + ), + ], + ), + ), + + Positioned( + right: isActive ? -30 : -5, + top: isActive ? -10 : 15, + child: SizedBox( + height: isActive ? 170 : 120, + child: Image.asset( + "assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png", + fit: BoxFit.contain, + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/components/sheet/active_ride_sheet.dart b/lib/presentation/components/sheet/active_ride_sheet.dart new file mode 100644 index 0000000..1d26525 --- /dev/null +++ b/lib/presentation/components/sheet/active_ride_sheet.dart @@ -0,0 +1,517 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../di/service_locator.dart'; +import '../../event/active_ride_event.dart'; +import '../../state/active_ride_state.dart'; +import '../../viewmodel/active_ride_bloc.dart'; +import '../notification_toast.dart'; + +class ActiveRideSheet extends StatefulWidget { + final int orderId; + final String scooterNumber; + final Duration initialElapsedTime; + + const ActiveRideSheet({ + super.key, + required this.orderId, + required this.scooterNumber, + this.initialElapsedTime = Duration.zero, + }); + + @override + State createState() => _ActiveRideSheetState(); +} + +class _ActiveRideSheetState extends State { + late final ActiveRideBloc _bloc; + Timer? _localTimer; + + @override + void initState() { + super.initState(); + _bloc = getIt(); + _bloc.add(LoadScooterOrder(widget.orderId)); + + // Локальный таймер для обновления UI каждую секунду + _localTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted && !_bloc.state.isPaused) { + setState(() {}); + } + }); + } + + @override + void dispose() { + _bloc.close(); + _localTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: BlocConsumer( + listenWhen: (previous, current) => previous.inZone != current.inZone, + listener: (context, state) { + if (!state.inZone) { + BotToast.showCustomNotification( + // duration: const Duration(seconds: 4), + + toastBuilder: (_) { + return NotificationToast( + title: "Вы покинули зону разрешенную для езды", + onClose: () { + BotToast.cleanAll(); + }, + ); + }, + ); + } + }, + builder: (context, state) { + // Логика отображения загрузки и ошибок остается прежней + if (state.status == ActiveRideStatus.loading && state.order == null) { + return _buildLoading(); + } + + if (state.status == ActiveRideStatus.failure && state.order == null) { + return _buildError(state.errorMessage); + } + + return _buildContent(state); + }, + ), + ); + } + + Widget _buildLoading() { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: const CircularProgressIndicator(color: Colors.white), + ), + ); + } + + Widget _buildError(String? message) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Text( + message ?? 'Ошибка загрузки', + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + + Widget _buildContent(ActiveRideState state) { + final displayTime = state.isPaused + ? state.elapsedTime + : state.elapsedTime + (DateTime.now().difference(DateTime.now().subtract(const Duration(seconds: 1)))); + + // Для отображения текущего времени в реальном времени + final effectiveElapsedTime = state.isPaused + ? state.elapsedTime + : DateTime.now().difference(state.order?.startAt ?? state.order?.createdAt ?? DateTime.now()); + + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Самокат ${widget.scooterNumber}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // 🔹 ТАЙМЕР + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + _formatDuration(effectiveElapsedTime), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + fontFamily: 'Digital Numbers', + ), + ), + const SizedBox(height: 4), + Text( + state.isPaused ? 'на паузе' : 'время в пути', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 13, + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // 🔹 КНОПКИ УПРАВЛЕНИЯ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Container( + height: 56, + decoration: BoxDecoration( + gradient: state.isPaused + ? null + : const LinearGradient( + colors: [Color(0xFF66E3C4), Color(0xFF4CD1B5)], + ), + color: state.isPaused ? Colors.white.withOpacity(0.15) : null, + borderRadius: BorderRadius.circular(16), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: state.status == ActiveRideStatus.loading + ? null + : () { + if (state.isPaused) { + _bloc.add(ResumeRide(widget.orderId)); + } else { + _bloc.add(PauseRide(widget.orderId)); + } + }, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + state.isPaused ? Icons.play_arrow : Icons.pause, + color: state.isPaused + ? Colors.white.withOpacity(0.8) + : const Color(0xFF0A0F2E), + size: 24, + ), + const SizedBox(height: 4), + Text( + state.isPaused ? 'ПРОДОЛЖИТЬ' : 'ПАУЗА', + style: TextStyle( + color: state.isPaused + ? Colors.white.withOpacity(0.8) + : const Color(0xFF0A0F2E), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFB84949), + borderRadius: BorderRadius.circular(16), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: state.status == ActiveRideStatus.loading + ? null + : () { + _bloc.add(FinishRide(widget.orderId)); + Navigator.pop(context); + context.go("/home/order-photos/${widget.orderId}"); + }, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.stop, + color: Colors.white, + size: 24, + ), + const SizedBox(height: 4), + const Text( + 'ЗАВЕРШИТЬ', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // TODO: Поддержка + }, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.phone_in_talk, + color: Colors.white.withOpacity(0.8), + size: 24, + ), + const SizedBox(height: 4), + Text( + 'Поддержка', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // 🔹 СТАТИСТИКА (2 равных столбца, правый на всю высоту) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: IntrinsicHeight( + child: Row( + children: [ + // 🔹 ЛЕВЫЙ СТОЛБЕЦ: Скорость + Расстояние + Expanded( + child: Column( + children: [ + // Скорость + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + state.speed.toStringAsFixed(1), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'скорость', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + // Расстояние + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + state.distance.toStringAsFixed(1), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'расстояние', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // 🔹 ПРАВЫЙ СТОЛБЕЦ: Стоимость (на всю высоту) + Expanded( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Стоимость', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 12, + ), + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: state.cost.toStringAsFixed(1), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Digital Numbers', + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const TextSpan( + text: ' BYN', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = twoDigits(duration.inHours); + final minutes = twoDigits(duration.inMinutes.remainder(60)); + final seconds = twoDigits(duration.inSeconds.remainder(60)); + return '$hours:$minutes:$seconds'; + } +} \ No newline at end of file diff --git a/lib/presentation/components/sheet/current_rides_sheet.dart b/lib/presentation/components/sheet/current_rides_sheet.dart new file mode 100644 index 0000000..d0bd767 --- /dev/null +++ b/lib/presentation/components/sheet/current_rides_sheet.dart @@ -0,0 +1,400 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../di/service_locator.dart'; +import '../../../domain/entities/scooter_order.dart'; +import '../../event/current_rides_event.dart'; +import '../../state/current_rides_state.dart'; +import '../../viewmodel/current_rides_bloc.dart'; +import '../gradient_button.dart'; +import 'reserved_ride_sheet.dart'; +import 'active_ride_sheet.dart'; + +class CurrentRidesSheet extends StatefulWidget { + + const CurrentRidesSheet({super.key}); + + @override + State createState() => _CurrentRidesSheetState(); +} + +class _CurrentRidesSheetState extends State { + late final CurrentRidesBloc _bloc; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 450, + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Текущие поездки', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.status == CurrentRidesStatus.loading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.status == CurrentRidesStatus.failure) { + return Center( + child: Text( + state.errorMessage ?? 'Ошибка загрузки', + style: const TextStyle(color: Colors.white), + ), + ); + } + + if (state.orders.isEmpty) { + return const Center( + child: Text( + 'Нет активных поездок', + style: TextStyle(color: Colors.white70), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: state.orders.map((order) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _RideCard(order: order), + ); + }).toList(), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + }, + borderRadius: BorderRadius.circular(24), + child: const Center( + child: Text( + 'Взять ещё самокат', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } +} + +class _RideCard extends StatefulWidget { + final ScooterOrder order; + + const _RideCard({required this.order}); + + @override + State<_RideCard> createState() => _RideCardState(); +} + +class _RideCardState extends State<_RideCard> { + late Timer _timer; + late Duration _elapsedTime; + late Duration _reservationTime; + late DateTime _startTime; + + @override + void initState() { + super.initState(); + _startTime = widget.order.startAt ?? widget.order.createdAt; + _elapsedTime = DateTime.now().difference(_startTime); + _reservationTime = const Duration(minutes: 5); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _elapsedTime = DateTime.now().difference(_startTime); + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isReserved = + widget.order.status == 'Booking' || //Drive, Finish + widget.order.status == 'holding'; + Duration displayTime; + if (isReserved) { + displayTime = _reservationTime - _elapsedTime; + if (displayTime.isNegative) { + displayTime = Duration.zero; + } + } else { + displayTime = _elapsedTime; + } + final timeString = _formatDuration(displayTime); + final statusText = _getStatusText(widget.order.status); + final statusColor = _getStatusColor(widget.order.status); + + final scooterNumber = + widget.order.scooter?.number ?? widget.order.scooterId.toString(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + SizedBox( + width: 70, + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + height: 70, + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + statusText, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + scooterNumber, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _getLocationText(), + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 13, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + timeString, + style: TextStyle( + color: statusColor, + fontSize: 26, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + fontFamily: 'Digital Numbers', + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 32, + child: GradientButton( + text: 'Подробнее', + showArrows: false, + height: 32, + width: 100, + fontSize: 11, + onTap: () { + if (isReserved) { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ReservedRideSheet( + orderId: widget.order.id, + scooterNumber: scooterNumber, + initialReservationTime: + _reservationTime - _elapsedTime, + ), + ); + } else { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ActiveRideSheet( + orderId: widget.order.id, + scooterNumber: scooterNumber, + initialElapsedTime: _elapsedTime, + ), + ); + } + }, + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _getStatusText(String status) { + switch (status.toLowerCase()) { + case 'reserved': + case 'holding': + return 'Забронировано'; + case 'active': + case 'in_progress': + return 'Активно'; + case 'completed': + return 'Завершено'; + default: + return status; + } + } + + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'reserved': + case 'holding': + return const Color(0xFFFFB800); + case 'active': + case 'in_progress': + return const Color(0xFF66E3C4); + default: + return Colors.white; + } + } + + String _getLocationText() { + /*if (widget.order.scooter != null && widget.order.scooter!.address != null) { + return widget.order.scooter!.address!; + }*/ + return 'Московский 33'; + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final minutes = twoDigits(duration.inMinutes.remainder(60)); + final seconds = twoDigits(duration.inSeconds.remainder(60)); + return '$minutes:$seconds'; + } +} diff --git a/lib/presentation/components/sheet/map_settings_sheet.dart b/lib/presentation/components/sheet/map_settings_sheet.dart new file mode 100644 index 0000000..07b8a2e --- /dev/null +++ b/lib/presentation/components/sheet/map_settings_sheet.dart @@ -0,0 +1,141 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/event/map_event.dart'; +import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; +import 'package:be_happy/presentation/state/map_settings_modal_state.dart'; +import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../viewmodel/map_bloc.dart'; + +class MapSettingsSheet extends StatelessWidget { + final VoidCallback? onClose; + + const MapSettingsSheet({super.key, this.onClose}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List<_SettingItemData> items = [ + _SettingItemData( + label: 'Геоточки', + icon: Icons.location_on_outlined, + color: const Color(0xFF66E3C4), + isActive: state.isAllGeomarksActive, + onChanged: (val) => context.read().add(AllGeomarksToggled(val)), + ), + _SettingItemData( + label: 'Геозоны', + icon: Icons.gps_fixed_outlined, + color: const Color(0xFF86EFAC), + isActive: state.isAllGeozonesActive, + onChanged: (val) => context.read().add(AllGeozonesToggled(val)), + ), + _SettingItemData( + label: 'Парковка', + icon: Icons.home_outlined, + color: const Color(0xFFA78BFA), + isActive: state.isParkingZoneActive, + onChanged: (val) => context.read().add(ParkingZonesToggled(val)), + ), + _SettingItemData( + label: 'Разрешено кататься', + icon: Icons.block_outlined, + color: const Color(0xFF5ECD4C), + isActive: state.isRestrictedParkingZoneActive, + onChanged: (val) => context.read().add(RestrictedParkingZonesToggled(val)), + ), + _SettingItemData( + label: 'Запрещено кататься', + icon: Icons.warning_amber_outlined, + color: const Color(0xFFEF4444), + isActive: state.isRestrictedDrivingZoneActive, + onChanged: (val) => context.read().add(RestrictedDrivingZonesToggled(val)), + ), + ]; + + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 365, + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Параметры карты', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + TextButton( + onPressed: () { + context.read().add(ApllyButtonClick()); + context.read().add(UpdateMap()); + Navigator.of(context).pop(); + }, + child: const Text( + 'Готово', + style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 10), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + leading: Icon(item.icon, color: item.color), + title: Text(item.label, style: const TextStyle(color: Colors.white)), + trailing: Switch.adaptive( + value: item.isActive, + onChanged: item.onChanged, + activeTrackColor: const Color(0xFF66E3C4), + inactiveThumbColor: Colors.white, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +// Вспомогательный класс для описания строк +class _SettingItemData { + final String label; + final IconData icon; + final Color color; + final bool isActive; + final ValueChanged onChanged; + + _SettingItemData({ + required this.label, + required this.icon, + required this.color, + required this.isActive, + required this.onChanged, + }); +} diff --git a/lib/presentation/components/sheet/payment_method_sheet.dart b/lib/presentation/components/sheet/payment_method_sheet.dart new file mode 100644 index 0000000..4eb5648 --- /dev/null +++ b/lib/presentation/components/sheet/payment_method_sheet.dart @@ -0,0 +1,278 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/event/tariff_sheet_event.dart'; +import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:be_happy/presentation/components/payment_option.dart'; + +import '../../../domain/entities/payment_card.dart'; +import '../../event/payment_method_sheet_event.dart'; +import '../../state/payment_method_sheet_state.dart'; +import '../../viewmodel/payment_method_sheet_bloc.dart'; + +class PaymentMethodSheet extends StatefulWidget { + final PaymentCard? initialSelectedCard; // Добавляем это поле + + const PaymentMethodSheet({ + super.key, + this.initialSelectedCard, // Инициализируем в конструкторе + }); + + @override + State createState() => _PaymentMethodSheetState(); +} + +class _PaymentMethodSheetState extends State { + int? _selectedPaymentMethod = -2; + + @override + void initState() { + super.initState(); + context.read().add(PaymentMethodSheetStarted()); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + + if (state.status == PaymentMethodSheetStatus.loading) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 450, + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ), + ); + } + + if (state.status == PaymentMethodSheetStatus.failure) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 450, + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Center( + child: Text( + state.errorMessage ?? 'Ошибка загрузки карт', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ), + ); + } + + if (state.status == PaymentMethodSheetStatus.success && _selectedPaymentMethod == -2) { + if (widget.initialSelectedCard != null) { + final initialIndex = state.cards.indexWhere( + (card) => card.cardLastNumber == widget.initialSelectedCard!.cardLastNumber + ); + _selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1; + } else { + final mainCardIndex = state.cards.indexWhere((card) => card.isMain); + _selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1; + } + } + + // Находим карту с isMain: true при загрузке + /*if (_selectedPaymentMethod == null) { + final mainCardIndex = state.cards.indexWhere((card) => card.isMain); + if (mainCardIndex != -1) { + _selectedPaymentMethod = mainCardIndex; + } + }*/ + + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 450, + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0x00000032).withOpacity(0.6), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Выберите способ оплаты', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + PaymentOption( + title: 'Баланс', + subtitle: '${state.balance.toStringAsFixed(2)} BYN', + isSelected: _selectedPaymentMethod == -1, + onTap: () { + setState(() { + _selectedPaymentMethod = -1; + }); + Navigator.pop(context, 'balance'); + }, + ), + + const SizedBox(height: 12), + + ...state.cards.asMap().entries.map((entry) { + final index = entry.key; + final card = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: PaymentOption( + title: card.type, + subtitle: '****${card.cardLastNumber}', + isSelected: _selectedPaymentMethod == index, + onTap: () { + setState(() { + _selectedPaymentMethod = index; + }); + Navigator.pop(context, card); + }, + ), + ); + }).toList(), + + Container( + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + context.go('/home/payment-methods/add-card'); + }, + borderRadius: BorderRadius.circular(24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add, + color: const Color(0xFF66E3C4), + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Добавить платежную карту', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + + + }, + ); + } + + String _getCardType(String lastNumber) { + if (lastNumber.isEmpty) return 'Card'; + final firstDigit = lastNumber[0]; + switch (firstDigit) { + case '4': + return 'Visa'; + case '5': + return 'Mastercard'; + case '9': + return 'BelCard'; + default: + return 'Card'; + } + } +} diff --git a/lib/presentation/components/sheet/reserved_ride_sheet.dart b/lib/presentation/components/sheet/reserved_ride_sheet.dart new file mode 100644 index 0000000..d122c0b --- /dev/null +++ b/lib/presentation/components/sheet/reserved_ride_sheet.dart @@ -0,0 +1,335 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../di/service_locator.dart'; +import '../../event/reserved_ride_event.dart'; +import '../../state/reserved_ride_state.dart'; +import '../../viewmodel/reserved_ride_bloc.dart'; +import '../dialog/cancel_booking_dialog.dart'; +import '../gradient_button.dart'; +import 'active_ride_sheet.dart'; + +class ReservedRideSheet extends StatefulWidget { + final String scooterNumber; + final int orderId; + final Duration initialReservationTime; + + const ReservedRideSheet({ + super.key, + required this.scooterNumber, + required this.orderId, + this.initialReservationTime = const Duration(minutes: 3, seconds: 17), + }); + + @override + State createState() => _ReservedRideSheetState(); +} + +class _ReservedRideSheetState extends State { + late final ReservedRideBloc _bloc; + late Duration _reservationTime; + late Timer _timer; + + @override + void initState() { + super.initState(); + _bloc = getIt(); + _reservationTime = widget.initialReservationTime; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _reservationTime = _reservationTime - const Duration(seconds: 1); + if (_reservationTime.isNegative) { + _reservationTime = Duration.zero; + timer.cancel(); + } + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + _bloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.5), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // HEADER + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Бесплатное бронирование', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + // Таймер + Expanded( + flex: 2, + child: Text( + _formatDuration(_reservationTime), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + fontFamily: 'Digital Numbers', + ), + ), + ), + // Иконка и информация (ВЫСОКИЙ БЛОК) + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Иконка самоката (ВЫШЕ) + SizedBox( + width: 44, + height: 56, + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 12), + // Инфо + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFFFB800), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Забронирован', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + const SizedBox(height: 8), + Text( + '№${widget.scooterNumber}', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // КНОПКА "НАЧАТЬ ПОЕЗДКУ" + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocListener( + listener: (context, state) { + if (state.rideStarted) { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ActiveRideSheet( + scooterNumber: widget.scooterNumber, + initialElapsedTime: Duration.zero, + orderId: widget.orderId, + ), + ); + } else if (state.status == ReservedRideStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), + ); + } + }, + child: GradientButton( + text: 'Начать поездку', + showArrows: true, + height: 48, + width: double.infinity, + fontSize: 15, + onTap: () { + _bloc.add(StartRide(widget.orderId)); + }, + ), + ), + ), + + const SizedBox(height: 12), + + // КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ" + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocListener( + listener: (context, state) { + if (state.rideCancelled) { + Navigator.pop(context); + } else if (state.status == ReservedRideStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), + ); + } + }, + child: Container( + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + final result = await showDialog( + context: context, + builder: (context) => const CancelBookingDialog(), + ); + if (result != null && result) { + _bloc.add(CancelRide(widget.orderId)); + } + }, + borderRadius: BorderRadius.circular(24), + child: BlocBuilder( + builder: (context, state) { + if (state.status == ReservedRideStatus.loading) { + return const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ); + } + return const Center( + child: Text( + 'Отменить бронирование', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ), + ); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final minutes = twoDigits(duration.inMinutes.remainder(60)); + final seconds = twoDigits(duration.inSeconds.remainder(60)); + return '$minutes:$seconds'; + } +} diff --git a/lib/presentation/components/sheet/scooter_bottom_sheet.dart b/lib/presentation/components/sheet/scooter_bottom_sheet.dart new file mode 100644 index 0000000..fd07d3a --- /dev/null +++ b/lib/presentation/components/sheet/scooter_bottom_sheet.dart @@ -0,0 +1,286 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/components/scooter/mini_battery_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../domain/entities/scooter.dart'; +import '../../state/scooter_detail_modal_state.dart'; +import '../../viewmodel/scooter_detail_modal_bloc.dart'; +import '../gradient_button.dart'; + +class ScooterData { + final String distance; + final String number; + final double batteryPercent; + + ScooterData({ + required this.distance, + required this.number, + required this.batteryPercent, + }); +} + +class ScooterBottomSheet extends StatefulWidget { + const ScooterBottomSheet({super.key}); + + @override + State createState() => _ScooterBottomSheetState(); +} + +class _ScooterBottomSheetState extends State { + final PageController _pageController = PageController(viewportFraction: 0.5); + double _currentPage = 0; + + _ScooterBottomSheetState(); + + @override + void initState() { + super.initState(); + _pageController.addListener(() { + setState(() { + _currentPage = _pageController.page ?? 0; + }); + }); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.status == ScooterDetailModalStatus.loading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.status == ScooterDetailModalStatus.success) { + return Dismissible( + key: const Key('scooter-modal'), + direction: DismissDirection.down, // Закрытие только вниз + onDismissed: (_) => context.pop(), + child: Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 320, + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header с адресом (без изменений) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + state.address ?? "Unknown address", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // PageView с динамическим списком + SizedBox( + height: 220, + child: PageView.builder( + controller: _pageController, + padEnds: false, + // Оставляем false, чтобы первый элемент прилипал к левому краю + itemCount: state.scooters!.length, + itemBuilder: (context, index) { + final scooter = state.scooters![index]; + final diff = (_currentPage - index).abs(); + final isActive = diff < 0.5; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + // 2. Добавляем левый отступ ТОЛЬКО для первого элемента, + // чтобы он совпадал с заголовком + margin: EdgeInsets.only( + left: index == 0 ? 10 : 0, + right: 10, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Transform.scale( + scale: isActive ? 1.0 : 0.9, + child: _ScooterCard( + scooter: scooter, + isActive: isActive, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + return Center(child: Text("Error")); + }, + ); + } +} + +class _ScooterCard extends StatelessWidget { + final Scooter scooter; + final bool isActive; + + const _ScooterCard({required this.scooter, required this.isActive}); + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 220, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + colors: [ + Colors.white.withOpacity(isActive ? 0.35 : 0.25), + Colors.white.withOpacity(isActive ? 0.25 : 0.18), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: Colors.white.withOpacity(0.4), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.location_on_outlined, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 6), + Text( + "${(scooter.distance?.toInt()) ?? 0}m", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + + const SizedBox(height: 12), + + Row( + children: [ + const Icon( + Icons.qr_code_scanner_outlined, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 6), + Text( + scooter.number, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + + const SizedBox(height: 20), + + Row( + children: [ + MiniBatteryIndicator(percent: scooter.batteryLevel), + const SizedBox(width: 8), + Transform.translate( + offset: const Offset(-40, 0), + child: Text( + '${(scooter.batteryLevel)}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + + const Spacer(), + + GradientButton( + text: "Подробнеe", + showArrows: true, + height: 32, + width: double.infinity, + fontSize: 12, + onTap: () { + Navigator.pop(context, scooter); + }, + ), + ], + ), + ), + + Positioned( + right: isActive ? -30 : -5, + top: isActive ? -10 : 15, + child: SizedBox( + height: isActive ? 190 : 160, + child: Image.asset( + "assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png", + fit: BoxFit.contain, + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/components/sheet/tariff_info_sheet.dart b/lib/presentation/components/sheet/tariff_info_sheet.dart new file mode 100644 index 0000000..57f3d0e --- /dev/null +++ b/lib/presentation/components/sheet/tariff_info_sheet.dart @@ -0,0 +1,177 @@ +import 'dart:ui'; +import 'package:be_happy/domain/entities/tariff.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; + +class TariffInfoSheet extends StatefulWidget { + final Tariff tariff; + + const TariffInfoSheet({super.key, required this.tariff}); + + @override + State createState() => _TariffInfoSheetState(); +} + +class _TariffInfoSheetState extends State { + bool _isInsuranceEnabled = true; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.9), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + children: [ + // Полоска сверху (Handle) + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + + // Контент со скроллом + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + // Заголовок + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_outlined, color: Color(0xFF66E3C4), size: 18), + const SizedBox(width: 8), + Text( + widget.tariff.title, + style: const TextStyle( + color: Color(0xFF66E3C4), + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 30), + + // Таблица цен + _buildPriceRow('Старт поездки', '${widget.tariff.startPrice} BYN'), + _buildPriceRow('Последующая минута', '${widget.tariff.drivePrice} BYN'), + _buildPriceRow('Пауза', '${widget.tariff.pausePrice} BYN/мин'), + _buildPriceRow('КЕШБЭК', '${widget.tariff.cashback * 100}%', isAccent: true), + + const Divider(color: Colors.white24, height: 40), + + // Страховка + Row( + children: [ + const Text( + 'Страховка', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + Image.asset('assets/icons/info_icon.png', width: 18, height: 18), + const Spacer(), + Text( + '${widget.tariff.insurance} BYN', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 12), + Transform.scale( + scale: 0.8, + child: CupertinoSwitch( + value: _isInsuranceEnabled, + activeColor: const Color(0xFF66E3C4), + onChanged: (val) => setState(() => _isInsuranceEnabled = val), + ), + ), + ], + ), + + const Divider(color: Colors.white24, height: 40), + + // Список правил (Bullet points) + _buildInfoBullet('Оплата страховки осуществляется только по банковской карте отдельным платежом'), + _buildInfoBullet('В режиме паузы время тарифа приостанавливается'), + _buildInfoBullet('При старте заказа будет заблокирована сумма в размере 7 рублей для проверки платежеспособности. Сумма разблокируется по факту списания средств за поездку.'), + + const SizedBox(height: 30), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildPriceRow(String label, String value, {bool isAccent = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 15), + ), + Text( + value, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: isAccent ? FontWeight.bold : FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildInfoBullet(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 6), + child: Icon(Icons.circle, size: 4, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 13, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/sheet/tariff_sheet.dart b/lib/presentation/components/sheet/tariff_sheet.dart new file mode 100644 index 0000000..c3ba696 --- /dev/null +++ b/lib/presentation/components/sheet/tariff_sheet.dart @@ -0,0 +1,551 @@ +import 'dart:ui'; +import 'package:be_happy/presentation/components/payment_option.dart'; +import 'package:be_happy/presentation/components/sheet/payment_method_sheet.dart'; +import 'package:be_happy/presentation/components/sheet/tariff_info_sheet.dart'; +import 'package:be_happy/presentation/viewmodel/payment_method_sheet_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../di/service_locator.dart'; +import '../../../domain/entities/payment_card.dart'; +import '../../../domain/entities/scooter.dart'; +import '../../../domain/entities/tariff.dart'; +import '../../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../event/payment_method_sheet_event.dart'; +import '../../event/tariff_sheet_event.dart'; +import '../../state/tariff_sheet_state.dart'; +import '../../viewmodel/tariff_sheet_bloc.dart'; +import '../gradient_button.dart'; +import '../scooter/mini_battery_indicator.dart'; + +class TariffSheet extends StatefulWidget { + final Scooter scooter; + + const TariffSheet({super.key, required this.scooter}); + + @override + State createState() => _TariffSheetState(); +} + +class _TariffSheetState extends State { + int? _selectedTariffIndex; + bool _hasPaymentCard = true; + + @override + void initState() { + super.initState(); + context.read().add(TariffSheetStarted(widget.scooter.id)); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.status == TariffSheetStatus.loading) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 520, + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ), + ); + } + + if (state.status == TariffSheetStatus.failure) { + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 520, + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: Center( + child: Text( + state.errorMessage ?? 'Ошибка загрузки тарифов', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ), + ); + } + + return Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + height: 520, + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.88), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 🔹 HEADER + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Самокат ${widget.scooter.number}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + SizedBox( + height: 80, + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + MiniBatteryIndicator( + percent: widget.scooter.batteryLevel, + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Center( + child: Text( + '${widget.scooter.batteryLevel.toInt()}%', // ✅ Цифры + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Заряда хватит на 4 часа 17 минут\nили 47 км', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + SizedBox( + height: 150, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 20), + itemCount: state.tariffs.length, + itemBuilder: (context, index) { + final tariff = state.tariffs[index]; + return Row( + children: [ + _TariffCard( + title: tariff.title, + price: tariff.startPrice.toStringAsFixed(2), + currency: tariff.currency, + subtitle: 'Старт поездки', + details: [ + 'Далее ${tariff.drivePrice.toStringAsFixed(2)} ${tariff.currency}/мин.', + 'Минута на паузе ${tariff.pausePrice.toStringAsFixed(0)} ${tariff.currency}', + ], + isSelected: _selectedTariffIndex == index, + tariff: tariff, + onTap: () { + setState(() { + _selectedTariffIndex = index; + }); + }, + ), + if (index < state.tariffs.length - 1) + const SizedBox(width: 12), + ], + ); + }, + ), + ), + + const SizedBox(height: 16), + + if (state.useBalance || state.selectedCard != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: PaymentOption( + title: state.useBalance + ? 'Баланс' + : state.selectedCard!.type, + subtitle: state.useBalance + ? '${state.userBalance.toStringAsFixed(2)} BYN' + : '****${state.selectedCard!.cardLastNumber}', + isSelected: true, + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + )..add(PaymentMethodSheetStarted()), + child: PaymentMethodSheet( + initialSelectedCard: state.useBalance ? null : state.selectedCard, + ), + ), + ); + + if (result != null && mounted) { + if (result is PaymentCard) { + context.read().add( + PaymentCardChanged(result), + ); + } else if (result == 'balance') { + context.read().add( + SelectBalancePressed(), + ); + } + } + }, + ), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.pushReplacement( + '/home/payment-method-sheet', + ); + }, + borderRadius: BorderRadius.circular(24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Способ оплаты', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.6), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.4), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.2), + ), + ], + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 12), + + // 🔹 КНОПКА "ЗАБРОНИРОВАТЬ" + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GradientButton( + text: 'Забронировать', + showArrows: true, + height: 56, + width: double.infinity, + fontSize: 16, + enabled: _selectedTariffIndex != null && (state.selectedCard != null || state.useBalance), + onTap: (_selectedTariffIndex != null && (state.selectedCard != null || state.useBalance)) + ? () { + context.read().add( + BookScooterPressed( + widget.scooter.id, + state.tariffs[_selectedTariffIndex!].id, + 0, + state.useBalance ? null : state.selectedCard?.id, + state.useBalance, + false + ) + ); + context.pushReplacement('/home/current-rides-sheet'); + } + : null, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + String _getCardType(String lastNumber) { + if (lastNumber.isEmpty) return 'Card'; + final firstDigit = lastNumber[0]; + switch (firstDigit) { + case '4': + return 'Visa'; + case '5': + return 'Mastercard'; + case '9': + return 'BelCard'; + default: + return 'Card'; + } + } +} + +class _TariffCard extends StatelessWidget { + final String title; + final Tariff tariff; + final String price; + final String currency; + final String subtitle; + final List details; + final bool isSelected; + final VoidCallback onTap; + + const _TariffCard({ + required this.title, + required this.tariff, + required this.price, + required this.currency, + required this.subtitle, + required this.details, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 220, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF1A1F3E) + : Colors.white.withOpacity(0.19), + borderRadius: BorderRadius.circular(20), + ), + // Используем Stack, чтобы наложить кнопку поверх контента + child: Stack( + children: [ + // Основной контент карточки + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Заголовок с иконкой часов + Row( + children: [ + const Icon( + Icons.timer_outlined, + size: 16, + color: Color(0xFF66E3C4), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + color: Color(0xFF66E3C4), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Резервируем место под иконку инфо справа, + // чтобы текст не залез под неё + const SizedBox(width: 24), + ], + ), + const SizedBox(height: 12), + // Цена + текст рядом + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$price $currency', + style: const TextStyle(color: Colors.white, fontSize: 24), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(width: 6), + Expanded( + child: Text( + subtitle, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 12, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 12), + // Детали + ...details.map( + (detail) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + detail, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 12, + ), + ), + ), + ), + ], + ), + + // Кнопка-иконка в верхнем правом углу + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => TariffInfoSheet(tariff: tariff), + ); + print('Info pressed for $title'); + }, + child: Image.asset( + 'assets/icons/info_icon.png', + width: 20, + height: 20, + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/components/side_menu.dart b/lib/presentation/components/side_menu.dart new file mode 100644 index 0000000..3e5c668 --- /dev/null +++ b/lib/presentation/components/side_menu.dart @@ -0,0 +1,187 @@ +import 'package:be_happy/presentation/viewmodel/map_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../event/map_event.dart'; + +class SideMenu extends StatelessWidget { + + const SideMenu({super.key,}); + + @override + Widget build(BuildContext context) { + + final state = context.watch().state; + + return Drawer( + child: Container( + decoration: BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Заголовок + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Добро пожаловать!', + style: TextStyle( + color: AppColors.smsDigit, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + state.phoneNumber ?? "Загрузка...", + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + 'Баланс ${state.balance} руб.', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ), + + const Divider(color: Colors.white30, height: 1), + + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + // Пункты меню + Column( + children: [ + _MenuItem( + icon: 'assets/icons/person_icon.png', + title: 'Профиль', + onTap: () => { + context.go('/home/profile'), + }, + ), + _MenuItem( + icon: 'assets/icons/list_star_icon.png', + title: 'История поездок', + onTap: () => context.go("/home/order-history"), + ), + const Divider(color: Colors.white30, height: 1), + _MenuItem( + icon: 'assets/icons/creditcard_icon.png', + title: 'Способ оплаты', + onTap: () => context.go("/home/payment-methods"), + ), + _MenuItem( + icon: 'assets/icons/promo_icon.png', + title: 'Промокоды', + onTap: () => context.go("/home/promo"), + ), + _MenuItem( + icon: 'assets/icons/plans_icon.png', + title: 'Абонементы', + onTap: () => context.push("/home/subscriptions"), + ), + const Divider(color: Colors.white30, height: 1), + _MenuItem( + icon: 'assets/icons/magazine_icon.png', + title: 'Правила пользования самокатом', + onTap: () => context.go("/home/rules") + ), + _MenuItem( + icon: 'assets/icons/headphones_icon.png', + title: 'Техподдержка', + onTap: () => { + context.go('/home/support'), + }, + ), + _MenuItem( + icon: 'assets/icons/doc_icon.png', + title: 'Документы', + onTap: () => context.go("/home/documents"), + ), + _MenuItem( + icon: 'assets/icons/news_icon.png', + title: 'Новости', + onTap: () => context.go("/home/news"), + ), + const Divider(color: Colors.white30, height: 1), + _MenuItem( + icon: 'assets/icons/logout_icon.png', + title: 'Выход', + onTap: () => { + context.go('/login'), + context.read().stopNotificationStream(), + context.read().add(LogoutPressed()), + }, + ), + ], + ), + + // Картинка внизу (внутри скролла) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Image.asset( + 'assets/wave.png', + width: double.infinity, + fit: BoxFit.fitWidth, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ) + ); + } +} + +class _MenuItem extends StatelessWidget { + final String icon; + final String title; + final Color? color; + final VoidCallback onTap; + + const _MenuItem({ + required this.icon, + required this.title, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Image.asset( + icon, + width: 24, + height: 24 + ), + title: Text( + title, + style: TextStyle( + color: color ?? Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/subscription_card.dart b/lib/presentation/components/subscription_card.dart new file mode 100644 index 0000000..1c086ed --- /dev/null +++ b/lib/presentation/components/subscription_card.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/entities/subscription.dart'; + +class SubscriptionCard extends StatelessWidget { + final Subscription subscription; + final bool isActive; + + const SubscriptionCard({super.key, required this.subscription, required this.isActive}); + + @override + Widget build(BuildContext context) { + final minPriceOption = subscription.options.isNotEmpty + ? subscription.options.reduce((a, b) => a.price < b.price ? a : b) + : null; + + final maxDaysOption = subscription.options.isNotEmpty + ? subscription.options.reduce((a, b) => a.days > b.days ? a : b) + : null; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF0D143C), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isActive ? MainAxisAlignment.spaceBetween : MainAxisAlignment.start, + children: [ + Text( + subscription.title, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + if (isActive) + Container( + width: 100, + alignment: Alignment.center, + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), // Опционально: скругление углов + ), + child: Text( + "АКТИВНА", + style: TextStyle( + color: Colors.greenAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + + ] + ), + + const SizedBox(height: 12), + Text( + subscription.shortDescription, + style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14), + ), + const SizedBox(height: 16), + if (maxDaysOption != null) ...[ + const SizedBox(height: 16), + Text( + "Период действия: до ${maxDaysOption.days} дней", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (minPriceOption != null) + Text( + "от ${minPriceOption.pricePrint}", + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ) + else + const SizedBox.shrink(), + ElevatedButton( + onPressed: () => context.push("/home/subscriptions/${subscription.id}"), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF80FFD1), + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: const Text("Подробнее", style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/unpaid_order_notification_card.dart b/lib/presentation/components/unpaid_order_notification_card.dart new file mode 100644 index 0000000..e62deb3 --- /dev/null +++ b/lib/presentation/components/unpaid_order_notification_card.dart @@ -0,0 +1,90 @@ +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class UnpaidOrderNotificationCard extends StatelessWidget { + final VoidCallback onClose; + + const UnpaidOrderNotificationCard({ + super.key, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(24, 10, 20, 24), + decoration: BoxDecoration( + color: const Color(0xFF2D2B4D), + borderRadius: BorderRadius.circular(32), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.only(left: 64), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'У вас имеются неоплаченные поездки!', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 12), + Text( + 'Оплатите поездки, чтобы начать новую поездку', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.2, + ), + ), + ], + ), + ), + ], + ), + ), + + Positioned( + top: -20, + left: -30, + child: Image.asset( + 'assets/icons/card-screen.png', + width: 120, + height: 120, + fit: BoxFit.contain, + ), + ), + + Positioned( + top: 15, + right: 15, + child: GestureDetector( + onTap: onClose, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white70, size: 20), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/components/utils/card_formatter.dart b/lib/presentation/components/utils/card_formatter.dart new file mode 100644 index 0000000..bbb256d --- /dev/null +++ b/lib/presentation/components/utils/card_formatter.dart @@ -0,0 +1,49 @@ +import 'package:flutter/services.dart'; + +class CardNumberFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var text = newValue.text.replaceAll(' ', ''); + var buffer = StringBuffer(); + for (int i = 0; i < text.length; i++) { + buffer.write(text[i]); + var nonZeroIndex = i + 1; + if (nonZeroIndex % 4 == 0 && nonZeroIndex != text.length) { + buffer.write(' '); // Добавляем пробел каждые 4 цифры + } + } + var string = buffer.toString(); + return newValue.copyWith( + text: string, + selection: TextSelection.collapsed(offset: string.length), + ); + } +} + +class CardMonthInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var newText = newValue.text; + if (newValue.selection.baseOffset == 0) return newValue; + + var buffer = StringBuffer(); + for (int i = 0; i < newText.length; i++) { + buffer.write(newText[i]); + var nonZeroIndex = i + 1; + if (nonZeroIndex % 2 == 0 && nonZeroIndex != newText.length) { + buffer.write('/'); // Добавляем слэш после 2-й цифры + } + } + var string = buffer.toString(); + return newValue.copyWith( + text: string, + selection: TextSelection.collapsed(offset: string.length), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/event/active_ride_event.dart b/lib/presentation/event/active_ride_event.dart new file mode 100644 index 0000000..5971acd --- /dev/null +++ b/lib/presentation/event/active_ride_event.dart @@ -0,0 +1,31 @@ +sealed class ActiveRideEvent {} + +class LoadScooterOrder extends ActiveRideEvent { + final int orderId; + + LoadScooterOrder(this.orderId); +} + +class PauseRide extends ActiveRideEvent { + final int orderId; + + PauseRide(this.orderId); +} + +class ResumeRide extends ActiveRideEvent { + final int orderId; + + ResumeRide(this.orderId); +} + +class FinishRide extends ActiveRideEvent { + final int orderId; + + FinishRide(this.orderId); +} + +class SyncScooterOrder extends ActiveRideEvent { + final int orderId; + + SyncScooterOrder(this.orderId); +} diff --git a/lib/presentation/event/add_card_event.dart b/lib/presentation/event/add_card_event.dart new file mode 100644 index 0000000..6ed3170 --- /dev/null +++ b/lib/presentation/event/add_card_event.dart @@ -0,0 +1,27 @@ +abstract class AddCardEvent {} + +class AddCardSubmitted extends AddCardEvent {} + +class CardNumberChanged extends AddCardEvent { + final String cardNumber; + + CardNumberChanged(this.cardNumber); +} + +class ExpiryDateChanged extends AddCardEvent { + final String expiryDate; + + ExpiryDateChanged(this.expiryDate); +} + +class CvvChanged extends AddCardEvent { + final String cvv; + + CvvChanged(this.cvv); +} + +class CardHolderChanged extends AddCardEvent { + final String cardHolder; + + CardHolderChanged(this.cardHolder); +} \ No newline at end of file diff --git a/lib/presentation/event/auth_event.dart b/lib/presentation/event/auth_event.dart new file mode 100644 index 0000000..83ba6fd --- /dev/null +++ b/lib/presentation/event/auth_event.dart @@ -0,0 +1,23 @@ +abstract class PhoneAuthEvent {} + +class PhoneAuthStarted extends PhoneAuthEvent {} + +class PhoneChanged extends PhoneAuthEvent { + final String phone; + + PhoneChanged(this.phone); +} + +class IsAdultChanged extends PhoneAuthEvent { + final bool isAdult; + + IsAdultChanged(this.isAdult); +} + +class PrivacyAcceptedChanged extends PhoneAuthEvent { + final bool accepted; + + PrivacyAcceptedChanged(this.accepted); +} + +class SubmitPhonePressed extends PhoneAuthEvent {} \ No newline at end of file diff --git a/lib/presentation/event/current_rides_event.dart b/lib/presentation/event/current_rides_event.dart new file mode 100644 index 0000000..60bb5ac --- /dev/null +++ b/lib/presentation/event/current_rides_event.dart @@ -0,0 +1,3 @@ +sealed class CurrentRidesEvent {} + +class LoadClientOrders extends CurrentRidesEvent {} diff --git a/lib/presentation/event/edit_profile_event.dart b/lib/presentation/event/edit_profile_event.dart new file mode 100644 index 0000000..66ee0dc --- /dev/null +++ b/lib/presentation/event/edit_profile_event.dart @@ -0,0 +1,13 @@ +import '../../domain/entities/user_profile.dart'; + +abstract class EditProfileEvent {} + +class EditProfileStarted extends EditProfileEvent {} + + +class EditProfileSubmitted extends EditProfileEvent { + final UserProfile profile; + + EditProfileSubmitted(this.profile); +} + diff --git a/lib/presentation/event/map_event.dart b/lib/presentation/event/map_event.dart new file mode 100644 index 0000000..1d4c21e --- /dev/null +++ b/lib/presentation/event/map_event.dart @@ -0,0 +1,33 @@ +import '../../domain/entities/client_notification.dart'; +import '../../domain/entities/point.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/zone.dart'; + +abstract class ScooterEvent {} + +class CheckUser extends ScooterEvent {} + +class FetchScooters extends ScooterEvent { + final List area; + final List areaScooters; + FetchScooters(this.area, this.areaScooters); +} + +class UpdateMap extends ScooterEvent {} + +class FetchProfileData extends ScooterEvent {} + +class LogoutPressed extends ScooterEvent {} + +class UpdateUserLocation extends ScooterEvent { + final double latitude; + final double longitude; + UpdateUserLocation(this.latitude, this.longitude); +} + +class NotificationReceived extends ScooterEvent { + final ClientNotification notification; + NotificationReceived(this.notification); +} + + diff --git a/lib/presentation/event/map_settings_modal_event.dart b/lib/presentation/event/map_settings_modal_event.dart new file mode 100644 index 0000000..8107f1b --- /dev/null +++ b/lib/presentation/event/map_settings_modal_event.dart @@ -0,0 +1,33 @@ +sealed class MapSettingsModalEvent {} + +class AllGeomarksToggled extends MapSettingsModalEvent { + final bool value; + AllGeomarksToggled(this.value); +} + +class AllGeozonesToggled extends MapSettingsModalEvent { + final bool value; + AllGeozonesToggled(this.value); +} + +class RestrictedDrivingZonesToggled extends MapSettingsModalEvent { + final bool value; + RestrictedDrivingZonesToggled(this.value); +} + +class RestrictedParkingZonesToggled extends MapSettingsModalEvent { + final bool value; + RestrictedParkingZonesToggled(this.value); +} + +class ParkingZonesToggled extends MapSettingsModalEvent { + final bool value; + ParkingZonesToggled(this.value); +} + +class ApllyButtonClick extends MapSettingsModalEvent {} + +class MapSettingsModalStarted extends MapSettingsModalEvent {} + + + diff --git a/lib/presentation/event/news_event.dart b/lib/presentation/event/news_event.dart new file mode 100644 index 0000000..408618d --- /dev/null +++ b/lib/presentation/event/news_event.dart @@ -0,0 +1,7 @@ +abstract class NewsEvent { + const NewsEvent(); +} + +class NewsFetchRequested extends NewsEvent { + const NewsFetchRequested(); +} \ No newline at end of file diff --git a/lib/presentation/event/payment_confirm_event.dart b/lib/presentation/event/payment_confirm_event.dart new file mode 100644 index 0000000..8ae04f5 --- /dev/null +++ b/lib/presentation/event/payment_confirm_event.dart @@ -0,0 +1,29 @@ +import '../../domain/entities/payment_card.dart'; + +sealed class PaymentConfirmEvent {} + +class PaymentConfirmStarted extends PaymentConfirmEvent { + final int orderId; + PaymentConfirmStarted(this.orderId); +} + +class PaymentCardChanged extends PaymentConfirmEvent { + final PaymentCard card; + PaymentCardChanged(this.card); +} + +class SelectBalancePressed extends PaymentConfirmEvent {} + +class PayRide extends PaymentConfirmEvent { + final int orderId; + final int? cardId; + final bool isBalance; + final List photoIds; + + PayRide({ + required this.orderId, + required this.cardId, + required this.isBalance, + required this.photoIds, + }); +} diff --git a/lib/presentation/event/payment_method_sheet_event.dart b/lib/presentation/event/payment_method_sheet_event.dart new file mode 100644 index 0000000..b3fdd3c --- /dev/null +++ b/lib/presentation/event/payment_method_sheet_event.dart @@ -0,0 +1,3 @@ +sealed class PaymentMethodSheetEvent {} + +class PaymentMethodSheetStarted extends PaymentMethodSheetEvent {} diff --git a/lib/presentation/event/payment_methods_event.dart b/lib/presentation/event/payment_methods_event.dart new file mode 100644 index 0000000..53ed38d --- /dev/null +++ b/lib/presentation/event/payment_methods_event.dart @@ -0,0 +1,15 @@ +sealed class PaymentMethodsEvent {} + +class PaymentMethodsStarted extends PaymentMethodsEvent {} + +class PaymentMethodsDeleteCard extends PaymentMethodsEvent { + final int cardId; + + PaymentMethodsDeleteCard(this.cardId); +} + +class PaymentMethodsSetMainCard extends PaymentMethodsEvent { + final int cardId; + + PaymentMethodsSetMainCard(this.cardId); +} diff --git a/lib/presentation/event/pin_event.dart b/lib/presentation/event/pin_event.dart new file mode 100644 index 0000000..3d61108 --- /dev/null +++ b/lib/presentation/event/pin_event.dart @@ -0,0 +1,15 @@ +abstract class PinEvent {} + +class PinScreenStarted extends PinEvent {} + +class PinDigitChanged extends PinEvent { + final String pin; + + PinDigitChanged(this.pin); +} + +class PinSubmitted extends PinEvent { + final String pin; + + PinSubmitted(this.pin); +} \ No newline at end of file diff --git a/lib/presentation/event/profile_event.dart b/lib/presentation/event/profile_event.dart new file mode 100644 index 0000000..e6a993b --- /dev/null +++ b/lib/presentation/event/profile_event.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import '../../domain/entities/user_profile.dart'; + +abstract class ProfileEvent {} + +class ProfileStarted extends ProfileEvent {} + +class ProfileUpdated extends ProfileEvent {} + +class ProfilePhotoUpdated extends ProfileEvent{ + final File imageFile; + + ProfilePhotoUpdated(this.imageFile); +} diff --git a/lib/presentation/event/reserved_ride_event.dart b/lib/presentation/event/reserved_ride_event.dart new file mode 100644 index 0000000..fef4911 --- /dev/null +++ b/lib/presentation/event/reserved_ride_event.dart @@ -0,0 +1,13 @@ +sealed class ReservedRideEvent {} + +class StartRide extends ReservedRideEvent { + final int orderId; + + StartRide(this.orderId); +} + +class CancelRide extends ReservedRideEvent { + final int orderId; + + CancelRide(this.orderId); +} diff --git a/lib/presentation/event/route_event.dart b/lib/presentation/event/route_event.dart new file mode 100644 index 0000000..160f7c1 --- /dev/null +++ b/lib/presentation/event/route_event.dart @@ -0,0 +1,7 @@ +abstract class RouteEvent {} + +class FetchRouteEvent extends RouteEvent { + final int orderId; + FetchRouteEvent(this.orderId); +} + diff --git a/lib/presentation/event/scooter_code_event.dart b/lib/presentation/event/scooter_code_event.dart new file mode 100644 index 0000000..b9c4cd7 --- /dev/null +++ b/lib/presentation/event/scooter_code_event.dart @@ -0,0 +1,11 @@ +abstract class ScooterCodeEvent {} + +class ScooterCodeChanged extends ScooterCodeEvent { + final String code; + ScooterCodeChanged(this.code); +} + +class ScooterCodeSubmitted extends ScooterCodeEvent { + final String code; + ScooterCodeSubmitted(this.code); +} \ No newline at end of file diff --git a/lib/presentation/event/scooter_detail_event.dart b/lib/presentation/event/scooter_detail_event.dart new file mode 100644 index 0000000..7df0e5a --- /dev/null +++ b/lib/presentation/event/scooter_detail_event.dart @@ -0,0 +1,6 @@ +sealed class ScooterDetailEvent {} + +class LoadScooterDetails extends ScooterDetailEvent { + final int scooterId; + LoadScooterDetails(this.scooterId); +} diff --git a/lib/presentation/event/scooter_detail_modal_event.dart b/lib/presentation/event/scooter_detail_modal_event.dart new file mode 100644 index 0000000..915f2ba --- /dev/null +++ b/lib/presentation/event/scooter_detail_modal_event.dart @@ -0,0 +1,13 @@ +import '../../domain/entities/scooter.dart'; + +sealed class ScooterDetailModalEvent {} + +class ScooterDetailModalStarted extends ScooterDetailModalEvent { + final List scooters; + final double userLatitude; + final double userLongitude; + + ScooterDetailModalStarted(this.scooters, this.userLatitude, this.userLongitude); +} + + diff --git a/lib/presentation/event/send_photo_event.dart b/lib/presentation/event/send_photo_event.dart new file mode 100644 index 0000000..e8b7a23 --- /dev/null +++ b/lib/presentation/event/send_photo_event.dart @@ -0,0 +1,13 @@ +abstract class SendPhotoEvent {} + +class PhotoSelected extends SendPhotoEvent { + final List imagePaths; + + PhotoSelected(this.imagePaths); +} + +class PhotoUploadSubmitted extends SendPhotoEvent { + final int orderId; + + PhotoUploadSubmitted(this.orderId); +} diff --git a/lib/presentation/event/spalsh_event.dart b/lib/presentation/event/spalsh_event.dart new file mode 100644 index 0000000..8e9abca --- /dev/null +++ b/lib/presentation/event/spalsh_event.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; + +abstract class SplashEvent extends Equatable { + const SplashEvent(); + + @override + List get props => []; +} + +// Событие, которое будет отправляться со SplashScreen +class AuthCheckRequested extends SplashEvent {} + +class AuthStarted extends SplashEvent {} + +class PinVerificationSuccess extends SplashEvent {} diff --git a/lib/presentation/event/subscription_details_event.dart b/lib/presentation/event/subscription_details_event.dart new file mode 100644 index 0000000..485deb6 --- /dev/null +++ b/lib/presentation/event/subscription_details_event.dart @@ -0,0 +1,20 @@ +import '../../domain/entities/subscription_period.dart'; + +abstract class SubscriptionDetailsEvent {} + +class LoadDetailsEvent extends SubscriptionDetailsEvent { + final int subscriptionId; + LoadDetailsEvent(this.subscriptionId); +} + +class SelectPeriodEvent extends SubscriptionDetailsEvent { + final SubscriptionPeriod period; + SelectPeriodEvent(this.period); +} + +class ToggleAgreementEvent extends SubscriptionDetailsEvent { + final bool value; + ToggleAgreementEvent(this.value); +} + +class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {} \ No newline at end of file diff --git a/lib/presentation/event/subscription_list_event.dart b/lib/presentation/event/subscription_list_event.dart new file mode 100644 index 0000000..ba862c2 --- /dev/null +++ b/lib/presentation/event/subscription_list_event.dart @@ -0,0 +1,5 @@ +// subscription_event.dart +abstract class SubscriptionEvent {} + +class LoadSubscriptionsEvent extends SubscriptionEvent {} + diff --git a/lib/presentation/event/tariff_sheet_event.dart b/lib/presentation/event/tariff_sheet_event.dart new file mode 100644 index 0000000..a66cc8a --- /dev/null +++ b/lib/presentation/event/tariff_sheet_event.dart @@ -0,0 +1,31 @@ +import 'package:be_happy/domain/entities/payment_card.dart'; + +sealed class TariffSheetEvent {} + +class TariffSheetStarted extends TariffSheetEvent { + final int scooterId; + + TariffSheetStarted(this.scooterId); +} + +class PaymentCardChanged extends TariffSheetEvent { + final PaymentCard card; + + PaymentCardChanged(this.card); +} + +class SelectBalancePressed extends TariffSheetEvent {} + +class BookScooterPressed extends TariffSheetEvent { + final int scooterId; + final int planId; + final int? subscriptionId; + final int? cardId; + final bool isBalance; + final bool isInsurance; + + BookScooterPressed(this.scooterId, this.planId, this.subscriptionId, + this.cardId, this.isBalance, this.isInsurance); + + +} diff --git a/lib/presentation/event/top_up_event.dart b/lib/presentation/event/top_up_event.dart new file mode 100644 index 0000000..38ac3c2 --- /dev/null +++ b/lib/presentation/event/top_up_event.dart @@ -0,0 +1,23 @@ +import 'package:be_happy/domain/entities/certificate.dart'; +import 'package:be_happy/domain/entities/payment_card.dart'; + +import '../../domain/entities/top_up_tariff.dart'; + +abstract class TopUpEvent {} + +class LoadTopUpData extends TopUpEvent {} + +class SelectCertificate extends TopUpEvent { + final Certificate certificate; + SelectCertificate(this.certificate); +} + +class SelectCard extends TopUpEvent { + final PaymentCard card; + SelectCard(this.card); +} + +class ToggleAgreement extends TopUpEvent { + final bool value; + ToggleAgreement(this.value); +} \ No newline at end of file diff --git a/lib/presentation/event/verify_code_event.dart b/lib/presentation/event/verify_code_event.dart new file mode 100644 index 0000000..b7a54a7 --- /dev/null +++ b/lib/presentation/event/verify_code_event.dart @@ -0,0 +1,18 @@ +abstract class VerifyCodeEvent {} + +class VerifyCodeStarted extends VerifyCodeEvent { + final String phoneNumber; + final String tempToken; + + VerifyCodeStarted({required this.phoneNumber, required this.tempToken}); +} + +class CodeChanged extends VerifyCodeEvent { + final String code; + + CodeChanged(this.code); +} + +class ResendCodePressed extends VerifyCodeEvent {} + +class VerifyCodeSubmitted extends VerifyCodeEvent {} \ No newline at end of file diff --git a/lib/presentation/navigation/app_router.dart b/lib/presentation/navigation/app_router.dart new file mode 100644 index 0000000..e1ed39c --- /dev/null +++ b/lib/presentation/navigation/app_router.dart @@ -0,0 +1,561 @@ +import 'dart:async'; + +import 'package:bot_toast/bot_toast.dart'; +import 'package:be_happy/core/app_colors.dart'; +import 'package:be_happy/di/service_locator.dart'; +import 'package:be_happy/domain/entities/user_profile.dart'; +import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart'; +import 'package:be_happy/domain/usecase/get_certificates_usecase.dart'; +import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart'; +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart'; +import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; +import 'package:be_happy/domain/usecase/is_pin_set_usecase.dart'; +import 'package:be_happy/domain/usecase/purchase_certificate_usecase.dart'; +import 'package:be_happy/presentation/event/payment_confirm_event.dart'; +import 'package:be_happy/presentation/event/pin_event.dart'; +import 'package:be_happy/presentation/event/subscription_list_event.dart'; +import 'package:be_happy/presentation/screens/block_screen.dart'; +import 'package:be_happy/presentation/screens/documents_screen.dart'; +import 'package:be_happy/presentation/screens/edit_profile_screen.dart'; +import 'package:be_happy/presentation/screens/map_screen.dart'; +import 'package:be_happy/presentation/screens/news_screen.dart'; +import 'package:be_happy/presentation/screens/onboarding_screen.dart'; +import 'package:be_happy/presentation/screens/order_history_detail_screen.dart'; +import 'package:be_happy/presentation/screens/payment_confirm_screen.dart'; +import 'package:be_happy/presentation/screens/profile_screen.dart'; +import 'package:be_happy/presentation/screens/promo_code_screen.dart'; +import 'package:be_happy/presentation/screens/qr_scan_info_screen.dart'; +import 'package:be_happy/presentation/screens/scooter_code_input_screen.dart'; +import 'package:be_happy/presentation/screens/scooter_detail_screen.dart'; +import 'package:be_happy/presentation/screens/send_photo_screen.dart'; +import 'package:be_happy/presentation/screens/subscription_list_screen.dart'; +import 'package:be_happy/presentation/screens/support_screen.dart'; +import 'package:be_happy/presentation/screens/top_up_screen.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/susbcription_details_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/top_up_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:go_router/go_router.dart'; +import 'package:path/path.dart'; + +import '../../domain/entities/scooter.dart'; +import '../../domain/usecase/activate_subscription_usecase.dart'; +import '../../domain/usecase/book_scooter_usecase.dart'; +import '../../domain/usecase/create_pin_usecase.dart'; +import '../../domain/usecase/get_address_by_point_usecase.dart'; +import '../../domain/usecase/get_available_tariffs_usecase.dart'; +import '../../domain/usecase/get_client_orders_usecase.dart'; +import '../../domain/usecase/get_map_settings_usecase.dart'; +import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/get_pedestrian_routes_usecase.dart'; +import '../../domain/usecase/get_scooter_order_by_id_usecase.dart'; +import '../../domain/usecase/get_scooter_usecase.dart'; +import '../../domain/usecase/pay_ride_usecase.dart'; +import '../../domain/usecase/remove_payment_card_usecase.dart'; +import '../../domain/usecase/save_map_settings_usecase.dart'; +import '../../domain/usecase/set_main_payment_card_usecase.dart'; +import '../../domain/usecase/verify_pin_usecase.dart'; +import '../components/map_settings_sheet.dart'; +import '../components/scooter_bottom_sheet.dart'; +import '../components/sheet/current_rides_sheet.dart'; +import '../components/sheet/payment_method_sheet.dart'; +import '../components/sheet/reserved_ride_sheet.dart'; +import '../components/sheet/tariff_sheet.dart'; +import '../event/current_rides_event.dart'; +import '../event/edit_profile_event.dart'; +import '../event/map_settings_modal_event.dart'; +import '../event/payment_methods_event.dart'; +import '../event/profile_event.dart'; +import '../event/scooter_detail_modal_event.dart'; +import '../event/subscription_details_event.dart'; +import '../event/tariff_sheet_event.dart'; +import '../event/top_up_event.dart'; +import '../screens/add_card_screen.dart'; // ← новый импорт +import '../screens/license_agreement_screen.dart'; +import '../screens/order_history_screen.dart'; +import '../screens/payment_methods_screen.dart'; +import '../screens/phone_login_screen.dart'; +import '../screens/phone_screen.dart'; +import '../screens/pin_login_screen.dart'; +import '../screens/privacy_policy_screen.dart'; +import '../screens/qr_scan_screen.dart'; +import '../screens/splash_screen.dart'; +import '../screens/subscription_details_screen.dart'; +import '../state/splash_state.dart'; +import '../viewmodel/add_card_bloc.dart'; +import '../viewmodel/current_rides_bloc.dart'; +import '../viewmodel/edit_profile_bloc.dart'; +import '../viewmodel/map_settings_modal_bloc.dart'; +import '../viewmodel/payment_confirm_bloc.dart'; +import '../viewmodel/payment_method_sheet_bloc.dart'; +import '../viewmodel/payment_methods_bloc.dart'; +import '../viewmodel/pin_bloc.dart'; +import '../viewmodel/profile_bloc.dart'; +import '../viewmodel/scooter_code_bloc.dart'; +import '../viewmodel/scooter_detail_bloc.dart'; +import '../viewmodel/scooter_detail_modal_bloc.dart'; +import '../viewmodel/subscription_list_bloc.dart'; +import '../viewmodel/tariff_sheet_bloc.dart'; // ← новый импорт + +class AppRouter { + final SplashBloc splashBloc; + + AppRouter(this.splashBloc); + + late final GoRouter router = GoRouter( + debugLogDiagnostics: true, + initialLocation: '/splash', + + routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const SplashScreen(), + ), + GoRoute(path: '/login', builder: (context, state) => const PhoneScreen()), + GoRoute( + path: '/verify', + builder: (context, state) { + final phone = state.uri.queryParameters['phone']; + if (phone != null) { + return PhoneLoginScreen(phoneNumber: phone, tempToken: ''); + } + throw Exception("Incorrect phone"); + }, + ), + GoRoute( + path: '/block', + builder: (context, state) => const BlockedScreen(), + ), + GoRoute( + path: '/pin', + builder: (context, state) => + BlocProvider( + create: (context) => + PinBloc( + createPinUseCase: getIt(), + verifyPinUsecase: getIt(), + isPinSetUsecase: getIt(), + )..add(PinScreenStarted()), + child: const PinLoginScreen(), + ) + ), + GoRoute( + path: '/privacy-policy', + builder: (context, state) => const PrivacyPolicyScreen(), + ), + GoRoute( + path: '/license-agreement', + builder: (context, state) => const LicenseAgreementScreen(), + ), + GoRoute( + path: '/home', + builder: (context, state) => const MapScreen(), + routes: [ + //Modal Bottom Sheets + GoRoute( + path: 'scooter-sheet', + pageBuilder: (context, state) { + final data = state.extra as Map; + + final scooters = data['scooters'] as List; + final location = data['currentLocation'] as Position; + + return modalPage( + state: state, + child: Material( + type: MaterialType.transparency, + child: BlocProvider( + create: (context) => + ScooterDetailModalBloc( + getIt(), + getIt(), + getIt(), + ) + ..add( + ScooterDetailModalStarted( + scooters, + location.latitude, + location.longitude, + ), + ), + child: ScooterBottomSheet(), + ), + ), + ); + }, + ), + GoRoute( + path: 'tarif-sheet', + pageBuilder: (context, state) { + final scooter = state.extra as Scooter; + + return modalPage( + state: state, + child: Material( + type: MaterialType.transparency, + child: BlocProvider( + create: (context) => + TariffSheetBloc( + getIt(), + getIt(), + getIt(), + getIt(), + ), + child: TariffSheet(scooter: scooter), + ), + ), + ); + }, + ), + GoRoute( + path: 'current-rides-sheet', + pageBuilder: (context, state) { + return modalPage( + state: state, + child: Material( + type: MaterialType.transparency, + child: BlocProvider( + create: (context) => + CurrentRidesBloc(getIt()) + ..add(LoadClientOrders()), + child: CurrentRidesSheet(), + ), + ), + ); + }, + ), + GoRoute( + path: 'payment-method-sheet', + pageBuilder: (context, state) { + return modalPage( + state: state, + child: Material( + type: MaterialType.transparency, + child: BlocProvider( + create: (context) => + PaymentMethodSheetBloc(getIt()), + child: PaymentMethodSheet(), + ), + ), + ); + }, + ), + + GoRoute( + path: 'map-settings-sheet', + pageBuilder: (context, state) { + return modalPage( + state: state, + child: Material( + type: MaterialType.transparency, + child: BlocProvider( + create: (context) => + MapSettingsModalBloc( + getIt(), + getIt(), + ) + ..add(MapSettingsModalStarted()), + child: MapSettingsSheet(), + ), + ), + ); + }, + ), + //Sub screens + GoRoute( + path: 'profile', + builder: (context, state) => + BlocProvider( + create: (context) => + getIt() + ..add(ProfileStarted()), + child: ProfileScreen(), + ), + routes: [ + GoRoute( + path: 'edit', + builder: (context, state) => + BlocProvider( + create: (context) => + getIt() + ..add(EditProfileStarted()), + child: EditProfileScreen( + profile: UserProfile( + name: '', + birthDate: '', + phone: '', + balance: 23, + email: '', + ), + ), + ), + ), + ], + ), + GoRoute( + path: 'scooter/:id', + builder: (context, state) => + BlocProvider( + create: (context) => getIt(), + child: ScooterDetailScreen(), + ), + ), + GoRoute( + path: 'order-photos/:orderId', + builder: (context, state) { + int orderId = int.parse(state.pathParameters['orderId']!); + return SendPhotoScreen(orderId: orderId); + }, + ), + GoRoute( + path: 'checkout/:orderId', + builder: (context, state) { + int orderId = int.parse(state.pathParameters['orderId']!); + List photoIds = []; + if (state.extra != null) { + photoIds = state.extra as List; + } + + return BlocProvider( + create: (context) => + PaymentConfirmBloc( + getIt(), + getIt(), + getIt(), + getIt(), + ) + ..add(PaymentConfirmStarted(orderId)), + child: PaymentConfirmScreen( + orderId: orderId, + photoIds: photoIds, + ), + ); + }, + ), + GoRoute( + path: 'support', + builder: (context, state) => const SupportScreen(), + ), + GoRoute( + path: 'documents', + builder: (context, state) => const DocumentsScreen(), + ), + GoRoute( + path: 'promo', + builder: (context, state) => const PromoCodeScreen(), + ), + GoRoute( + path: 'subscriptions', + builder: (context, state) { + return BlocProvider( + create: (context) => + SubscriptionListBloc( + getAvailableSubscriptionsUsecase: + getIt(), + getClientSubscriptionsUsecase: + getIt(), + ) + ..add(LoadSubscriptionsEvent()), + child: SubscriptionsListScreen(), + ); + }, + routes: [ + GoRoute( + path: ':id', + builder: (context, state) { + return BlocProvider( + create: (context) => + SubscriptionDetailsBloc( + getIt(), + getIt(), + ) + ..add( + LoadDetailsEvent( + int.parse(state.pathParameters['id']!), + ), + ), + child: SubscriptionDetailsScreen(subscriptionId: 1), + ); + }, + ), + ], + ), + GoRoute( + path: 'rules', + builder: (context, state) => const OnboardingScreen(), + ), + GoRoute( + path: 'news', + builder: (context, state) => const NewsScreen(), + ), + GoRoute( + path: 'qr-info', + builder: (context, state) => const QRScanInfoScreen(), + routes: [ + GoRoute( + path: 'qr-scan', + builder: (context, state) => const QrScanScreen(), + ), + GoRoute( + path: 'qr-input', + builder: (context, state) => BlocProvider( + create: (context) => + ScooterCodeBloc( + getScooterByTitleUsecase: getIt(), + ), + child: ScooterCodeInputScreen(), + ), + ), + ] + ), + GoRoute( + path: 'payment-methods', + builder: (context, state) => + BlocProvider( + create: (context) => + PaymentMethodsBloc( + getIt(), + getIt(), + getIt(), + getIt(), + ) + ..add(PaymentMethodsStarted()), + child: PaymentMethodsScreen(), + ), + routes: [ + GoRoute( + path: 'add-card', + builder: (context, state) => + BlocProvider( + create: (context) => getIt(), + child: const AddCardScreen(), + ), + ), + GoRoute( + path: 'top-up', + builder: (context, state) => + BlocProvider( + create: (context) => + TopUpBloc( + getCertificatesUsecase: getIt(), + purchaseCertificateUsecase: + getIt(), + getUserCards: getIt(), + ) + ..add(LoadTopUpData()), + child: TopUpScreen(), + ), + ), + ], + ), + GoRoute( + path: 'order-history', + builder: (context, state) => const OrderHistoryScreen(), + routes: [] + ), + ], + ), + ], + + observers: [BotToastNavigatorObserver()], + + redirect: (BuildContext context, GoRouterState state) { + final authState = splashBloc.state; + final currentLocation = state.uri.toString(); + + print("inside redirect"); + print(splashBloc.state); + print(state.uri.toString()); + + if (authState is AuthInitial && currentLocation != '/splash') { + print("splash"); + return '/splash'; + } + + if (authState is AuthFirstLaunch && + currentLocation != '/login' && + currentLocation != '/privacy-policy' && + currentLocation != '/license-agreement') { + print("login"); + return '/login'; + } + + if (authState is AuthUnauthenticated && + currentLocation != '/login' && + currentLocation != '/privacy-policy' && + currentLocation != '/license-agreement') { + print("login2"); + return '/login'; + } + + if (authState is AuthAuthenticated) { + final isComingFromStart = + currentLocation == '/splash' || + currentLocation == '/login' || + currentLocation == '/phone'; + + if (isComingFromStart) { + print("redirecting to pin check"); + return '/pin'; + } + } + + if (authState is AuthPinVerified) { + if (currentLocation == '/splash' || + currentLocation == '/login' || + currentLocation == '/pin') { + return '/home'; + } + } + + return null; + }, + + refreshListenable: GoRouterRefreshStream(splashBloc.stream), + ); + + CustomTransitionPage modalPage({ + required GoRouterState state, + required Widget child, + }) { + return CustomTransitionPage( + key: state.pageKey, + opaque: false, + barrierDismissible: true, + barrierColor: Colors.black54, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: animation.drive( + Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + ), + child: child, + ); + }, + child: child, + ); + } +} + +class GoRouterRefreshStream extends ChangeNotifier { + late final StreamSubscription _subscription; + + GoRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen((dynamic _) { + print("Stream updated"); + notifyListeners(); + }); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/lib/presentation/screens/add_card_screen.dart b/lib/presentation/screens/add_card_screen.dart new file mode 100644 index 0000000..01d8dab --- /dev/null +++ b/lib/presentation/screens/add_card_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; +import '../components/card_input_field.dart'; // ← новый импорт +import '../components/utils/card_formatter.dart'; +import '../viewmodel/add_card_bloc.dart'; +import '../event/add_card_event.dart'; +import '../state/add_card_state.dart'; + +class AddCardScreen extends StatelessWidget { + const AddCardScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: BlocListener( + listenWhen: (previous, current) => + previous.status != current.status && + current.status == AddCardStatus.success, + listener: (context, state) { + context.pop(); + }, + child: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + // 🔹 ВЕРХНЯЯ ЧАСТЬ (шапка + форма) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const CustomAppBar(title: 'Добавление карты'), + const SizedBox(height: 24), + + // 🔹 ОСНОВНОЙ КОНТЕЙНЕР С ПОЛЯМИ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + // Номер карты + BlocBuilder( + builder: (context, state) { + return CardInputField( + hintText: '0000 0000 0000 0000', + icon: Icons.credit_card, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(16), + CardNumberFormatter(), + ], + letterSpacing: 2, + onChanged: (value) { + // Очищаем от пробелов перед отправкой в BLoC + final cleanValue = value.replaceAll(' ', ''); + context.read().add(CardNumberChanged(cleanValue)); + }, + ); + }, + ), + + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: BlocBuilder( + builder: (context, state) { + return CardInputField( + hintText: 'MM/YY', + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + CardMonthInputFormatter(), + ], + onChanged: (value) { + final cleanValue = value.replaceAll('/', ''); + context.read().add(ExpiryDateChanged(cleanValue)); + }, + ); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: BlocBuilder< + AddCardBloc, + AddCardState>( + builder: (context, state) { + return CardInputField( + hintText: 'CVV', + obscureText: true, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter + .digitsOnly, + LengthLimitingTextInputFormatter( + 3), + ], + onChanged: (value) { + context.read().add( + CvvChanged(value)); + }, + ); + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + BlocBuilder( + builder: (context, state) { + return CardInputField( + hintText: 'Имя и фамилия на карте', + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization + .words, + onChanged: (value) { + context.read().add( + CardHolderChanged(value)); + }, + ); + }, + ), + + const SizedBox(height: 20), + + // 🔹 КНОПКА "ДОБАВИТЬ КАРТУ" + BlocBuilder( + builder: (context, state) { + return Container( + height: 46, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Colors.white.withOpacity(0.15), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: state.isFormValid + ? () => { + context.read().add( + AddCardSubmitted()), + context.go("/home/payment-methods") + } + : null, + borderRadius: BorderRadius.circular( + 24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment + .center, + children: [ + Icon( + Icons.add, + color: state.isFormValid + ? const Color(0xFF66E3C4) + : Colors.white + .withOpacity(0.3), + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Добавить карту', + style: TextStyle( + color: state.isFormValid + ? Colors.white + : Colors.white + .withOpacity(0.3), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 24), + child: Column( + children: [ + // Текст о безопасности + const Text( + 'Мы не сохраняем данные карты у себя. Оплата происходит ' + 'через сертифицированный провайдер Беларуси bePaid. ' + 'Платежная страница системы bePaid отвечает требованиям ' + 'безопасности передачи данных PCI DSS Level I. ' + 'Все конфиденциальные данные хранятся в зашифрованном виде.', + style: TextStyle( + color: Colors.white38, + fontSize: 11, + height: 1.5, + ), + textAlign: TextAlign.justify, + ), + + const SizedBox(height: 24), + + // Логотипы платёжных систем + + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ), + ) + ); + } +} diff --git a/lib/presentation/screens/block_screen.dart b/lib/presentation/screens/block_screen.dart new file mode 100644 index 0000000..a9087db --- /dev/null +++ b/lib/presentation/screens/block_screen.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../components/gradient_button.dart'; +import '../../core/app_colors.dart'; + +class BlockedScreen extends StatelessWidget { + const BlockedScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Container( + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Аккаунт заблокирован", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 22), + ), + + const SizedBox(height: 40), + Image.asset( + 'assets/ban.png', + width: double.infinity, + fit: BoxFit.cover, + ), + const SizedBox(height: 40), + + GradientButton( + text: "Обратиться в техподдержку", + onTap: () {}, + showArrows: true, + height: 50, + width: 290, + fontSize: 14, + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/documents_screen.dart b/lib/presentation/screens/documents_screen.dart new file mode 100644 index 0000000..49f5e8a --- /dev/null +++ b/lib/presentation/screens/documents_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; +import '../components/link_row.dart'; + +class DocumentsScreen extends StatelessWidget { + const DocumentsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // ✅ Используем общий AppBar + const SizedBox(height: 16), + CustomAppBar(title: 'Документы'), + const SizedBox(height: 32), + + // Список ссылок + LinkRow( + icon: 'assets/icons/doc.png', + title: 'Договор аренды', + onTap: () => openLink('https://...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/doc.png', + title: 'Политика конфиденциальности', + onTap: () => openLink('https://...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/doc.png', + title: 'Правила вождения', + onTap: () => openLink('https://...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/doc.png', + title: 'Правила оплаты картой', + onTap: () => openLink('https://...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + + const Spacer(), // Отодвигаем картинку вниз + + + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/edit_profile_screen.dart b/lib/presentation/screens/edit_profile_screen.dart new file mode 100644 index 0000000..7cb9e2b --- /dev/null +++ b/lib/presentation/screens/edit_profile_screen.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../core/app_colors.dart'; +import '../../domain/entities/user_profile.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../event/edit_profile_event.dart'; +import '../state/edit_profile_state.dart'; +import '../event/profile_event.dart'; +import '../viewmodel/edit_profile_bloc.dart'; +import '../viewmodel/profile_bloc.dart'; + +class EditProfileScreen extends StatefulWidget { + final UserProfile profile; + + const EditProfileScreen({super.key, required this.profile}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + late final TextEditingController nameController; + late final TextEditingController birthDateController; + late final TextEditingController phoneController; + late final TextEditingController emailController; + + DateTime? _selectedBirthDate; + + @override + void initState() { + super.initState(); + context.read().add(EditProfileStarted()); + + nameController = TextEditingController(); + phoneController = TextEditingController(); + emailController = TextEditingController(); + + String initialDateText = ''; + + if (widget.profile.birthDate.isNotEmpty) { + try { + _selectedBirthDate = DateFormat('dd.MM.yyyy').parse(widget.profile.birthDate); + initialDateText = DateFormat('dd.MM.yyyy').format(_selectedBirthDate!); + } catch (e) { + initialDateText = widget.profile.birthDate; + print("EXCEPTION: $e"); + } + } + birthDateController = TextEditingController(text: initialDateText); + } + + void _submit(BuildContext context) { + final profileFromState = context.read().state.profile; + if (profileFromState == null) return; + + final String birthDateForApi = _selectedBirthDate?.toIso8601String() ?? ''; + + final updatedProfile = profileFromState.copyWith( + name: nameController.text, + birthDate: birthDateForApi.isNotEmpty ? "${birthDateForApi}Z" : '', + email: emailController.text, + ); + + context.read().add(EditProfileSubmitted(updatedProfile)); + context.read().add(ProfileUpdated()); + + } + + Future _selectDate(BuildContext context) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedBirthDate ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 100), + lastDate: DateTime.now(), + ); + + if (picked != null && picked != _selectedBirthDate) { + setState(() { + _selectedBirthDate = picked; + birthDateController.text = DateFormat('dd.MM.yyyy').format(picked); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocConsumer( + listener: (context, state) { + final profile = state.profile; + if (profile != null && nameController.text.isEmpty) { + nameController.text = profile.name; + phoneController.text = profile.phone; + emailController.text = profile.email; + birthDateController.text = profile.birthDate; + + } + + if (state.isSuccess) { + context.read().add(ProfileUpdated()); + context.pop(); + } + if (state.error != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!))); + } + }, + builder: (context, state) { + if (state.profile == null) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + return Column( + children: [ + const SizedBox(height: 16), + CustomAppBar(title: 'Личные данные'), + const SizedBox(height: 32), + _Field( + 'Имя', + nameController, + iconPath: 'assets/icons/edit.png', + ), + _Field( + 'Дата рождения', + birthDateController, + iconPath: 'assets/icons/edit.png', + readOnly: true, + onTap: () => _selectDate( + context, + ), + ), + _Field( + 'Телефон', + phoneController, + iconPath: 'assets/icons/lock.png', + enabled: false, + ), + _Field( + 'E-mail', + emailController, + iconPath: 'assets/icons/edit.png', + ), + const Spacer(), + state.isSaving + ? const CircularProgressIndicator(color: Colors.white) + : GradientButton( + text: 'Сохранить изменения', + onTap: () => _submit(context), + width: double.infinity, + height: 56, + fontSize: 16, + showArrows: true, + ), + const SizedBox(height: 24), + ], + ); + }, + ), + ), + ), + ), + ); + } + +} + +class _Field extends StatelessWidget { + final String label; + final TextEditingController controller; + final String iconPath; + final bool enabled; + final bool readOnly; + final VoidCallback? onTap; + + const _Field( + this.label, + this.controller, { + required this.iconPath, + this.enabled = true, + this.readOnly = false, + this.onTap + }); + + @override + Widget build(BuildContext context) { + final borderColor = enabled + ? Colors.white.withOpacity(0.3) + : Colors.white.withOpacity(0.15); + final iconOpacity = enabled ? 0.7 : 0.3; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextField( + controller: controller, + enabled: enabled, + readOnly: readOnly, + onTap: onTap, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColors.white70), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: borderColor), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: AppColors.smsDigit, width: 1.5), + ), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 12), + child: Opacity( + opacity: iconOpacity, + child: Image.asset(iconPath, width: 20, height: 20), + ), + ), + suffixIconConstraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screens/license_agreement_screen.dart b/lib/presentation/screens/license_agreement_screen.dart new file mode 100644 index 0000000..c8d3599 --- /dev/null +++ b/lib/presentation/screens/license_agreement_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; + +class LicenseAgreementScreen extends StatelessWidget { + const LicenseAgreementScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + // 🔹 APPBAR С КНОПКОЙ НАЗАД + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: ' '), + ), + + // 🔹 ПРОКРУЧИВАЕМЫЙ ТЕКСТ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Лицензионное соглашение на использование программы «Be Happy» для мобильных устройств', + style: const TextStyle( + color: Colors.white, + fontSize: 18 + ), + ), + + const SizedBox(height: 16), + + _buildParagraph( + 'Перед использованием программы, пожалуйста, ознакомьтесь с условиями нижеследующего лицензионного соглашения.\n\n' + 'Любое использование Вами программы означает полное и безоговорочное принятие Вами условий настоящего лицензионного соглашения.\n\n' + 'Если Вы не принимаете условия лицензионного соглашения в полном объёме, Вы не имеете права использовать программу в каких-либо целях.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('1. Общие положения'), + _buildParagraph( + '1.1. Настоящее Лицензионное соглашение («Лицензия») устанавливает условия использования программы «Be Happy» для мобильных устройств («Программа») и заключено между любым лицом, использующим Программу («Пользователь»), и ООО “БИХЕППИБЕЛ”, Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А, являющимся правообладателем исключительного права на Программу («Лицензиар»).\n\n' + '1.2. Копируя Программу, устанавливая её на свое мобильное устройство или используя Программу любым образом, Пользователь выражает свое полное и безоговорочное согласие со всеми условиями Лицензии.\n\n' + '1.3. Использование Программы разрешается только на условиях настоящей Лицензии. Если Пользователь не принимает условия Лицензии в полном объёме, Пользователь не имеет права использовать Программу в каких-либо целях. Использование Программы с нарушением (невыполнением) какого-либо из условий Лицензии запрещено.\n\n' + '1.4. Использование Программы Пользователем на условиях настоящей Лицензии в личных некоммерческих целях осуществляется безвозмездно. Использование Программы на условиях и способами, не предусмотренными настоящей Лицензией, возможно только на основании отдельного соглашения с Лицензиаром.\n\n' + '• Оферта на подключение к программе «Be Happy», размещенная в сети Интернет по адресу: https://behappybel.by,\n' + '• Условия подключения к сервису «Be Happy», размещенные в сети Интернет по адресу: https://behappybel.by,\n' + '• «Политика конфиденциальности», размещенная в сети Интернет по адресу: https://behappybel.by.\n\n' + 'Указанные документы (в том числе любые из их частей) могут быть изменены Правообладателем в одностороннем порядке без какого-либо специального уведомления, новая редакция документов вступает в силу с момента их опубликования, если иное не предусмотрено новыми редакциями документов.\n\n' + '1.6. Все или некоторые функции Программы могут быть недоступны или ограничены в зависимости от наличия или отсутствия акцепта Пользователем документов, указанных в пункте 1.5 настоящей Лицензии.\n\n' + '1.7. К настоящей Лицензии и всем отношениям, связанным с использованием Программы, подлежит применению право Республики Беларусь и любые претензии или иски, вытекающие из настоящей Лицензии или использования Программы, должны быть поданы и рассмотрены в суде по месту нахождения Правообладателя.\n\n' + '1.8. Лицензиар может предоставить Пользователю перевод настоящей Лицензии с русского на другие языки, однако в случае противоречия между условиями Лицензии на русском языке и ее переводом, юридическую силу имеет исключительно русскоязычная версия Лицензии. Русскоязычная версия Лицензии размещена по адресу: https://behappybel.by.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('2. Права на Программу'), + _buildParagraph( + '2.1. Исключительное право на Программу принадлежит Лицензиар.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('3. Лицензия'), + _buildParagraph( + '3.1. Лицензиар безвозмездно, на условиях простой (неисключительной) лицензии, предоставляет Пользователю непередаваемое право использования Программы на территории всех стран мира следующими способами:\n\n' + '3.1.1. Воспроизводить Программу путем её копирования и установки на мобильное (-ые) устройство (-ва) Пользователя. При установке на мобильное устройство каждой копии Программы присваивается индивидуальный номер, который автоматически сообщается Правообладателю;\n\n' + '3.1.2. Применять Программу по прямому функциональному назначению только после принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов и ввода в интерфейсе Программы специального кода (пароля), предоставленного Пользователю в подтверждение принятия указанных документов.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('4. Ограничения'), + _buildParagraph( + '4.1. За исключением использования в объемах и способами, прямо предусмотренными настоящей Лицензией или законодательством Республики Беларусь, Пользователь не имеет права изменять, декомпилировать, дизассемблировать, дешифровать и производить иные действия с объектным кодом Программы, имеющие целью получение информации о реализации алгоритмов, используемых в Программе, создавать производные произведения с использованием Программы, а также осуществлять (разрешать осуществлять) иное использование Программы, без письменного согласия Лицензиара.\n\n' + '4.2. Пользователь не имеет право воспроизводить и распространять Программу в коммерческих целях (в том числе за плату), в том числе в составе сборников программных продуктов, без письменного согласия Лицензиара.\n\n' + '4.3. Пользователь не имеет права распространять Программу в виде, отличном от того, в котором он ее получил, без письменного согласия Правообладателя.\n\n' + '4.4. Программа должна использоваться (в том числе распространяться) под наименованием: «Be Happy». Пользователь не вправе изменять наименование Программы, изменять и/или удалять знак охраны авторского права (copyright notice) или иные указания на Лицензиара.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('5. Условия использования отдельных функций Программы'), + _buildParagraph( + '5.1. Выполнение некоторых функций Программы возможно только при наличии доступа к сети Интернет. Пользователь самостоятельно получает и оплачивает такой доступ на условиях и по тарифам своего оператора связи или провайдера доступа к сети Интернет.\n\n' + '5.2. Пользователь считается правомерно владеющим экземпляром Программы, при условии ввода в интерфейсе Программы специального кода (пароля), который выдается Лицензиар Пользователю в случае акцепта Пользователем Оферты на подключение к программе «Be Happy» размещенной в сети Интернет по адресу: https://behappybel.by и при условии соответствия Пользователя требованиям, указанным в Условиях подключения к сервису «Be Happy», размещенных в сети Интернет по адресу: https://behappybel.by В этом случае Пользователь вправе использовать функциональные возможности Программы.\n\n' + '5.3. Специальных код (пароль), необходимый для подтверждения правомерности владения (использования) Пользователем экземпляром Программы, не считается действительным, если он был получен без принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('6. Ответственность по Лицензии'), + _buildParagraph( + '6.1. Программа (включая её части и содержание) предоставляется на условиях «как есть» (as is). Лицензиар не предоставляет никаких гарантий в отношении безошибочной и бесперебойной работы Программы или отдельных её компонентов и/или функций, соответствия Программы конкретным целям и ожиданиям Пользователя, не гарантирует достоверность, точность, полноту и своевременность Данных, а также не предоставляет никаких иных гарантий, прямо не указанных в настоящей Лицензии.\n\n' + '6.2. Лицензиар не несет ответственности за какие-либо прямые или косвенные последствия какого-либо использования или невозможности использования Программы (включая ей части и содержание) и/или ущерб, причиненный Пользователю и/или третьим сторонам в результате какого-либо использования, неиспользования или невозможности использования Программы (включая ей части и содержание) или отдельных её компонентов и/или функций, в том числе из-за возможных ошибок или сбоев в работе Программы, за исключением случаев, прямо предусмотренных законодательством.\n\n' + '6.3. Пользователь настоящим уведомлен и соглашается, что при использовании Программы Лицензиар в автоматическом передается следующая информация: тип операционной системы мобильного устройства Пользователя, версия и идентификатор Программы, статистика использования функций Программы, а также иная техническая информация.\n\n' + '6.4. Программа может содержать ссылки на сайты и приложения третьих лиц. Правообладатель не контролирует и не несет ответственности за содержание и порядок использования таких сайтов и приложений. Условия использования таких сайтов и приложений определены в их соглашениях и политиках конфиденциальности.\n\n' + '6.5. Все вопросы и претензии, связанные с использованием/невозможностью использования Программы, а также возможным нарушением Программой законодательства и/или прав третьих лиц, должны направляться через форму обратной связи по адресу:', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('7. Обновления/новые версии Программы'), + _buildParagraph( + '7.1. Действие настоящей Лицензии распространяется на все последующие обновления/новые версии Программы. Соглашаясь с установкой обновления/новой версии Программы, Пользователь принимает условия настоящей Лицензии для соответствующих обновлений/новых версий Программы, если обновление/установка новой версии Программы не сопровождается иным Лицензионным соглашением.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('8. Изменения условий настоящей Лицензии'), + _buildParagraph( + '8.1. Настоящее лицензионное соглашение может изменяться Лицензиром в одностороннем порядке. Уведомление Пользователя о внесенных изменениях в условия настоящей Лицензии публикуется на странице: https://behappybel.by. Указанные изменения в условиях лицензионного соглашения вступают в силу с даты их публикации, если иное не оговорено в соответствующей публикации.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('Лицензиар'), + _buildParagraph( + 'ООО “БИХЕППИБЕЛ”\n' + 'УНП 392050943\n' + 'ОКПО 511318892000\n' + 'Свидетельство о государственной регистрации № 392026683 от 22.01.2026 выдано Инспекцией Министерства по налогам и сборам Республики Беларусь по Октябрьскому району г.Витебска\n\n' + 'Юридический адрес: Республика Беларусь, 210017, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А', + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildParagraph(String text) { + return Text( + text, + style: const TextStyle( + color: Color(0xFFD1D1D6), + fontSize: 14, + height: 1.5, + ), + ); + } + + Widget _buildSectionHeader(String text) { + return Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + height: 1.4, + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/map_screen.dart b/lib/presentation/screens/map_screen.dart new file mode 100644 index 0000000..f7d35b2 --- /dev/null +++ b/lib/presentation/screens/map_screen.dart @@ -0,0 +1,834 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bot_toast/bot_toast.dart'; +import 'package:be_happy/domain/entities/client_notification.dart'; +import 'package:be_happy/domain/usecase/book_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart'; +import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; +import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart'; +import 'package:be_happy/presentation/components/fine_notification_card.dart'; +import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart'; +import 'package:be_happy/presentation/components/payment_notification_card.dart'; +import 'package:be_happy/presentation/components/sheet/current_rides_sheet.dart'; +import 'package:be_happy/presentation/components/sheet/map_settings_sheet.dart'; +import 'package:be_happy/presentation/components/sheet/tariff_sheet.dart'; +import 'package:be_happy/presentation/event/current_rides_event.dart'; +import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; +import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; +import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:go_router/go_router.dart'; +import 'package:yandex_mapkit/yandex_mapkit.dart'; + +import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/zone.dart'; +import '../../domain/usecase/get_address_by_point_usecase.dart'; +import '../../domain/usecase/get_available_tariffs_usecase.dart'; +import '../../domain/usecase/get_notifications_stream_usecase.dart'; +import '../components/notification_toast.dart'; +import '../components/sheet/scooter_bottom_sheet.dart'; +import '../components/side_menu.dart'; +import '../components/unpaid_order_notification_card.dart'; +import '../event/map_event.dart'; +import '../event/scooter_detail_modal_event.dart'; +import '../state/map_state.dart'; +import '../viewmodel/map_bloc.dart'; +import '../viewmodel/scooter_detail_modal_bloc.dart'; + +class MapScreen extends StatefulWidget { + const MapScreen({super.key}); + + @override + State createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + YandexMapController? mapController; + Position? _currentPosition; + StreamSubscription? _positionStreamSubscription; + StreamSubscription? _notificationStreamSubscription; + bool _isFirstLocationUpdate = true; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _checkLocationPermission(); + _initScooterIcon(); + _startNotificationStream(); + context.read().add(FetchProfileData()); + context.read().add(CheckUser()); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _positionStreamSubscription?.cancel(); + _notificationStreamSubscription?.cancel(); + context.read().stopNotificationStream(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + drawer: const SideMenu(), + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Stack( + children: [ + BlocConsumer( + listenWhen: (previous, current) { + return current.lastNotification != + previous.lastNotification || + current.flags != previous.flags; + }, + + listener: (context, state) { + if (state.lastNotification != null) { + _showNotificationToast(state.lastNotification!); + } + + if (state.flags != null) { + if (!state.flags.hasCard) { + BotToast.showCustomNotification( + duration: null, + toastBuilder: (_) { + return Container( + margin: const EdgeInsets.only(top: 120), + child: Material( + color: Colors.transparent, + child: PaymentNotificationCard( + onBindCard: () { + BotToast.cleanAll(); + context.push("/home/payment-methods"); + }, + onClose: () => BotToast.cleanAll(), + ), + ), + ); + }, + ); + } + if (state.flags.hasFine) { + BotToast.showCustomNotification( + duration: null, + toastBuilder: (_) { + return Container( + margin: const EdgeInsets.only(top: 120), + child: Material( + color: Colors.transparent, + child: FineNotificationCard( + /*onBindCard: () { + BotToast.cleanAll(); + context.push("/home/payment-methods"); + },*/ + onClose: () => BotToast.cleanAll(), + ), + ), + ); + }, + ); + } + + if (state.flags.hasUnpaidOrder) { + BotToast.showCustomNotification( + duration: null, + toastBuilder: (_) { + return Container( + margin: const EdgeInsets.only(top: 120), + child: Material( + color: Colors.transparent, + child: UnpaidOrderNotificationCard( + /*onBindCard: () { + BotToast.cleanAll(); + context.push("/home/payment-methods"); + },*/ + onClose: () => BotToast.cleanAll(), + ), + ), + ); + }, + ); + } + } + }, + buildWhen: (previous, current) => + previous.scooters != current.scooters || + previous.zones != current.zones, + builder: (context, state) { + final scooters = _buildScooterPlacemarks( + state.scooters, + state.address ?? "Unknown address", + ); + + final zonePolygons = _buildZonePolygons(state.zones); + + return RepaintBoundary( + child: YandexMap( + onMapCreated: (controller) { + controller.toggleUserLayer(visible: true); + mapController = controller; + if (_currentPosition != null) { + _fetchScooters(); + } + }, + onCameraPositionChanged: + (cameraPosition, reason, finished) { + if (finished) { + _fetchScooters(); + } + }, + mapObjects: [ + ...zonePolygons, + ClusterizedPlacemarkCollection( + mapId: const MapObjectId('scooters_cluster'), + placemarks: scooters, + radius: 30, + minZoom: 15, + consumeTapEvents: true, + onClusterTap: (collection, cluster) { + final clusteredPlacemarks = cluster.placemarks; + final filtered = state.scooters.where((scooter) { + return clusteredPlacemarks.any( + (pm) => pm.mapId.value == scooter.id.toString(), + ); + }).toList(); + _onMarkerTap(filtered); + }, + onClusterAdded: (self, cluster) async { + return cluster.copyWith( + appearance: cluster.appearance.copyWith( + opacity: 1.0, + icon: PlacemarkIcon.single( + PlacemarkIconStyle( + image: BitmapDescriptor.fromBytes( + await ClusterIconPainter( + cluster.size, + ).getClusterIconBytes(), + ), + scale: 0.8, + ), + ), + ), + ); + }, + ), + ], + ), + ); + }, + ), + + // Индикатор загрузки (отдельный строитель для статуса) + BlocBuilder( + buildWhen: (previous, current) => + previous.status != current.status, + builder: (context, state) { + if (state.status == ScooterStatus.loading) { + return const Positioned( + top: 80, + left: 0, + right: 0, + child: Center(child: CircularProgressIndicator()), + ); + } + return const SizedBox.shrink(); + }, + ), + + // Кнопки управления (Меню, Уведомления) + _buildTopButtons(), + + // Кнопки навигации + if (_currentPosition != null) _buildSideControls(), + + _buildCentralQrButton(), + ], + ), + ), + ), + ); + } + + void _startNotificationStream() { + final notificationsStreamUseCase = getIt(); + + _notificationStreamSubscription = notificationsStreamUseCase().listen( + (notification) { + if (mounted) { + context.read().add(NotificationReceived(notification)); + } + }, + onError: (error) { + print("SSE NOTIFICATION ERROR: $error"); + }, + ); + } + + void _checkLocationPermission() async { + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + await Geolocator.requestPermission(); + } + _getCurrentLocation(); + // _startTrackingLocation(); + } + + void _startTrackingLocation() { + const LocationSettings locationSettings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 10, + ); + + _positionStreamSubscription = + Geolocator.getPositionStream( + locationSettings: locationSettings, + ).listen((Position position) { + if (!mounted) return; + + print( + "----------------------------------------------------- tracking... --------------------------------------------------------", + ); + + setState(() => _currentPosition = position); + + context.read().add( + UpdateUserLocation(position.latitude, position.longitude), + ); + + if (_isFirstLocationUpdate) { + _moveCameraToPoint(position.latitude, position.longitude, zoom: 15); + _isFirstLocationUpdate = false; + } + _fetchScooters(); + }); + } + + void _getCurrentLocation() async { + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + setState(() => _currentPosition = position); + + if (mapController != null && mounted) { + await _moveCameraToPoint(position.latitude, position.longitude); + _fetchScooters(); + } + } catch (e) { + debugPrint('Ошибка геолокации: $e'); + } + } + + void _fetchScooters() async { + final controller = mapController; + if (controller == null) return; + + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + _debounceTimer = Timer(const Duration(milliseconds: 500), () async { + final visibleRegion = await controller.getVisibleRegion(); + + final areaScooters = [ + visibleRegion.bottomRight.longitude, + visibleRegion.bottomRight.latitude, + visibleRegion.topLeft.latitude, + visibleRegion.topLeft.longitude, + ]; + + final areaZones = [ + visibleRegion.bottomRight.longitude, + visibleRegion.bottomRight.latitude, + visibleRegion.topLeft.longitude, + visibleRegion.topLeft.latitude, + ]; + + if (mounted) { + context.read().add(FetchScooters(areaZones, areaScooters)); + } + }); + } + Future _moveCameraToPoint( + double lat, + double lon, { + double zoom = 15, + }) async { + await mapController?.moveCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: Point(latitude: lat, longitude: lon), + zoom: zoom, + ), + ), + ); + } + + void _onMarkerTap(List scooters) async { + context.push( + "/home/scooter-sheet", + extra: {'scooters': scooters, 'currentLocation': _currentPosition}, + ); + + /*final scoot = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: true, + builder: (context) { + return BlocProvider( + create: (context) => + ScooterDetailModalBloc( + getIt(), + getIt(), + getIt(), + )..add( + ScooterDetailModalStarted( + scooters, + _currentPosition!.latitude, + _currentPosition!.longitude, + ), + ), + child: ScooterBottomSheet(), + ); + }, + );*/ + /*bool? isBooking = false; + if (scoot != null) { + final result = await context.push('/home/scooter/${scoot.id}'); + + if (result == true) { + // Даем небольшую задержку, чтобы навигация завершилась корректно + await Future.delayed(Duration(milliseconds: 300), () async { + isBooking = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: true, + builder: (context) => BlocProvider( + create: (context) => TariffSheetBloc( + getIt(), + getIt(), + getIt(), + ), + child: TariffSheet(scooter: scoot), + ), + ); + }); + } + } + + if (isBooking ?? false) { + showModalBottomSheet( + context: context, + builder: (context) => BlocProvider( + create: (context) => + CurrentRidesBloc(getIt()) + ..add(LoadClientOrders(1)), + child: CurrentRidesSheet(clientId: 1), + ), + ); + }*/ + } + + void _onMapSettingsTap() { + context.push("/home/map-settings-sheet"); + /*showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: true, + builder: (context) { + return BlocProvider( + create: (context) => MapSettingsModalBloc( + getIt(), + getIt(), + )..add(MapSettingsModalStarted()), + child: MapSettingsSheet(), + ); + }, + );*/ + } + + void _onNotificationTap() { + context.push("/home/current-rides-sheet"); + + /*showModalBottomSheet( + context: context, + builder: (context) => BlocProvider( + create: (context) => + CurrentRidesBloc(getIt()) + ..add(LoadClientOrders()), + child: CurrentRidesSheet(), + ), + );*/ + + // BotToast.showCustomNotification( + // duration: const Duration(seconds: 4), + // + // toastBuilder: (_) { + // return NotificationToast( + // title: "", + // onClose: () { + // BotToast.cleanAll(); + // }, + // ); + // }, + // ); + } + + void _showNotificationToast(ClientNotification notification) { + String title = _getNotificationTitle(notification.type); + Color backgroundColor = _getNotificationColor(notification.type); + + BotToast.showCustomNotification( + duration: null, + toastBuilder: (_) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 50), + child: Material( + elevation: 8, + shadowColor: Colors.black26, + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 60, + child: Image.asset( + 'assets/icons/clichnik.png', + fit: BoxFit.contain, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + notification.content, + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () { + BotToast.cleanAll(); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _getNotificationTitle(NotificationType type) { + switch (type) { + case NotificationType.info: + return 'Информация'; + case NotificationType.attention: + return 'Внимание'; + case NotificationType.warning: + return 'Предупреждение'; + } + } + + Color _getNotificationColor(NotificationType type) { + switch (type) { + case NotificationType.info: + return const Color(0xFF2196F3); + case NotificationType.attention: + return const Color(0xFFFF9800); + case NotificationType.warning: + return Colors.red; + } + } + + void _initScooterIcon() async { + await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png'); + } + + List _buildScooterPlacemarks( + List scooters, + String address, + ) { + return scooters.map((scooter) { + return PlacemarkMapObject( + mapId: MapObjectId('${scooter.id}'), + point: Point(latitude: scooter.longitude, longitude: scooter.latitude), + icon: PlacemarkIcon.single( + PlacemarkIconStyle( + image: BitmapDescriptor.fromAssetImage( + 'assets/icons/scooter_placemark_fill.png', + ), + scale: 0.2, + ), + ), + opacity: 1.0, + onTap: (object, point) async => { + _onMarkerTap([scooter]), + }, + ); + }).toList(); + } + + List _buildZonePolygons(List? zones) { + if (zones == null || zones.isEmpty) return []; + + List objects = []; + + List allZoneHoles = []; + + for (var zone in zones) { + var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); + + var cleanPoints = []; + for (var p in points) { + if (cleanPoints.isEmpty || cleanPoints.last != p) { + cleanPoints.add(p); + } + } + + if (cleanPoints.length > 2) { + if (cleanPoints.first != cleanPoints.last) { + cleanPoints.add(cleanPoints.first); + } + + allZoneHoles.add(LinearRing(points: cleanPoints)); + } + } + + objects.add( + PolygonMapObject( + mapId: const MapObjectId('global_inverse_mask'), + polygon: Polygon( + outerRing: const LinearRing(points: [ + Point(latitude: 85, longitude: -179.9), + Point(latitude: 85, longitude: 179.9), + Point(latitude: -85, longitude: 179.9), + Point(latitude: -85, longitude: -179.9), + ]), + innerRings: allZoneHoles, + ), + strokeWidth: 0, + fillColor: Colors.red.withOpacity(0.15), + zIndex: 0, + ), + ); + + for (var zone in zones) { + Color borderColor; + if (zone.type == "Drive") { + borderColor = const Color(0xFF5ECD4C); + } else if (zone.type == "NotDrive") { + borderColor = const Color(0xFFEF4444); + } else { + borderColor = const Color(0xFFA78BFA); + } + + objects.add( + PolylineMapObject( + mapId: MapObjectId('zone_contour_${zone.id}'), + polyline: Polyline( + points: zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(), + ), + strokeColor: borderColor, + strokeWidth: 2.0, + zIndex: 1, + ), + ); + } + + return objects; + } + + Widget _buildTopButtons() { + return Stack( + children: [ + Positioned( + top: 16, + left: 16, + child: Builder( + builder: (innerContext) => _RoundIconButton( + icon: Icons.menu, + onPressed: () => {Scaffold.of(innerContext).openDrawer()}, + ), + ), + ), + Positioned( + top: 16, + right: 16, + child: Column( + children: [ + _RoundIconButton( + icon: Icons.notifications_sharp, + onPressed: _onNotificationTap, + ), + const SizedBox(height: 12), + _RoundIconButton( + icon: Icons.directions_run, + onPressed: () => context.push("/home/current-rides-sheet"), + ), + ], + ), + ), + ], + ); + } + + Widget _buildCentralQrButton() { + return Positioned( + bottom: 24, + left: 0, + right: 0, + child: Center( + child: GestureDetector( + onTap: () => context.push("/home/qr-info"), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.darkBlue, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.qr_code_scanner, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ); + } + + Widget _buildSideControls() { + return Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CircleIconButton(icon: Icons.map, onPressed: _onMapSettingsTap), + const SizedBox(height: 16), + _CircleIconButton( + icon: Icons.my_location, + onPressed: () { + context.read().add( + UpdateUserLocation( + _currentPosition!.latitude, + _currentPosition!.longitude, + ), + ); + _moveCameraToPoint( + _currentPosition!.latitude, + _currentPosition!.longitude, + zoom: 17, + ); + }, + ), + ], + ), + ), + ); + } +} + +Future painterToBytes(CustomPainter painter, Size size) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage(size.width.toInt(), size.height.toInt()); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData!.buffer.asUint8List(); +} + +class _RoundIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + + const _RoundIconButton({required this.icon, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.darkBlue, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: Icon(icon, color: Colors.white, size: 24), + onPressed: onPressed, + ), + ); + } +} + +class _CircleIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + + const _CircleIconButton({required this.icon, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: AppColors.darkBlue, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(icon, color: Colors.white, size: 24), + onPressed: onPressed, + ), + ); + } +} diff --git a/lib/presentation/screens/news_detail_screen.dart b/lib/presentation/screens/news_detail_screen.dart new file mode 100644 index 0000000..cdc853c --- /dev/null +++ b/lib/presentation/screens/news_detail_screen.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:html/dom.dart' as dom; +import 'dart:convert'; +import 'package:be_happy/di/service_locator.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; +import '../../domain/usecase/get_news_by_id_usecase.dart'; + +class NewsDetailScreen extends StatefulWidget { + final int newsId; + final String title; + + const NewsDetailScreen({ + super.key, + required this.newsId, + required this.title, + }); + + @override + State createState() => _NewsDetailScreenState(); +} + +class _NewsDetailScreenState extends State { + bool _isLoading = true; + String? _errorMessage; + dynamic _news; + + @override + void initState() { + super.initState(); + _fetchNews(); + } + + Future _fetchNews() async { + try { + final usecase = getIt(); + final news = await usecase(widget.newsId); + + if (mounted) { + setState(() { + _news = news; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + // 🔹 Заголовок в AppBar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: widget.title), + ), + + // 🔹 Контент + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator(color: Colors.white), + ) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Ошибка загрузки новости', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() { + _isLoading = true; + _errorMessage = null; + _fetchNews(); + }); + }, + child: const Text('Повторить'), + ), + ], + ), + ) + : _news != null + ? _buildNewsContent(_news) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildNewsContent(dynamic news) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + // Текст новости: сначала проверяем textJson, потом text + if (news.textJson != null) + ..._parseJsonText(news.textJson) + else if (news.text != null) + ..._parseHtmlText(news.text), + ], + ), + ); + } + + List _parseHtmlText(String htmlText) { + final parsedHtml = html_parser.parse(htmlText); + final List elements = parsedHtml.body?.nodes ?? []; + + return _parseHtmlElements(elements); + } + + List _parseHtmlElements(List nodes) { + List widgets = []; + + for (final node in nodes) { + if (node is dom.Element) { + switch (node.localName) { + case 'h1': + case 'h2': + case 'h3': + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + node.text, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + break; + case 'p': + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + node.text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ), + ); + break; + case 'ul': + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children + .where((element) => element is dom.Element && element.localName == 'li') + .map((dom.Element li) => Padding( + padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + )), + Expanded( + child: Text( + li.text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ), + )) + .toList(), + ), + ), + ); + break; + default: + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + node.text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ), + ); + } + } else if (node is dom.Text) { + widgets.add( + Text( + node.text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ); + } + } + + return widgets; + } + + // 🔹 Парсинг JSON-текста (новый метод) + List _parseJsonText(String jsonString) { + try { + final dynamic data = jsonDecode(jsonString); + return _parseJsonNode(data); + } catch (e) { + return [ + const Text( + 'Ошибка отображения текста новости', + style: TextStyle(color: Colors.red, fontSize: 14), + ), + ]; + } + } + + List _parseJsonNode(dynamic node) { + List widgets = []; + + if (node is List) { + // Если корень — массив, парсим каждый элемент + for (final item in node) { + widgets.addAll(_parseJsonNode(item)); + } + } else if (node is Map) { + final type = node['type'] as String?; + final content = node['content']; + + switch (type) { + case 'div': + if (content is List) { + widgets.addAll(_parseJsonNode(content)); + } + break; + case 'h2': + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + (content is List) + ? content.join(' ') + : content?.toString() ?? '', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + break; + case 'p': + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + (content is List) + ? content.join(' ') + : content?.toString() ?? '', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ), + ); + break; + case 'ul': + if (content is List) { + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: content + .where((item) => item is Map) + .map((item) => item as Map) + .where((item) => item['type'] == 'li') + .map((li) => Padding( + padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + )), + Expanded( + child: Text( + (li['content'] is List) + ? li['content'].join(' ') + : li['content']?.toString() ?? '', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ), + )) + .toList(), + ), + ), + ); + } + break; + default: + // Если тип неизвестен, просто выводим текст + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + (content is List) + ? content.join(' ') + : content?.toString() ?? type ?? 'unknown', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ), + ); + } + } + + return widgets; + } + + String _formatDate(DateTime? date) { + if (date == null) return ''; + final localDate = date.toLocal(); + return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}'; + } +} \ No newline at end of file diff --git a/lib/presentation/screens/news_screen.dart b/lib/presentation/screens/news_screen.dart new file mode 100644 index 0000000..965f7c8 --- /dev/null +++ b/lib/presentation/screens/news_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'dart:developer' as dev; +import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; +import '../components/custom_app_bar.dart'; +import '../event/news_event.dart'; +import '../state/news_state.dart'; +import '../viewmodel/news_bloc.dart'; +import 'news_detail_screen.dart'; + +class NewsScreen extends StatelessWidget { + const NewsScreen({super.key}); + + @override + Widget build(BuildContext context) { + dev.log('🔍 NewsScreen: Создание экрана новостей'); + + return BlocProvider( + create: (context) { + dev.log('🔍 NewsScreen: Создание NewsBloc'); + return getIt()..add(const NewsFetchRequested()); + }, + child: const NewsView(), + ); + } +} + +class NewsView extends StatelessWidget { + const NewsView({super.key}); + + @override + Widget build(BuildContext context) { + dev.log('🔍 NewsView: Построение UI'); + + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Новости'), + ), + const SizedBox(height: 32), + Expanded( + child: BlocBuilder( + builder: (context, state) { + dev.log('🔍 NewsView: Состояние ${state.status}, новостей: ${state.news.length}'); + + if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.status == NewsStatus.failure) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Ошибка загрузки новостей', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + state.errorMessage ?? '', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + dev.log('🔍 NewsView: Повторная загрузка'); + context.read().add(const NewsFetchRequested()); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.smsDigit, + ), + child: const Text('Повторить'), + ), + ], + ), + ); + } + + if (state.news.isEmpty) { + return const _EmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: state.news.length, + itemBuilder: (context, index) { + return _NewsCard(news: state.news[index]); + }, + ); + }, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/news_empty.png', + width: 280, + height: 280, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.newspaper_outlined, + size: 120, + color: Colors.white38, + ); + }, + ), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'Следите за нашими последними новостями и акциями! Сейчас их нет, но скоро они появятся.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontSize: 16, + height: 1.5, + ), + ), + ), + ], + ); + } +} + +class _NewsCard extends StatelessWidget { + final dynamic news; + + const _NewsCard({required this.news}); + + @override + Widget build(BuildContext context) { + final date = _formatDate(news.publishedAt); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF141530), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: const TextStyle( + color: AppColors.white70, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + Text( + news.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + news.previewText, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 40, + child: OutlinedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewsDetailScreen( + newsId: news.id, + title: news.title, + ), + ), + ); + }, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)), + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Подробнее', + style: TextStyle(color: AppColors.smsDigit), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios_sharp, + size: 12, + color: AppColors.smsDigit, + ), + Icon( + Icons.arrow_forward_ios_sharp, + size: 12, + color: AppColors.smsDigit.withOpacity(0.6), + ), + Icon( + Icons.arrow_forward_ios_sharp, + size: 12, + color: AppColors.smsDigit.withOpacity(0.3), + ), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } else if (difference.inDays == 1) { + return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } else { + return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + } +} \ No newline at end of file diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart new file mode 100644 index 0000000..fd92a4b --- /dev/null +++ b/lib/presentation/screens/onboarding_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import '../components/gradient_button.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List> _slides = [ + { + 'image': 'assets/onboard_1.jpg', + 'title': 'Найдите самокат на карте и выберите нужный', + }, + { + 'image': 'assets/onboard_2.jpg', + 'title': 'Отсканируйте QR-код или введите номер который указан под козлом', + }, + { + 'image': 'assets/onboard_3.png', + 'title': 'Дождитесь звукового сигнала. При наличии замка отстегните его', + }, + { + 'image': 'assets/onboard_4.jpg', + 'title': 'Выберите тариф и начните поездку', + }, + { + 'image': 'assets/onboard_5.jpg', + 'title': 'Управляйте скоростью с помощью курка тормоза', + }, + { + 'image': 'assets/onboard_6.jpg', + 'title': 'Для торможения используйте курок тормоза', + }, + { + 'image': 'assets/onboard_7.png', + 'title': 'Для завершения аренды припаркуйте самокат в разрешённом месте', + }, + ]; + + void _nextPage() { + if (_currentPage < _slides.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + // TODO: переход дальше (авторизация / главная) + Navigator.pop(context); + } + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + /// Фон — PageView + PageView.builder( + controller: _pageController, + itemCount: _slides.length, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (index) { + setState(() { + _currentPage = index; + }); + }, + itemBuilder: (context, index) { + return Image.asset( + _slides[index]['image']!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + }, + ), + + /// Градиент сверху для читаемости + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.35), + Colors.transparent, + ], + stops: const [0.0, 0.7], + ), + ), + ), + + /// Контент + SafeArea( + child: Column( + children: [ + const Expanded(child: SizedBox()), + + /// Текст + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + _slides[_currentPage]['title']!, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 32), + + /// Кнопка + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GradientButton( + text: _currentPage == _slides.length - 1 + ? 'Начать' + : 'Далее', + onTap: _nextPage, + width: double.infinity, + height: 56, + fontSize: 18, + showArrows: true, + ), + ), + + const SizedBox(height: 24), + + /// Индикатор + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_slides.length, (index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: index == _currentPage ? 10 : 8, + height: index == _currentPage ? 10 : 8, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: index == _currentPage + ? Colors.greenAccent + : Colors.white.withOpacity(0.3), + shape: BoxShape.circle, + ), + ); + }), + ), + + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/order_history_detail_screen.dart b/lib/presentation/screens/order_history_detail_screen.dart new file mode 100644 index 0000000..b0d19e0 --- /dev/null +++ b/lib/presentation/screens/order_history_detail_screen.dart @@ -0,0 +1,339 @@ +import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:yandex_mapkit/yandex_mapkit.dart'; +import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; +import '../../domain/entities/scooter_order.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../event/route_event.dart'; +import '../state/route_state.dart'; +import '../viewmodel/route_bloc.dart'; + +class OrderHistoryDetailScreen extends StatelessWidget { + final ScooterOrder order; + + const OrderHistoryDetailScreen({ + super.key, + required this.order, + }); + + @override + Widget build(BuildContext context) { + final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now()); + final scooterNumber = order.scooter?.title ?? '№${order.scooterId}'; + final price = order.totalPricePrint ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN'; + + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + // 🔹 HEADER + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Поездка $date'), + ), + + const SizedBox(height: 16), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BlocProvider( + create: (context) => RouteBloc( + getRouteUseCase: getIt(), // Используем DI (Service Locator) + )..add(FetchRouteEvent(order.id)), + child: Container( + height: 280, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BlocBuilder( + builder: (context, state) { + if (state is RouteLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is RouteLoaded) { + final List yandexPoints = state.points.map((p) => + Point(latitude: p.latitude, longitude: p.longitude) + ).toList(); + return YandexMap( + rotateGesturesEnabled: false, + scrollGesturesEnabled: false, + tiltGesturesEnabled: false, + zoomGesturesEnabled: false, + mapObjects: [ + PolylineMapObject( + mapId: const MapObjectId('route_line'), + polyline: Polyline(points: yandexPoints), + strokeColor: Colors.blue, + strokeWidth: 3, + ), + ], + onMapCreated: (controller) async { + final bounds = _calculateBounds(yandexPoints); + await controller.moveCamera( + CameraUpdate.newBounds(bounds), + ); + await controller.moveCamera(CameraUpdate.zoomOut()); + }, + ); + } + + if (state is RouteError) { + return Center(child: Text(state.message, style: const TextStyle(color: Colors.grey))); + } + + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + + const SizedBox(height: 16), + + // 🔹 ИНФОРМАЦИЯ О ПОЕЗДКЕ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E).withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + // Фото самоката + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.electric_scooter, + color: Colors.white, + size: 32, + ); + }, + ), + ), + const SizedBox(width: 12), + // Информация + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.qr_code_2, + color: Colors.white.withOpacity(0.6), + size: 16, + ), + const SizedBox(width: 4), + Text( + scooterNumber, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), + // Цена + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 100, + alignment: Alignment.center, + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), // Опционально: скругление углов + ), + child: Text( + order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН', + style: TextStyle( + color: order.status == 'Paid' + ? Colors.greenAccent + : Colors.redAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + + const SizedBox(height: 8), + + Text( + price, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + + + if (order.status != 'Paid') ...[ + const SizedBox(height: 16), + GradientButton( + text: 'Оплатить', + showArrows: true, + height: 56, + width: double.infinity, + fontSize: 16, + onTap: () { + context.go('/home/checkout/${order.id}'); + }, + ), + ], + + const SizedBox(height: 16), + + // 🔹 ДЕТАЛИ ПОЕЗДКИ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E).withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _DetailRow( + label: 'Старт', + value: _formatTime(order.startAt), + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Завершение', + value: _formatTime(order.finishAt), + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Расстояние', + value: '${order.mileage.toStringAsFixed(2)?? '0'} км', + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Скорость', + value: '${order.avgSpeed ?? '0'} км/ч', + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Тариф', + value: order.plan?.title ?? '—', + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Страховка', + value: order.isInsurance ? '1 руб' : '—', + ), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + // 🔹 ВИДЖЕТ СТРОКИ С ДЕТАЛЯМИ + Widget _DetailRow({required String label, required String value}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 14, + ), + ), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + String _formatDate(DateTime date) { + final localDate = date.toLocal(); + const months = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' + ]; + return '${localDate.day} ${months[localDate.month - 1]}, ${_formatTime(date)}'; + } + + String _formatTime(DateTime? date) { + if (date == null) return '—'; + final localDate = date.toLocal(); + return '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}'; + } + + BoundingBox _calculateBounds(List points) { + double minLat = points.first.latitude; + double minLng = points.first.longitude; + double maxLat = points.first.latitude; + double maxLng = points.first.longitude; + + for (var p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLng) minLng = p.longitude; + if (p.longitude > maxLng) maxLng = p.longitude; + } + + return BoundingBox( + southWest: Point(latitude: minLat, longitude: minLng), + northEast: Point(latitude: maxLat, longitude: maxLng), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/order_history_screen.dart b/lib/presentation/screens/order_history_screen.dart new file mode 100644 index 0000000..612b3f1 --- /dev/null +++ b/lib/presentation/screens/order_history_screen.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; +import '../../domain/entities/scooter_order.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../viewmodel/order_history_bloc.dart'; +import 'order_history_detail_screen.dart'; + +class OrderHistoryScreen extends StatelessWidget { + const OrderHistoryScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt()..add(OrderHistoryFetchRequested()), + child: const OrderHistoryView(), + ); + } +} + +class OrderHistoryView extends StatelessWidget { + const OrderHistoryView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'История поездок'), + ), + + const SizedBox(height: 16), + + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.status == OrderHistoryStatus.loading && state.orders.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.status == OrderHistoryStatus.failure) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Ошибка загрузки', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 8), + Text( + state.errorMessage ?? '', + style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + context.read().add(OrderHistoryFetchRequested()); + }, + child: const Text('Повторить'), + ), + ], + ), + ); + } + + if (state.status == OrderHistoryStatus.empty) { + return const _EmptyState(); + } + + final groupedOrders = _groupByMonth(state.orders); + + return RefreshIndicator( + onRefresh: () async { + context.read().add(OrderHistoryRefreshRequested()); + }, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: groupedOrders.length, + itemBuilder: (context, index) { + final monthKey = groupedOrders.keys.elementAt(index); + final orders = groupedOrders[monthKey]!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + _getMonthTitle(monthKey), + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ...orders.map((order) => _OrderCard(order: order)), + ], + ); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + Map> _groupByMonth(List orders) { + final Map> grouped = {}; + + for (var order in orders) { + final date = order.startAt ?? order.finishAt ?? DateTime.now(); + final localDate = date.toLocal(); + final monthKey = '${localDate.year}-${localDate.month.toString().padLeft(2, '0')}'; + + if (!grouped.containsKey(monthKey)) { + grouped[monthKey] = []; + } + grouped[monthKey]!.add(order); + } + + final sortedKeys = grouped.keys.toList()..sort((a, b) => b.compareTo(a)); + + return Map.fromEntries( + sortedKeys.map((key) => MapEntry(key, grouped[key]!)), + ); + } + + String _getMonthTitle(String monthKey) { + final parts = monthKey.split('-'); + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + + const months = [ + 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' + ]; + + return '${months[month - 1]} $year'; + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/history.png', + width: 380, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 280, + height: 280, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.history_outlined, + size: 120, + color: Colors.white38, + ), + ); + }, + ), + + const SizedBox(height: 40), + + const Text( + 'У вас пока нет завершенных поездок.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + + const SizedBox(height: 32), + + GradientButton( + text: 'Прокатиться', + showArrows: true, + height: 56, + width: double.infinity, + fontSize: 16, + onTap: () { + context.go('/home'); + }, + ), + ], + ), + ), + ); + } +} + +// 🔹 КАРТОЧКА ОДНОГО ЗАКАЗА +class _OrderCard extends StatelessWidget { + final ScooterOrder order; + + const _OrderCard({required this.order}); + + @override + Widget build(BuildContext context) { + final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now()); + final scooterNumber = order.scooter?.title ?? '№${order.scooterId}'; + final price = '${order.totalPrice?.toStringAsFixed(2)} BYN' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN'; + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => OrderHistoryDetailScreen(order: order), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.fromLTRB(4, 4, 16, 4), + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E).withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.electric_scooter, + color: Colors.white, + size: 48, + ); + }, + ), + ), + ), + + const SizedBox(width: 16), + + // 🔹 СТОЛБЕЦ 2: Дата, время, ID самоката + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Дата и время + Text( + date, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + // ID самоката + Row( + children: [ + Icon( + Icons.qr_code_2, + color: Colors.white.withOpacity(0.6), + size: 18, + ), + const SizedBox(width: 4), + Text( + scooterNumber, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + + + // 🔹 СТОЛБЕЦ 3: Цена + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 100, + alignment: Alignment.center, + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), // Опционально: скругление углов + ), + child: Text( + order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН', + style: TextStyle( + color: order.status == 'Paid' + ? Colors.greenAccent + : Colors.redAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + + const SizedBox(height: 8), + + Text( + price, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + + ], + ), + ), + ); + } + + // 🔹 ФОРМАТИРОВАНИЕ ДАТЫ (с корректным сравнением) + String _formatDate(DateTime date) { + // ✅ Конвертируем в локальное время + final localDate = date.toLocal(); + final now = DateTime.now(); + + // ✅ Сравниваем только дату (год, месяц, день), игнорируя время + final isToday = localDate.year == now.year && + localDate.month == now.month && + localDate.day == now.day; + + final yesterday = now.subtract(const Duration(days: 1)); + final isYesterday = localDate.year == yesterday.year && + localDate.month == yesterday.month && + localDate.day == yesterday.day; + + if (isToday) { + return 'Сегодня, ${_formatTime(localDate)}'; + } else if (isYesterday) { + return 'Вчера, ${_formatTime(localDate)}'; + } else { + return '${_formatDateFull(localDate)}, ${_formatTime(localDate)}'; + } + } + + String _formatDateFull(DateTime date) { + const months = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' + ]; + return '${date.day} ${months[date.month - 1]}'; + } + + String _formatTime(DateTime date) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } +} \ No newline at end of file diff --git a/lib/presentation/screens/payment_confirm_screen.dart b/lib/presentation/screens/payment_confirm_screen.dart new file mode 100644 index 0000000..8b05cba --- /dev/null +++ b/lib/presentation/screens/payment_confirm_screen.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../components/payment_option.dart'; +import '../components/sheet/payment_method_sheet.dart'; +import '../event/payment_confirm_event.dart'; +import '../event/payment_method_sheet_event.dart'; +import '../state/payment_confirm_state.dart'; +import '../viewmodel/payment_confirm_bloc.dart'; +import '../viewmodel/payment_method_sheet_bloc.dart'; + +class PaymentConfirmScreen extends StatelessWidget { + final int orderId; + final List photoIds; + + const PaymentConfirmScreen({ + super.key, + required this.orderId, + required this.photoIds, + }); + + @override + Widget build(BuildContext context) { + return _PaymentConfirmScreenContent(orderId: orderId, photoIds: photoIds); + } +} + +class _PaymentConfirmScreenContent extends StatelessWidget { + final int orderId; + final List photoIds; + + const _PaymentConfirmScreenContent({ + required this.orderId, + required this.photoIds, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Stack( + children: [ + Positioned( + bottom: 60, + left: 0, + right: 0, + child: Image.asset( + 'assets/wave.png', + fit: BoxFit.fitWidth, + color: Colors.white.withOpacity(0.1), + ), + ), + + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Завершение поездки'), + ), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: BlocConsumer( + listenWhen: (previous, current) { + return current.status != previous.status; + }, + + listener: (context, state) { + if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) { + context.go('/home'); + } else if (state.status == PaymentConfirmStatus.failure) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.errorMessage ?? 'Произошла ошибка при оплате', + style: const TextStyle(color: Colors.white), + ), + backgroundColor: Colors.redAccent.withOpacity(0.9), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(20), + duration: const Duration(seconds: 3), + ), + ); + } + }, + builder: (context, state) { + final order = state.order; + + if (state.status == PaymentConfirmStatus.loading && + order == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 30), + + _buildInfoLabel('Самокат:'), + _buildValueRow( + 'qr_icon.png', + "${order?.scooter?.number}", + ), + + const SizedBox(height: 24), + + _buildInfoLabel('Расстояние:'), + _buildValueRow( + 'distance_icon.png', + "${order?.mileage} km", + ), + + const SizedBox(height: 24), + + _buildInfoLabel('Начислено баллов:'), + _buildValueRow('points_icon.png', '2 балла'), + + const SizedBox(height: 24), + + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildInfoLabel('Стоимость поездки:'), + _buildValueRow( + 'money_icon.png', + '17,17 BYN', + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildInfoLabel('Оплачено:'), + _buildValueRow( + 'money_icon.png', + '10,00 BYN', + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 40), + + // 🔹 БЛОК СУММЫ К ОПЛАТЕ + Center( + child: Column( + children: [ + Text( + 'Сумма к оплате:', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 16, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/icons/money_icon.png', + width: 40, + ), + const SizedBox(width: 12), + const Text( + '7,17 BYN', + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 40), + + // 🔹 КАРТА ОПЛАТЫ + (state.useBalance || state.selectedCard != null) + ? Padding(padding: const EdgeInsets.symmetric(horizontal: 20), + child: PaymentOption( + title: state.useBalance ? 'Баланс' : state.selectedCard!.type, + subtitle: state.useBalance + ? '${state.userBalance.toStringAsFixed(2)} BYN' + : '****${state.selectedCard!.cardLastNumber}', + isSelected: true, + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + )..add(PaymentMethodSheetStarted()), + child: PaymentMethodSheet( + initialSelectedCard: state.useBalance ? null : state.selectedCard, + ), + ), + ); + + if (result != null) { + if (result is PaymentCard) { + context.read().add(PaymentCardChanged(result)); + } else if (result == 'balance') { + context.read().add(SelectBalancePressed()); + } + } + }, + ), + ) + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Container( + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 24, + ), + border: Border.all( + color: Colors.white.withOpacity( + 0.4, + ), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.pushReplacement( + '/home/payment-method-sheet', + ); + + }, + borderRadius: BorderRadius.circular( + 24, + ), + child: Center( + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const Text( + 'Способ оплаты', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white + .withOpacity(0.6), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white + .withOpacity(0.4), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white + .withOpacity(0.2), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(24.0), + child: + BlocConsumer( + listener: (context, state) { + if (state.status == PaymentConfirmStatus.success && + state.paymentCompleted) { + context.go('/home'); + } else if (state.status == + PaymentConfirmStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.errorMessage ?? 'Ошибка оплаты', + ), + ), + ); + } + }, + builder: (context, state) { + return GradientButton( + text: state.status == PaymentConfirmStatus.loading + ? 'Обработка...' + : 'Оплатить', + showArrows: true, + height: 56, + width: double.infinity, + onTap: (state.status == PaymentConfirmStatus.loading || (!state.useBalance && state.selectedCard == null)) + ? null + : () { + context.read().add( + PayRide( + cardId: state.useBalance ? null : state.selectedCard?.id, + isBalance: state.useBalance, + orderId: orderId, + photoIds: photoIds, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + // Вспомогательный метод для подзаголовков + Widget _buildInfoLabel(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text, + style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 15), + ), + ); + } + + // Вспомогательный метод для строк со значениями и иконками + Widget _buildValueRow(String iconName, String value) { + return Row( + children: [ + Image.asset('assets/icons/$iconName', width: 24, height: 24), + const SizedBox(width: 12), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } + + String _formatDuration(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/presentation/screens/payment_methods_screen.dart b/lib/presentation/screens/payment_methods_screen.dart new file mode 100644 index 0000000..0e2f13d --- /dev/null +++ b/lib/presentation/screens/payment_methods_screen.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../../domain/entities/payment_card.dart'; +import '../components/custom_app_bar.dart'; +import '../event/payment_methods_event.dart'; +import '../state/payment_methods_state.dart'; +import '../viewmodel/payment_methods_bloc.dart'; + +class PaymentMethodsScreen extends StatelessWidget { + const PaymentMethodsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Способы оплаты'), + ), + const SizedBox(height: 24), + Expanded( + child: BlocConsumer( + listener: (context, state) { + if (state.status == PaymentMethodsStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), + ); + } + }, + builder: (context, state) { + if (state.status == PaymentMethodsStatus.loading && state.cards.isEmpty) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF00D4AA))); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBalanceCard(context, state.balance), + const SizedBox(height: 20), + _buildCardsList(context, state), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBalanceCard(BuildContext context, int balance) { + return Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppColors.activeButtonGradient, + borderRadius: BorderRadius.circular(20), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Баланс', + style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + balance.toStringAsFixed(2), + style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 32, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + Text( + 'баллов', + style: TextStyle(color: const Color(0xFF0A0F2E).withOpacity(0.7), fontSize: 14), + ), + ], + ), + const SizedBox(height: 20), + _buildTopUpBalanceButton(context), + ], + ), + Positioned( + right: -30, + top: -50, + child: Image.asset('assets/icons/card-screen.png', width: 100, height: 100, fit: BoxFit.contain), + ), + ], + ), + ); + } + + Widget _buildCardsList(BuildContext context, PaymentMethodsState state) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E).withOpacity(0.65), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Карты', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + + if (state.cards.isEmpty && state.status == PaymentMethodsStatus.success) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Text('У вас пока нет привязанных карт', style: TextStyle(color: Colors.white70)), + ), + + ...state.cards.asMap().entries.map((entry) { + final index = entry.key; + final card = entry.value; + return Column( + children: [ + _CardItem( + card: card, + onDelete: () => context.read().add(PaymentMethodsDeleteCard(card.id)), + onMakeMain: card.isMain + ? null + : () => context.read().add(PaymentMethodsSetMainCard(card.id)), + ), + if (index < state.cards.length - 1) const SizedBox(height: 16), + ], + ); + }).toList(), + + const SizedBox(height: 20), + _buildAddCardButton(context), + ], + ), + ); + } + + Widget _buildAddCardButton(BuildContext context) { + return Container( + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E), + borderRadius: BorderRadius.circular(24), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.go('/home/payment-methods/add-card'), + borderRadius: BorderRadius.circular(24), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(Icons.credit_card, color: Color(0xFF00D4AA), size: 24), + SizedBox(width: 12), + Expanded( + child: Text( + 'Привязать карту', + style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + Icon(Icons.add, color: Color(0xFF00D4AA), size: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTopUpBalanceButton(BuildContext context) { + return Container( + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF0A0F2E), + borderRadius: BorderRadius.circular(24), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.go('/home/payment-methods/top-up'), + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Image.asset("assets/icons/money_icon.png", width: 24, height: 24), + SizedBox(width: 12), + Expanded( + child: Text( + 'Пополнить баланс', + style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + Icon(Icons.add, color: Color(0xFF00D4AA), size: 24), + ], + ), + ), + ), + ), + ); + } +} + + + + +class _CardItem extends StatelessWidget { + final PaymentCard card; + final VoidCallback onDelete; + final VoidCallback? onMakeMain; + + const _CardItem({ + required this.card, + required this.onDelete, + this.onMakeMain, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Image.asset( + _getDefaultIconPath(card.type), + width: 40, + height: 40, + fit: BoxFit.contain, + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: onMakeMain, // Нажатие на текст карты делает её основной + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${card.type} ****${card.cardLastNumber}', + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + card.isMain ? 'основная' : 'сделать основной', + style: TextStyle( + color: card.isMain ? const Color(0xFF66E3C4) : Colors.white.withOpacity(0.5), + fontSize: 12, + ), + ), + ], + ), + ), + ), + GestureDetector( + onTap: onDelete, + child: const Icon(Icons.close, color: Color(0xFF00D4AA), size: 20), + ), + ], + ); + } + + String _getDefaultIconPath(String cardType) { + switch (cardType) { + case 'Belcard': return 'assets/icons/belcard.png'; + case 'Visa': return 'assets/icons/visa.png'; + case 'Maestro': return 'assets/icons/maestro.png'; + case 'Mir': return 'assets/icons/mir.png'; + case 'Mastercard': return 'assets/icons/mastercard.png'; + default: return 'assets/icons/belcard.png'; + } + } +} \ No newline at end of file diff --git a/lib/presentation/screens/phone_login_screen.dart b/lib/presentation/screens/phone_login_screen.dart new file mode 100644 index 0000000..961d8db --- /dev/null +++ b/lib/presentation/screens/phone_login_screen.dart @@ -0,0 +1,183 @@ +import 'package:be_happy/presentation/event/spalsh_event.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/app_colors.dart'; +import '../components/code_dots.dart'; +import '../components/gradient_button.dart'; +import '../event/verify_code_event.dart'; +import '../state/verify_code_state.dart'; +import '../viewmodel/verify_code_bloc.dart'; +import 'pin_login_screen.dart'; + +class PhoneLoginScreen extends StatefulWidget { + final String phoneNumber; + final String tempToken; + + const PhoneLoginScreen({ + Key? key, + required this.phoneNumber, + required this.tempToken, + }) : super(key: key); + + @override + State createState() => _PhoneLoginScreenState(); +} + +class _PhoneLoginScreenState extends State { + final TextEditingController codeController = TextEditingController(); + final FocusNode codeFocusNode = FocusNode(); + bool _isFirstTry = true; + + @override + void initState() { + super.initState(); + context.read().add( + VerifyCodeStarted( + phoneNumber: widget.phoneNumber, + tempToken: widget.tempToken, + ), + ); + + codeController.addListener(() { + context.read().add(CodeChanged(codeController.text)); + + if (codeController.text.length == 6) { + Future.delayed(const Duration(milliseconds: 150), () { + context.read().add(VerifyCodeSubmitted()); + }); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusScope.of(context).requestFocus(codeFocusNode); + }); + } + + void openKeyboard() { + if (codeFocusNode.hasFocus) { + codeFocusNode.unfocus(); + } + + Future.delayed(const Duration(milliseconds: 50), () { + FocusScope.of(context).requestFocus(codeFocusNode); + }); + } + + @override + void dispose() { + codeController.dispose(); + codeFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.isSuccess) { + context.go("/pin"); + } else if (state.error != null) { + setState(() { + _isFirstTry = false; + }); + codeController.clear(); + FocusScope.of(context).requestFocus(codeFocusNode); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.error!))); + } else if (state.isBlocked) { + context.go("/block"); + } + }, + builder: (context, state) { + return Scaffold( + resizeToAvoidBottomInset: true, + body: Container( + width: double.infinity, + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Stack( + children: [ + Positioned( + top: 310, + left: 0, + right: 0, + child: Image.asset("assets/wave.png", fit: BoxFit.cover), + ), + Column( + children: [ + const SizedBox(height: 60), + const Text( + "Код отправлен на номер", + style: TextStyle( + fontSize: 18, + color: AppColors.whiteText, + ), + ), + const SizedBox(height: 6), + Text( + widget.phoneNumber, + style: const TextStyle( + fontSize: 20, + color: AppColors.whiteText, + ), + ), + const SizedBox(height: 35), + GestureDetector( + onTap: openKeyboard, + child: CodeDots(code: state.code, length: 6), + ), + const SizedBox(height: 8), + + if (!_isFirstTry) + Text( + "Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}", + style: const TextStyle( + color: AppColors.pinError, + fontSize: 14, + ), + ), + + const SizedBox(height: 40), + GradientButton( + text: state.secondsLeft == 0 + ? "Отправить код повторно" + : "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.", + onTap: state.secondsLeft == 0 + ? () { + context.read().add( + ResendCodePressed(), + ); + } + : () {}, + enabled: state.secondsLeft == 0, + width: 250, + ), + const Spacer(), + SizedBox( + height: 50, + width: double.infinity, + child: Opacity( + opacity: 0.0, + child: TextField( + controller: codeController, + focusNode: codeFocusNode, + keyboardType: TextInputType.number, + maxLength: 6, + autofocus: true, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/phone_screen.dart b/lib/presentation/screens/phone_screen.dart new file mode 100644 index 0000000..b44760a --- /dev/null +++ b/lib/presentation/screens/phone_screen.dart @@ -0,0 +1,248 @@ +import 'package:be_happy/presentation/event/spalsh_event.dart'; +import 'package:be_happy/presentation/state/splash_state.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/app_colors.dart'; +import '../components/app_checkbox.dart'; +import '../components/gradient_button.dart'; +import '../event/auth_event.dart'; +import '../state/auth_state.dart'; +import '../viewmodel/auth_bloc.dart'; +import 'phone_login_screen.dart'; + +class PhoneScreen extends StatefulWidget { + const PhoneScreen({Key? key}) : super(key: key); + + @override + State createState() => _PhoneScreenState(); +} + +class _PhoneScreenState extends State { + final TextEditingController _phoneController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.isSuccess) { + context.go("/verify?phone=+375${state.phone}"); + context.read().add(AuthStarted()); + } else if (state.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error!)), + ); + } + }, + builder: (context, state) { + final isAdult = state.isAdult ?? false; + final privacyAccepted = state.privacyAccepted ?? false; + + return Scaffold( + backgroundColor: Colors.transparent, + resizeToAvoidBottomInset: false, + body: Container( + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 30), + + Image.asset( + 'assets/wave.png', + width: double.infinity, + fit: BoxFit.cover, + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 20), + + const Text( + 'Введите номер телефона', + style: TextStyle( + color: AppColors.whiteText, + fontSize: 20, + ), + ), + + const SizedBox(height: 40), + + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + style: const TextStyle(color: AppColors.whiteText), + decoration: InputDecoration( + filled: true, + fillColor: AppColors.disabledButtonColor, + prefixText: '+375 ', + prefixStyle: const TextStyle( + color: AppColors.whiteText, + fontSize: 16, + ), + hintText: 'Номер телефона', + hintStyle: const TextStyle(color: AppColors.hint), + suffixIcon: _phoneController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, + color: AppColors.whiteText), + onPressed: () { + _phoneController.clear(); + context.read().add(PhoneChanged("")); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) { + context.read().add(PhoneChanged(value)); + }, + ), + + const SizedBox(height: 24), + + Row( + children: [ + AppCheckbox( + value: isAdult, + onChanged: (bool? value) { + if (value != null) { + context.read().add(IsAdultChanged(value)); + } + }, + isError: state.error != null && !isAdult, + ), + const SizedBox(width: 12), + Flexible( + child: Text.rich( + TextSpan( + text: 'Подтверждаю, что мне исполнилось 18 лет и я принял ', + style: const TextStyle( + color: AppColors.white70, + fontSize: 12, + ), + children: [ + WidgetSpan( + child: ClickableText( + text: 'Условия использования сервиса', + onTap: () => context.push('/license-agreement'), + ), + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 14), + + Row( + children: [ + AppCheckbox( + value: privacyAccepted, + onChanged: (bool? value) { + if (value != null) { + context.read().add(PrivacyAcceptedChanged(value)); + } + }, + isError: state.error != null && !privacyAccepted, + ), + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + text: 'Подтверждаю, что я ознакомился с ', + style: const TextStyle( + color: AppColors.white70, + fontSize: 12, + ), + children: [ + WidgetSpan( + child: ClickableText( + text: 'Политикой обработки персональных данных', + onTap: () => context.push('/privacy-policy'), + ), + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 47), + + GradientButton( + text: "Получить код", + enabled: state.phone.isNotEmpty && + isAdult && + privacyAccepted && + !state.isSubmitting, + onTap: state.isSubmitting + ? null + : () { + context.read().add(SubmitPhonePressed()); + }, + showArrows: true, + height: 50, + width: 340, + fontSize: 14, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +// 🔹 Кликабельный текст для политики +class ClickableText extends StatelessWidget { + final String text; + final VoidCallback onTap; + + const ClickableText({ + super.key, + required this.text, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Text( + text, + style: const TextStyle( + color: Colors.blueAccent, + decorationColor: Colors.blueAccent, + decoration: TextDecoration.underline, + fontSize: 12 + ), + ), + ); + } +} diff --git a/lib/presentation/screens/pin_login_screen.dart b/lib/presentation/screens/pin_login_screen.dart new file mode 100644 index 0000000..aaa55f3 --- /dev/null +++ b/lib/presentation/screens/pin_login_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../components/code_dots.dart'; +import '../event/spalsh_event.dart'; +import '../viewmodel/pin_bloc.dart'; +import '../event/pin_event.dart'; +import '../state/pin_state.dart'; +import '../viewmodel/splash_bloc.dart'; + +class PinLoginScreen extends StatefulWidget { + const PinLoginScreen({super.key}); + + @override + State createState() => _PinLoginScreenState(); +} + +class _PinLoginScreenState extends State { + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + final text = controller.text; + context.read().add(PinDigitChanged(text)); + + if (text.length == 6) { + context.read().add(PinSubmitted(text)); + } + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is PinSuccess) { + context.read().add(PinVerificationSuccess()); + context.go("/home"); + } + if (state.error != null) { + controller.clear(); + focusNode.requestFocus(); + } + }, + child: Scaffold( + body: GestureDetector( + onTap: () => focusNode.requestFocus(), + child: Container( + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: SafeArea( + child: Stack( + children: [ + Positioned( + top: 310, left: 0, right: 0, + child: Image.asset("assets/wave.png", fit: BoxFit.cover), + ), + BlocBuilder( + builder: (context, state) { + final title = state is PinCreateInProgress + ? "Создайте PIN-код" + : "Введите PIN-код"; + + final subtitle = state is PinCreateInProgress + ? "Запомните PIN-код" + : "Введите ваш пароль для входа"; + + if (state is PinLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + children: [ + const SizedBox(height: 60), + Text( + title, + style: const TextStyle( + color: AppColors.whiteText, + fontSize: 20, + ), + ), + const SizedBox(height: 35), + + Stack( + alignment: Alignment.center, + children: [ + CodeDots( + code: state.pin, + length: 6, + ), + SizedBox( + width: 200, + height: 50, + child: TextField( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.number, + maxLength: 6, + showCursor: false, + decoration: const InputDecoration( + border: InputBorder.none, + counterText: "", + ), + style: const TextStyle(color: Colors.transparent), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Блок ошибки + if (state.error != null) + Text( + state.error!, + style: const TextStyle( + color: AppColors.pinError, + fontSize: 14, + ), + ), + + const SizedBox(height: 40), + + Text( + subtitle, + style: const TextStyle(color: AppColors.white70), + ), + + const SizedBox(height: 40), + + ], + ); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/privacy_policy_screen.dart b/lib/presentation/screens/privacy_policy_screen.dart new file mode 100644 index 0000000..c4c6430 --- /dev/null +++ b/lib/presentation/screens/privacy_policy_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; + +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: ''), + ), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Политика конфиденциальности', + style: const TextStyle( + color: Colors.white, + fontSize: 18 + ), + ), + + const SizedBox(height: 16), + + _buildParagraph( + 'Настоящая Политика конфиденциальности (далее - Политика) действует в отношении всей информации, которую Общество с ограниченной ответственностью “БИХЕППИБЕЛ”, зарегистрированное по адресу: Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А УНП: 392050943 Регистрационный номер: 392026683, (далее – Компания), получает о Пользователе в процессе регистрации, авторизации и иного использования Сайта https://behappybel.by, (далее - Сайта), Приложения Be Happy, Сервиса Be Happy в соответствии с размещенными на Сайте и в Приложении Be Happy Пользовательским соглашением и Договором присоединения.\n\n' + 'Выраженное в соответствии с Политикой согласие Пользователя на обработку предоставляемых им Компании персональных данных и иной информации, считается, одновременно, предоставленным Пользователем указанным в Политике третьим лицам, привлекаемым Компанией для содействия в исполнении Пользовательского соглашения и Договора аренды.\n\n' + 'Использование Сайта, Приложения Be Happy, Сервиса Be Happy (в том числе, осуществление Пользователем регистрации, авторизации) означает безоговорочное согласие Пользователя с Политикой и всеми указанными в ней условиями обработки его персональных данных и иной информации; в случае несогласия с Политикой и всеми ее условиями Пользователь должен воздержаться от использования Сайта, Приложения Be Happy, Сервиса Be Happy.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('1. Персональные данные и иная информация Пользователя, которую получает и обрабатывает Компания\n'), + _buildParagraph( + '1.1. При регистрации, авторизации Пользователя, а также при использовании Сайта, Приложения Be Happy, Сервиса Be Happy, осуществлении оплат, проведении опросов, рассылке информационных и рекламных сообщений и во всех иных случаях, предусмотренных Пользовательским соглашением и Договором присоединения, Компания может запросить у Пользователя (п.1.1.1. Политики) и/или получить автоматически (п.1.1.2. Политики) следующую информацию о Пользователе:\n\n' + '1.1.1. имя, номер мобильного телефона, адрес электронной почты, информацию о логине и пароле для доступа к отдельным функциям Сайта, Приложения Be Happy, Сервиса Be Happy, историю пользования Сервисом Be Happy (информацию о количестве, стоимости, времени и порядке произведенных Пользователями заказов на услуги Сервиса Be Happy и их оплате, в том числе данные о банковском счете и/или счете банковской карты), информацию об участии в рекламных акциях, информацию о подписке на информационную рассылку или материалы службы поддержки), реквизиты банка для возврата денежных средств, а также иные данные и информация;\n\n' + '1.1.2. информация, которая автоматически передается Компании в процессе использования Сайта, Приложения Be Happy, Сервиса Be Happy, с помощью установленного на устройстве Пользователя программного обеспечения, в том числе IP-адрес, информация из cookie и tracking bugs, информация о стране и (или) городе нахождения Пользователя, информация об Интернет-браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy), время доступа, адрес запрашиваемой страницы об устройствах Пользователя, с помощью которых осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy.\n\n' + '1.2. Настоящая Политика применима только к Сайту, Приложению Be Happy, Сервису Be Happy. Компания не контролирует и не несет ответственность за сайты и программное обеспечение третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на Сайте, в Приложении Be Happy. На иных сайтах третьих лиц у Пользователя может собираться или запрашиваться иная информация, а также могут совершаться иные действия, за которые Компания не несет ответственности.\n\n' + '1.3. Компания исходит из того, что Пользователь является совершеннолетним дееспособным лицом, соответствующим требованиям Пользовательского соглашения и Договора присоединения, предоставляет достоверную и достаточную информацию и поддерживает эту информацию в актуальном состоянии. Компания вправе осуществить проверку предоставленной Пользователем информации в соответствии с положениями Пользовательского соглашения и Договора присоединения. В случае предоставления Пользователем недостоверной информации, Компания имеет право приостановить либо отменить регистрацию и/или отказать Пользователю в предоставлении доступа к Сайту, Приложению Be Happy, Сервису Be Happy. За предоставление недостоверной информации и возникшие вследствие этого негативные последствия Компания и/или иные третьи лица ответственности не несут. Если использование Сайта, Приложения Be Happy, Сервиса Be Happy осуществило несовершеннолетнее и/или недееспособное лицо, то ответственность за такое несанкционированное Компанией использование несут родители, усыновители и иные законные представители несовершеннолетнего и/или недееспособного лица.\n\n' + '1.4 Пользователь дает свое согласие на осуществление Арендодателем записи разговоров Пользователя со Службой поддержки и предоставление такой записи третьим лицам.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('2. Цели сбора и обработки данных и иной информации Пользователя\n'), + _buildParagraph( + '2.1. Компания использует данные и иную информацию Пользователя для целей заключения и исполнения Пользовательского соглашения, заключения и исполнения Договора присоединения, оказания дополнительных услуг, повышения качества сервиса, участия Пользователя в проводимых Компанией акциях, опросах, исследованиях (включая, но не ограничиваясь проведением опросов, исследований посредством электронной, телефонной и сотовой связи), принятия решений или совершения иных действий, порождающих юридические последствия в отношении Пользователя или других лиц, представления Пользователю информации об оказываемых Компанией услугах, предоставления Компанией консультационных услуг. Указанные цели использования персональных данных распространяются на всю информацию, указанную в пункте 1.1 Политики.\n\n' + '2.2. Цели сбора и обработки персональных данных включают, без ограничений, следующие:\n\n' + '2.2.1. регистрацию, идентификацию и авторизацию Пользователя в рамках Сервиса Be Happy;\n' + '2.2.2. заключение и исполнение Пользовательского соглашения и Договора присоединения;\n' + '2.2.3. предоставление Пользователю Сервиса Be Happy, а также любого дополнительного функционала в рамках Сервиса Be Happy;\n' + '2.2.4. обработка запросов Пользователей Компанией в рамках Сервиса Be Happy;\n' + '2.2.5. анализ и исследования возможностей улучшения Сервиса Be Happy;\n' + '2.2.6. рассылка новостей и информации о продуктах, услугах, специальных предложениях, связанных с Сервисом Be Happy;\n' + '2.2.7. рассылка служебных сообщений (например, для информирования о статусе аренды самоката, восстановления/изменения логина и пароля Пользователя и пр.);\n' + '2.2.8. предотвращение и выявление мошенничества и незаконного использования Сервиса Be Happy;\n' + '2.2.9. проведение статистических и иных исследований на основе обезличенных данных.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('3. Условия, способы и порядок обработки персональных данных и иной персональной информации Пользователя\n'), + _buildParagraph( + '3.1. Компания использует персональные данные и иную информацию Пользователя только для целей, указанных в Политике и в соответствии с Политикой.\n\n' + '3.2. В отношении персональных данных и иной информации Пользователя Компанией соблюдается конфиденциальность.\n\n' + '3.3. Компания не будет раскрывать третьим лицам, распространять, продавать или иным образом распоряжаться полученными персональными данными и иной информацией, кроме как для целей, способами и в пределах, предусмотренных настоящей Политикой.\n\n' + '3.4. Обработка персональных и иных данных Пользователя осуществляется Компанией в объеме, который необходим для достижения каждой из целей, указанных в разделе 2 Политики, следующими возможными способами: сбор, запись (в том числе на электронные носители), систематизация, накопление, хранение, составление перечней, маркировка, уточнение (обновление, изменение), извлечение, использование, передача (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение, трансграничная передача персональных данных, получение изображения путем фотографирования, а также осуществление любых иных действий с персональными данными Пользователя с учетом применимого права. Компания вправе осуществлять обработку персональных и иных данных Пользователя как с использованием автоматизированных средств обработки персональных данных Пользователя, так и без использования средств автоматизации.\n\n' + '3.5. Компания вправе передавать предоставленные ей Пользователем персональные данные для их обработки (давать поручение на обработку) (в объеме, необходимом для выполнения Компанией своих обязательств) третьим лицам, в том числе, организациям, которые привлекаются Компанией для осуществления информационной отправки сообщений посредством электронной почты/операторов мобильной связи, осуществляют списание/зачисление денежных средств с/на банковской(-ую) карты(- у)/расчетный счет - кредитным организациям (банкам), платежным системам, операторам мобильной связи, курьерским службам, организациями почтовой связи, включая трансграничную передачу персональных данных Пользователя в письменной либо электронной форме, в случаях и в порядке, предусмотренном соответствующими договорами с указанными третьими лицами, правилами Компании, применимым правом.\n\n' + '3.6. Компания также вправе передавать предоставленные ей Пользователем персональные данные государственным органам, суду, иным уполномоченным органам и организациям, в случаях и в порядке, когда это требуется в соответствии с применяемым к Политике правом.\n\n' + '3.7. Компания гарантирует добросовестную и законную обработку персональных и иных данных Пользователя в соответствии с предусмотренными в разделе 2 Политики целями.\n\n' + '3.8. Компания гарантирует незамедлительное обновление данных Пользователя в случае предоставления им обновленных данных.\n\n' + '3.9. Согласие на обработку персональных данных и иных данных дается Пользователем на бессрочной основе, либо до истечения сроков хранения соответствующей информации или документов, содержащих вышеуказанную информацию, определяемых в соответствии с применимым к Политике правом. По истечении указанного срока персональные данные подлежат уничтожению Компанией.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('4. Изменение или удаление информации Пользователем. Отзыв согласия на обработку персональных данных\n\n'), + _buildParagraph( + '4.1. Пользователь может в любой момент изменить (обновить, дополнить) предоставленные им персональные данные и иную информацию обратившись к Компании, например, через службу поддержки или с использованием контактов, указанных на Сайте, в Приложении Be Happy с запросом об изменении (обновлении, дополнении) предоставленной им ранее информации (Компания изменяет (обновляет, дополняет) предоставленную Пользователем информацию только после проведения применяемой в Компании на момент соответствующего обращения процедурой идентификации Пользователя).\n\n' + '4.2. Пользователь вправе отозвать свое согласие на обработку персональных данных путем направления соответствующего письменного уведомления Компании не менее чем за 30 (тридцать календарных) дней до момента отзыва согласия, при этом Пользователь признает и понимает, что доступ к пользованию Сервисами Сайта и Приложения Be Happy, Сервису Be Happy не будет предоставляться Компанией с того момента, когда Компания лишилась возможности обрабатывать персональные данные Пользователя.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('5. Защита информации Пользователей\n'), + _buildParagraph( + '5.1. Компания обеспечивает принятие необходимых и достаточных организационных и технических мер для защиты персональных данных и иной информации Пользователей от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий с ней третьих лиц.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('6. Файлы cookies и tracking bugs\n'), + _buildParagraph( + '6.1. Для улучшения качества предоставления Сервиса Be Happy Компания может использовать (временные и постоянные) cookie-файлы, tracking bugs и/или другие технологии сбора не носящих личный характер данных (например, IP-адрес, тип браузера и данные о провайдере службы Интернет (ISP), а также (для Пользователей, которые пользуются услугами Компании через мобильное устройство), уникальный идентификатор устройства, данные об операционной системе и координаты с целью учёта количества Пользователей и их поведения при пользовании Сервисом Be Happy. Для повышения удобства Пользователей Компания вправе собирать и обрабатывать информацию об общем количестве операций, страниц, просмотренных Пользователем, ссылающихся/исходных страниц, типе платформы, дате/времени фиксирования информации, количестве и месте просмотров данной страницы, просмотра страницы и использованных (поисковых) слов.\n\n' + '6.2. Информация о cookies и tracking bugs:\n\n' + '6.2.1.Файл «cookie» - небольшой текстовый файл, отправляемый на браузер устройства Пользователя с используемого Компанией сервера. Cookies содержат информацию, которая позже может быть использована Компанией. Браузер будет хранить эту информацию и передавать ее обратно с каждым запросом Пользователя Компании. Одни значения cookies могут храниться только в течение одной сессии и удаляются после закрытия браузера. Другие, установленные на некоторый период времени, записываются в специальный файл на жестком диске и хранятся на устройстве Пользователя. Cookies используются для идентификации, отслеживания сессий (поддержания состояния) и сохранения информации о Пользователе, включая предпочтения при пользовании Сервисом Be Happy. Используемые Компанией сookies собирают только анонимные данные.\n\n' + '6.2.2. Файл tracking bugs - это графические объекты, встроенные в веб-страницы или в сообщения e-mail. Tracking bugs используются с различными целями включные отчёты о количестве Пользователей. Используемые Компанией tracking bugs собирают только анонимные данные.\n\n' + '6.3. Компания может использовать cookies и tracking bugs в целях контроля использования Сервиса Be Happy,сбора информации неличного характера о Пользователе, сохранения предпочтений и другой информации на устройстве Пользователя для того, чтобы сэкономить время Пользователя, необходимое для многократного введения в формах Сайта, Приложения Be Happy одной и той же информации, а также в целях отображения содержания в ходе последующих посещений Пользователем Сайта, Приложения Be Happy. Информация, полученная посредством cookies и tracking bugs, также может использоваться Компанией для статистических исследований, направленных на корректировку содержания Сайта, Приложения Be Happy в соответствии с предпочтениями Пользователя.\n\n' + '6.4. Компания может предоставить Пользователю возможность изменить настройки приема файлов cookies и tracking bugs в настройках своего браузера или отключить их полностью, однако в таком случае некоторые функции Сервиса Be Happy могут работать некорректно.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('7. Внесение изменений в Политику. Согласие Пользователя с Политикой\n'), + _buildParagraph( + '7.1. Пользователь признает и соглашается, что регистрация Пользователя на Сайте, в Приложении Be Happy и последующее использование Сервиса Be Happy, любых его служб, функционала означает безоговорочное согласие Пользователя со всеми пунктами настоящей Политики и безоговорочное принятие ее условий.\n\n' + '7.2. Продолжение Пользователем использования Сервиса Be Happy после любых изменений и/или дополнений Политики означает его согласие с такими изменениями и/или дополнениями.\n\n' + '7.3. Пользователь обязуется регулярно знакомиться с содержанием Политики в целях своевременного ознакомления с ее изменениями/дополнениями.\n\n' + '7.4. Компания оставляет за собой право по своему усмотрению изменять и (или) дополнять Политику в любое время без предварительного и (или) последующего уведомления Пользователя. Новая редакция Политики вступает в силу с момента ее размещения на Сайте и/или в Приложении Be Happy, если иное не предусмотрено новой редакцией Политики. Действующая редакция Политики всегда доступна на Сайте и/или в Приложении Be Happy.\n\n' + 'Уважаемый Пользователь, если Вы не согласны с положениями Политики, откажитесь от использования Сайта, Приложения Be Happy, Сервиса Be Happy.', + ), + + const SizedBox(height: 24), + + _buildSectionHeader('8. Заключительные положения\n'), + _buildParagraph( + '8.1. К Политике и возникающими в связи с применением Политики отношениям между Пользователями и Компанией подлежит применению право Республики Беларусь.\n\n' + '8.2. Все возможные споры по поводу настоящей Политики конфиденциальности и отношений между пользователем и Сервисом будут разрешаться по нормам белорусского права в суде по месту нахождения Администрации сайта, если иное прямо не предусмотрено законодательством РБ.\n\n' + '8.3. Соглашаясь с условиями Политики, Пользователь дает согласие на обработку персональных и иных данных своей волей и в своем интересе.\n\n' + '8.4. Отказ от предоставления персональных данных и иной необходимой для использования Сайта, Приложения Be Happy, Сервиса Be Happy информации влечет невозможность для Компании предоставлять Пользователю Сервиса Be Happy.', + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildParagraph(String text) { + return Text( + text, + style: const TextStyle( + color: Color(0xFFD1D1D6), + fontSize: 14, + height: 1.5, + ), + ); + } + + Widget _buildSectionHeader(String text) { + return Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + height: 1.4, + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/profile_screen.dart b/lib/presentation/screens/profile_screen.dart new file mode 100644 index 0000000..c1ce386 --- /dev/null +++ b/lib/presentation/screens/profile_screen.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import '../../core/app_colors.dart'; +import '../../domain/entities/user_profile.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../viewmodel/profile_bloc.dart'; +import 'edit_profile_screen.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../state/profile_state.dart'; +import '../event/profile_event.dart'; + +class ProfileScreen extends StatefulWidget { + const ProfileScreen({super.key}); + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + bool notificationsEnabled = false; + XFile? _avatarImage; + + Future _pickImage() async { + try { + final pickedImage = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + + if (pickedImage == null) return; + + context.read().add( + ProfilePhotoUpdated(File(pickedImage.path)), + ); + + } catch (e) { + print("Error picking or uploading image: $e"); + } + } + + Future _openEditProfile(UserProfile profile) async { + context.go("/home/profile/edit"); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (state.error != null) { + return Center( + child: Text( + state.error!, + style: const TextStyle(color: Colors.white), + ), + ); + } + + final profile = state.profile!; + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 16), + CustomAppBar(title: 'Профиль'), + const SizedBox(height: 32), + Stack( + alignment: Alignment.topRight, + children: [ + + CircleAvatar( + radius: 60, + backgroundColor: AppColors.checkboxFill, + backgroundImage: (profile.avatarUrl != null && profile.avatarUrl!.isNotEmpty) + ? NetworkImage("${profile.avatarUrl!}?v=${DateTime.now().minute}") + : null, + child: (profile.avatarUrl == null || profile.avatarUrl!.isEmpty) + ? Text( + profile.name.isNotEmpty ? profile.name[0].toUpperCase() : '', + style: const TextStyle(fontSize: 50, color: AppColors.darkBlue), + ) + : null, + ), GestureDetector( + onTap: _pickImage, + child: Container( + margin: const EdgeInsets.only(top: 0, right: 0), + child: Image.asset( + 'assets/icons/edit.png', + width: 24, + height: 24, + ), + ), + ), + ], + ), + const SizedBox(height: 32), + _ProfileInfoBlock( + profile: profile, + onEditTap: () => context.go("/home/profile/edit"), + ), + const SizedBox(height: 24), + _SettingsBlock( + notificationsEnabled: notificationsEnabled, + onNotificationsChanged: (v) => + setState(() => notificationsEnabled = v), + ), + const SizedBox(height: 24), + ], + ), + ); + }, + ), + ), + ), + ); + } +} + +class _ProfileInfoRow extends StatelessWidget { + final IconData icon; + final String value; + final Widget? trailing; + + const _ProfileInfoRow({ + required this.icon, + required this.value, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: Colors.white), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ), + ); + } +} + +class _ProfileInfoBlock extends StatelessWidget { + final UserProfile profile; + final VoidCallback onEditTap; + + const _ProfileInfoBlock({required this.profile, required this.onEditTap}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Color(0xFF141530), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Личные данные', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 16), + + _ProfileInfoRow(icon: Icons.person, value: profile.name), + _ProfileInfoRow(icon: Icons.calendar_today, value: profile.birthDate), + _ProfileInfoRow( + icon: Icons.phone, + value: profile.phone, + trailing: const Icon(Icons.lock, color: Colors.white70, size: 16), + ), + _ProfileInfoRow(icon: Icons.email, value: profile.email), + + const SizedBox(height: 8), + + Align( + alignment: Alignment.center, + child: OutlinedButton( + onPressed: onEditTap, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Редактировать', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + const SizedBox(width: 4), + Icon(Icons.edit, size: 16, color: AppColors.smsDigit), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + final String title; + final String? value; + final Widget? trailing; + final VoidCallback? onTap; + + const _SettingsRow({ + required this.title, + this.value, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + + if (value != null) + Text( + value!, + style: const TextStyle(color: AppColors.white70, fontSize: 13), + ), + + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ), + ), + ); + } +} + +class _SettingsBlock extends StatelessWidget { + final bool notificationsEnabled; + final ValueChanged onNotificationsChanged; + + const _SettingsBlock({ + required this.notificationsEnabled, + required this.onNotificationsChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Color(0xFF141530), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Настройки', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 16), + + _SettingsRow( + title: 'Уведомления', + trailing: Transform.scale( + scale: 0.8, + child: Switch( + value: notificationsEnabled, + onChanged: onNotificationsChanged, + activeColor: AppColors.checkboxFill, + ), + ), + ), + + _SettingsRow( + title: 'Тема приложения', + value: 'Системная', + trailing: const Icon(Icons.chevron_right, color: Colors.white70), + onTap: () {}, + ), + + _SettingsRow( + title: 'Язык', + value: 'Русский', + trailing: const Icon(Icons.chevron_right, color: Colors.white70), + onTap: () {}, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/promo_code_screen.dart b/lib/presentation/screens/promo_code_screen.dart new file mode 100644 index 0000000..d9a510b --- /dev/null +++ b/lib/presentation/screens/promo_code_screen.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; + +class PromoCodeScreen extends StatefulWidget { + const PromoCodeScreen({super.key}); + + @override + State createState() => _PromoCodeScreenState(); +} + +class _PromoCodeScreenState extends State { + final TextEditingController promoController = TextEditingController(); + bool isError = false; + + void _activatePromo() { + if (promoController.text == 'G17N160') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Промокод активирован!')), + ); + } else { + setState(() { + isError = true; + }); + } + } + + void _retry() { + setState(() { + isError = false; + promoController.clear(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + CustomAppBar(title: 'Промокоды'), + const SizedBox(height: 32), + + Container( + padding: const EdgeInsets.all(25), + decoration: BoxDecoration( + color: Color(0xFF141530), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'У вас есть промокод?', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height:24), + const Text( + 'Введите промокод и получите скидку на поездку', + style: TextStyle( + color: AppColors.white70, + fontSize: 16, + ), + ), + const SizedBox(height: 20), + TextField( + controller: promoController, + style: TextStyle( + color: isError ? Colors.red : Colors.white, + ), + decoration: InputDecoration( + hintText: 'Введите промокод', + hintStyle: const TextStyle(color: AppColors.white70), + filled: true, + fillColor: Colors.white.withOpacity(0.1), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide(color: AppColors.smsDigit, width: 1.5), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + side: BorderSide(color: AppColors.white70.withOpacity(0.4)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'Отмена', + style: TextStyle(color: AppColors.white70), + ), + ), + ), + const SizedBox(width: 22), + Expanded( + child: ElevatedButton( + onPressed: _activatePromo, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + backgroundColor: AppColors.activeButtonGradient.colors[0], + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'Активировать', + style: TextStyle(color: AppColors.activeButtonText), + ), + ), + ), + ], + ), + ], + ), + ), + + const Spacer(), + ], + ), + ), + ), + + + Image.asset('assets/promo_bottom.png', + width: double.infinity, + fit: BoxFit.contain, + alignment: Alignment.center, + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/qr_scan_info_screen.dart b/lib/presentation/screens/qr_scan_info_screen.dart new file mode 100644 index 0000000..972c7d3 --- /dev/null +++ b/lib/presentation/screens/qr_scan_info_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; + +class QRScanInfoScreen extends StatelessWidget { + const QRScanInfoScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: "Сканирование QR-кода"), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Column( + children: [ + const SizedBox(height: 40), + const Text( + "Наведите рамку сканера на QR-код -\nномер будет распознан автоматически", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.whiteText, + fontSize: 16, + height: 1.4, + ), + ), + const Spacer(), + Image.asset( + "assets/qr_phone_img.png", + height: 300, + fit: BoxFit.contain, + ), + const Spacer(), + + Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + gradient: const LinearGradient( + colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)], + ), + ), + child: ElevatedButton( + onPressed: () { + context.push("/home/qr-info/qr-scan"); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: const Text( + "Продолжить", + style: TextStyle( + color: Color(0xFF1D273A), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 16), + + // Кнопка "Ввести номер вручную" + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton( + onPressed: () => context.push("/home/qr-info/qr-input"), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white70), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: const Text( + "Ввести номер вручную", + style: TextStyle( + color: AppColors.whiteText, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/qr_scan_screen.dart b/lib/presentation/screens/qr_scan_screen.dart new file mode 100644 index 0000000..6130a8a --- /dev/null +++ b/lib/presentation/screens/qr_scan_screen.dart @@ -0,0 +1,219 @@ +import 'dart:ui' as ui; +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/scooter.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:go_router/go_router.dart'; +import 'package:be_happy/di/service_locator.dart'; +import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart'; +import '../components/gradient_button.dart'; + +class QrScanScreen extends StatefulWidget { + const QrScanScreen({super.key}); + + @override + State createState() => _QrScanScreenState(); +} + +class _QrScanScreenState extends State { + final MobileScannerController _controller = MobileScannerController(); + String? _scannedData; + String? _scooterTitle; + bool _torchOn = false; + bool _isLoading = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + bool _isProcessing = false; + + void _handleScannedData(String rawValue) async { + if (_isProcessing || _isLoading) return; + + final uri = Uri.tryParse(rawValue); + if (uri == null || uri.host != 'behappybel.by') return; + + final title = uri.pathSegments.last; + print("TITLE IS: $title"); + if (title.isEmpty) return; + + setState(() { + _isProcessing = true; + _isLoading = true; + }); + + await _controller.stop(); + + try { + final getScooterByTitleUsecase = getIt(); + print("UseCase успешно получен из DI"); + final result = await getScooterByTitleUsecase(title); + print("UseCase успешно выполнен"); + + + if (mounted) { + setState(() => _isLoading = false); + + switch (result) { + case Success(): + final scooter = result.data; + if (scooter != null) { + context.pop(); + context.push('/home/scooter/${scooter.id}'); + + } else { + _showErrorAndRestart('Самокат не найден'); + } + case Failure(): + _showErrorAndRestart('Ошибка при поиске самоката'); + } + } + } catch (e) { + print("Ошибка DI: $e"); + } + } + + void _showErrorAndRestart(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _isProcessing = false; + _scannedData = null; + }); + _controller.start(); + } + }); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: (capture) { + if (_isProcessing) return; + for (final barcode in capture.barcodes) { + final String? rawValue = barcode.rawValue; + if (rawValue != null) { + _handleScannedData(rawValue); + break; + } + } + }, + ), + + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.7), + BlendMode.srcOut, + ), + child: Stack( + children: [ + Container( + decoration: const BoxDecoration( + color: Colors.black, + backgroundBlendMode: BlendMode.dstOut, + ), + ), + Center( + child: Container( + width: 280, + height: 280, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(0), + ), + ), + ), + ], + ), + ), + + Center( + child: Container( + width: 280, + height: 280, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF6EE7B7), width: 3), + ), + ), + ), + + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + children: [ + const SizedBox(height: 60), + const Text( + 'Наведите рамку на QR-код — номер будет распознан автоматически', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + shadows: [ + Shadow(blurRadius: 4, color: Colors.black), + ], + ), + ), + ], + ), + ), + ), + + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Expanded( + child: GradientButton( + text: 'Ввести номер вручную', + onTap: () { + context.push('/home/qr-info/qr-input'); + }, + width: double.infinity, + height: 56, + fontSize: 16, + showArrows: true, + ), + ), + const SizedBox(width: 12), + CircleAvatar( + backgroundColor: Colors.black.withOpacity(0.5), + child: IconButton( + onPressed: () async { + final newState = !_torchOn; + await _controller.toggleTorch(); + setState(() => _torchOn = newState); + }, + icon: Icon( + _torchOn ? Icons.flashlight_on : Icons.flashlight_off, + color: Colors.white, + size: 24, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/scooter_code_input_screen.dart b/lib/presentation/screens/scooter_code_input_screen.dart new file mode 100644 index 0000000..6fddc3f --- /dev/null +++ b/lib/presentation/screens/scooter_code_input_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/app_colors.dart'; +import '../components/code_dots.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../event/scooter_code_event.dart'; +import '../state/scooter_code_state.dart'; +import '../viewmodel/pin_bloc.dart'; +import '../event/pin_event.dart'; +import '../state/pin_state.dart'; +import '../viewmodel/scooter_code_bloc.dart'; + +class ScooterCodeInputScreen extends StatefulWidget { + const ScooterCodeInputScreen({super.key}); + + @override + State createState() => _ScooterCodeInputScreenState(); +} + +class _ScooterCodeInputScreenState extends State { + final TextEditingController controller = TextEditingController(); + + @override + void initState() { + super.initState(); + controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + context.read().add(ScooterCodeChanged(controller.text)); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is ScooterCodeSuccess) { + context.go("/home/scooter/${state.scooter.id}"); + } + if (state is ScooterCodeFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error ?? "Ошибка")), + ); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: "Ввод QR-кода"), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: BlocBuilder( + builder: (context, state) { + final bool isCodeValid = state.code.length >= 5 && state.code.length <= 7; + + return Column( + children: [ + const SizedBox(height: 60), + const Text( + "Введите номер, расположенный\nпод QR-кодом", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.whiteText, + fontSize: 16, + height: 1.4, + ), + ), + + const SizedBox(height: 80), + + TextField( + controller: controller, + keyboardType: TextInputType.number, + maxLength: 7, + textAlign: TextAlign.left, + style: const TextStyle(color: Colors.white, fontSize: 18), + decoration: InputDecoration( + hintText: "Ввести номер", + hintStyle: const TextStyle(color: AppColors.hint), + counterText: "", + filled: true, + fillColor: Colors.white.withOpacity(0.1), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + ), + ), + + if (state is ScooterCodeLoading) + const Padding( + padding: EdgeInsets.only(top: 20), + child: CircularProgressIndicator(color: Colors.white), + ), + + const Spacer(), + + GradientButton( + text: "Подтвердить", + enabled: isCodeValid && state is! ScooterCodeLoading, + onTap: () { + context.read().add( + ScooterCodeSubmitted(state.code), + ); + }, + showArrows: true, + height: 56, + width: double.infinity, + ), + const SizedBox(height: 40), + ], + ); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/scooter_detail_screen.dart b/lib/presentation/screens/scooter_detail_screen.dart new file mode 100644 index 0000000..8ed64b8 --- /dev/null +++ b/lib/presentation/screens/scooter_detail_screen.dart @@ -0,0 +1,114 @@ +import 'package:be_happy/presentation/event/scooter_detail_event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../components/scooter/battery_indicator.dart'; +import '../components/scooter/scooter_info_section.dart'; +import '../components/scooter/slide_to_reserve_button.dart'; +import '../components/sheet/tariff_sheet.dart'; +import '../state/scooter_detail_state.dart'; +import '../viewmodel/scooter_detail_bloc.dart'; + +class ScooterDetailScreen extends StatelessWidget { + const ScooterDetailScreen({super.key}); + + @override + Widget build(BuildContext context) { + final id = GoRouterState.of(context).pathParameters['id']; + context.read().add(LoadScooterDetails(int.parse(id!))); + return Scaffold( + body: BlocBuilder( + builder: (context, state) { + if (state.status == ScooterStatus.loading) { + return const Center(child: CircularProgressIndicator(color: Colors.white)); + } + + if (state.status == ScooterStatus.failure) { + return Center( + child: Text( + state.errorMessage ?? 'Ошибка', + style: const TextStyle(color: Colors.white), + ), + ); + } + + final scooter = state.scooter; + + return Stack( + children: [ + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1B2A4A), Color(0xFF0F1E3A)], + ), + ), + ), + + Positioned( + top: 0, + bottom: -270, + right: -200, + child: Opacity( + opacity: 0.3, + child: SizedBox( + width: 400, + height: 500, + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png', + fit: BoxFit.contain, + ), + ), + ), + ), + + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + CustomAppBar( + title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат', + ), + const SizedBox(height: 24), + + BatteryIndicator(percent: (scooter?.batteryLevel ?? 0) / 100), + + const SizedBox(height: 24), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded(child: ScooterInfoSection()), + const SizedBox(width: 20), + ], + ), + ), + const SizedBox(height: 14), + + + GradientButton( + text: "Забронировать", + showArrows: true, + height: 52, + width: double.infinity, + fontSize: 16, + onTap: () { + context.pop(); + context.pushReplacement('/home/tarif-sheet', extra: scooter); + }, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/presentation/screens/send_photo_screen.dart b/lib/presentation/screens/send_photo_screen.dart new file mode 100644 index 0000000..af080eb --- /dev/null +++ b/lib/presentation/screens/send_photo_screen.dart @@ -0,0 +1,306 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/app_colors.dart'; +import '../../domain/usecase/finish_ride_usecase.dart'; +import '../components/custom_app_bar.dart'; +import '../components/gradient_button.dart'; +import '../viewmodel/send_photo_bloc.dart'; +import '../event/send_photo_event.dart'; +import '../state/send_photo_state.dart'; +import '../../domain/usecase/upload_scooter_photos_usecase.dart'; +import '../../di/service_locator.dart' as di; + +class SendPhotoScreen extends StatelessWidget { + final int orderId; + const SendPhotoScreen({super.key, required this.orderId}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SendPhotoBloc( + di.getIt(), + di.getIt(), + ), + child: SendPhotoView(orderId: orderId), + ); + } +} + +class SendPhotoView extends StatefulWidget { + final int orderId; + const SendPhotoView({super.key, required this.orderId}); + + @override + State createState() => _SendPhotoViewState(); +} + +class _SendPhotoViewState extends State { + final ImagePicker _imagePicker = ImagePicker(); + + // Метод для выбора фото (добавляет к существующим) + Future _pickImage() async { + await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + return SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Сделать фото'), + onTap: () { + Navigator.pop(context); + _getImage(ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Выбрать из галереи'), + onTap: () { + Navigator.pop(context); + _getImage(ImageSource.gallery); + }, + ), + ListTile( + leading: const Icon(Icons.cancel), + title: const Text('Отмена'), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + }, + ); + } + + // ✅ Метод для получения изображения из выбранного источника + Future _getImage(ImageSource source) async { + final XFile? pickedFile = await _imagePicker.pickImage( + source: source, + imageQuality: 80, + ); + + if (pickedFile != null && mounted) { + final currentImages = context.read().state.selectedImages; + context.read().add( + PhotoSelected([...currentImages, pickedFile.path]), + ); + } + } + + // Метод для удаления конкретного фото + void _removeImage(String path) { + final currentImages = context.read().state.selectedImages; + final updatedList = currentImages.where((p) => p != path).toList(); + context.read().add(PhotoSelected(updatedList)); + } + + // ✅ Динамический заголовок в зависимости от количества фото + String _getTopText(int photoCount) { + if (photoCount == 0) { + return 'Для завершения аренды\nсфотографируйте самокат'; + } else if (photoCount == 1) { + return 'Сделайте фото руля самоката'; + } else { + return 'Отправьте фото на проверку'; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100), + child: Text( + _getTopText(context.select((SendPhotoBloc bloc) => bloc.state.selectedImages.length)), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.4, + ), + ), + ), + + // Основной контент центрируем + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + BlocBuilder( + builder: (context, state) { + final photoCount = state.selectedImages.length; + + return Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + // Список выбранных фото + ...state.selectedImages.map((path) => _buildPhotoThumbnail(path)), + + const SizedBox(height: 35), + + // Кнопка "Добавить", если фото меньше лимита (например, 5) + if (photoCount < 2) + GestureDetector( + onTap: _pickImage, + child: _buildAddButton(), + ), + ], + ); + }, + ), + const SizedBox(height: 200), + + // Кнопка отправки + BlocConsumer( + listener: (context, state) { + if (state.status == SendPhotoStatus.success) { + context.go('/home/checkout/${widget.orderId}', extra: state.recievedPhotoIds); + } else if (state.status == SendPhotoStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), + ); + } + }, + builder: (context, state) { + final photoCount = state.selectedImages.length; + final isEnabled = photoCount >= 2; + + return GradientButton( + text: 'Отправить', + onTap: isEnabled + ? () => context.read().add(PhotoUploadSubmitted(widget.orderId)) + : null, + enabled: isEnabled, + showArrows: true, + height: 56, + width: double.infinity, + fontSize: 16, + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // миниатюра с крестиком + Widget _buildPhotoThumbnail(String path) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(path), + fit: BoxFit.cover, + ), + ), + ), + // Кнопка удаления (крестик) + Positioned( + top: -8, + right: -8, + child: GestureDetector( + onTap: () => _removeImage(path), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Color(0xFF75FBF0), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 16, + color: Color(0xFF242F51), + ), + ), + ), + ), + ], + ); + } + + // Виджет кнопки добавления + Widget _buildAddButton() { + return Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [ + Color(0xFF242F51), // полупрозрачный белый + Color(0xFF242F51), // ещё более прозрачный + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + boxShadow: [ + // Глубокое свечение + BoxShadow( + color: Color(0xFF8BFFAA).withOpacity(0.5), + blurRadius: 50, + spreadRadius: 6, + offset: Offset.zero, + ), + // Внутреннее свечение (для объёма) + BoxShadow( + color: Color(0xFF8BFFAA).withOpacity(0.2), + blurRadius: 10, + spreadRadius: -2, + offset: Offset.zero, + ), + ], + ), + child: const Center( + child: Icon( + Icons.add, + size: 32, + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart new file mode 100644 index 0000000..b4805b4 --- /dev/null +++ b/lib/presentation/screens/splash_screen.dart @@ -0,0 +1,124 @@ + +import 'package:be_happy/presentation/event/spalsh_event.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Подключи сюда свои реальные экраны: +import 'phone_screen.dart'; +import 'pin_login_screen.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _revealAnimation; + + static const double logoSize = 300; + + @override + void initState() { + super.initState(); + + // контроллер анимации + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2500), + ); + + // анимация движения "затемняющего" прямоугольника + _revealAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + + // запускаем анимацию + _controller.forward().then((_) async { + // небольшая пауза после анимации + await Future.delayed(const Duration(milliseconds: 800)); + if (!mounted) { + return; + } + context.read().add(AuthCheckRequested()); + }); + + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF3A3A3A), + body: Center( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final double offset = _revealAnimation.value * (logoSize * 1.2); + + return Stack( + alignment: Alignment.center, + children: [ + // Цветной логотип (на заднем плане) + Image.asset( + 'assets/logo_color.png', + width: logoSize, + height: logoSize, + fit: BoxFit.contain, + ), + + // Прямоугольник, который "уезжает" вправо, открывая логотип + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 1, + child: Container( + width: logoSize, + height: logoSize, + color: const Color(0xFF3A3A3A), + transform: Matrix4.translationValues(offset, 0, 0), + ), + ), + ), + ), + + // Обводка логотипа (поверх) + Image.asset( + 'assets/logo_outline.png', + width: logoSize * 1.01, + height: logoSize * 1.01, + fit: BoxFit.contain, + ), + ], + ); + }, + ), + ), + + bottomNavigationBar: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Text( + 'Версия приложения 1.0', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + ), + ); + } + +} diff --git a/lib/presentation/screens/subscription_details_screen.dart b/lib/presentation/screens/subscription_details_screen.dart new file mode 100644 index 0000000..6efe23e --- /dev/null +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../components/app_checkbox.dart'; +import '../components/gradient_button.dart'; +import '../components/period_selector.dart'; +import '../event/subscription_details_event.dart'; +import '../state/susbcription_details_state.dart'; +import '../viewmodel/susbcription_details_bloc.dart'; + +class SubscriptionDetailsScreen extends StatelessWidget { + final int subscriptionId; + + const SubscriptionDetailsScreen({super.key, required this.subscriptionId}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1A2355), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => context.pop(), + ), + title: BlocBuilder( + builder: (context, state) { + if (state is DetailsContentState) { + return Text(state.subscription.title); + } + return const Text("Загрузка..."); + }, + ), + ), + body: Stack( + children: [ + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Opacity( + opacity: 0.5, + child: Image.asset('assets/wave.png'), + ), + ), + BlocBuilder( + builder: (context, state) { + if (state is DetailsLoading) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF80FFD1)), + ); + } + if (state is DetailsError) { + return Center( + child: Text( + state.message, + style: const TextStyle(color: Colors.white), + ), + ); + } + if (state is DetailsContentState) { + return _buildContent(context, state); + } + return const SizedBox(); + }, + ), + ], + ) + + ); + + + } + + Widget _buildContent(BuildContext context, DetailsContentState state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.subscription.fullDescription, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.5, + ), + ), + const SizedBox(height: 30), + + _ActionCard(state: state), + + const SizedBox(height: 30), + + GradientButton( + text: 'Активировать', + onTap: () => context.read().add( + ActivateSubscriptionPressed(), + ), + width: double.infinity, + height: 56, + fontSize: 16, + showArrows: true, + ), + ], + ), + ); + } +} + +class _ActionCard extends StatelessWidget { + final DetailsContentState state; + + const _ActionCard({required this.state}); + + @override + Widget build(BuildContext context) { + final List periodTitles = state.subscription.options + .map((e) => e.title) + .toList(); + + final int selectedIndex = state.subscription.options.indexOf( + state.selectedPeriod, + ); + + context.read().add( + SelectPeriodEvent(state.subscription.options[selectedIndex])); + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF131B47), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Column( + children: [ + const Text( + "Выберите период действия", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 20), + + PeriodSelector( + periods: periodTitles, + currentIndex: selectedIndex != -1 ? selectedIndex : 0, + onSelect: (index) { + final selectedOption = state.subscription.options[index]; + context.read().add( + SelectPeriodEvent(selectedOption), + ); + }, + ), + + const SizedBox(height: 30), + + _PriceRow(price: state.selectedPeriod.pricePrint), + + const SizedBox(height: 20), + + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppCheckbox( + value: state.isAgreed, + onChanged: (bool? value) { + if (value != null) { + context.read().add( + ToggleAgreementEvent(value), + ); + } + }, + ), + const SizedBox(width: 12), + const Flexible( + child: Text( + 'Я подтверждаю, что ознакомился со всеми условиями предоставления подписки и принимаю их безоговорочно', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _PriceRow extends StatelessWidget { + final String price; + + const _PriceRow({required this.price}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/icons/money_icon.png", width: 72, height: 72), + SizedBox(width: 15), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Стоимость:", style: TextStyle(color: Color(0xFF80FFD1))), + Text( + price, + style: const TextStyle( + color: Color(0xFF80FFD1), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + } +} + diff --git a/lib/presentation/screens/subscription_list_screen.dart b/lib/presentation/screens/subscription_list_screen.dart new file mode 100644 index 0000000..c176b95 --- /dev/null +++ b/lib/presentation/screens/subscription_list_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; +import '../components/subscription_card.dart'; +import '../state/subscription_list_state.dart'; +import '../viewmodel/subscription_list_bloc.dart'; + +class SubscriptionsListScreen extends StatelessWidget { + const SubscriptionsListScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Абонементы'), + ), + + Expanded( + child: Stack( + children: [ + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Opacity( + opacity: 0.5, + child: Image.asset('assets/wave.png'), + ), + ), + BlocBuilder( + builder: (context, state) { + if (state is SubscriptionsLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is SubscriptionsLoaded) { + final activeIds = state.activeSubscriptions.map((e) => e.id).toSet(); + return ListView.builder( + padding: const EdgeInsets.only(top: 20), + itemCount: state.subscriptions.length, + itemBuilder: (context, index) { + final subscription = state.subscriptions[index]; + final bool isActive = activeIds.contains(subscription.id); + + return SubscriptionCard( + subscription: subscription, + isActive: isActive, + ); + }, + ); + } else if (state is SubscriptionsError) { + return Center( + child: Text( + state.message, + style: const TextStyle(color: Colors.red), + ), + ); + } + return const SizedBox(); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/support_screen.dart b/lib/presentation/screens/support_screen.dart new file mode 100644 index 0000000..bcd1e2d --- /dev/null +++ b/lib/presentation/screens/support_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../core/app_colors.dart'; +import '../components/custom_app_bar.dart'; +import '../components/link_row.dart'; + +class SupportScreen extends StatelessWidget { + const SupportScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // ✅ Используем общий AppBar + const SizedBox(height: 16), + CustomAppBar(title: 'Техподдержка'), + const SizedBox(height: 32), + + // Список ссылок + LinkRow( + icon: 'assets/icons/telegram.png', + title: 'Telegram', + onTap: () => openLink('https://t.me/...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/whatsapp.png', + title: 'WhatsApp', + onTap: () => openLink('https://wa.me/...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/viber.png', + title: 'Viber', + onTap: () => openLink('viber://chat?number=...'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + LinkRow( + icon: 'assets/icons/call.png', + title: 'Позвонить', + onTap: () => openLink('tel:+375000000000'), + ), + const Divider(height: 1, color: Colors.white24), + const SizedBox(height: 12), + + const Spacer(), // Отодвигаем картинку вниз + + // Нижняя картинка + Image.asset( + 'assets/support_bottom.png', + width: double.infinity, + fit: BoxFit.contain, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screens/top_up_screen.dart b/lib/presentation/screens/top_up_screen.dart new file mode 100644 index 0000000..db8b128 --- /dev/null +++ b/lib/presentation/screens/top_up_screen.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../di/service_locator.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../components/custom_app_bar.dart'; // ✅ Уже есть +import '../components/gradient_button.dart'; +import '../components/payment_option.dart'; +import '../components/sheet/payment_method_sheet.dart'; +import '../event/payment_method_sheet_event.dart'; +import '../event/top_up_event.dart'; +import '../state/top_up_state.dart'; +import '../viewmodel/payment_method_sheet_bloc.dart'; +import '../viewmodel/top_up_bloc.dart'; + +class TopUpScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1A234E), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 16), + const CustomAppBar(title: 'Пополнение баланса'), + const SizedBox(height: 20), + + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Expanded( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTariffList(state, context), + const SizedBox(height: 30), + _buildPriceInfo(state), + const SizedBox(height: 40), + const Text( + 'Способ оплаты', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 15), + _buildCardSelector(state, context), + _buildAddCardButton(() => context.push("/home/payment-methods/add-card")), + const SizedBox(height: 20), + _buildAgreement(state, context), + const Spacer(), + _buildPayButton(state), + const SizedBox(height: 30), + ], + ), + ], + ), + ); + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildTariffList(TopUpState state, BuildContext context) { + return SizedBox( + height: 120, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.certificates.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final tariff = state.certificates[index]; + final isSelected = state.selectedTariff == tariff; + return GestureDetector( + onTap: () => + context.read().add(SelectCertificate(tariff)), + child: Container( + width: 140, + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF80FFC1) + : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white24), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (tariff.discount != null) + Text( + 'скидка: ${tariff.discount!.toInt()}%', + style: TextStyle( + color: isSelected ? Colors.black87 : Colors.tealAccent, + ), + ), + Text( + '${tariff.value} баллов', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.black : Colors.white, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildPriceInfo(TopUpState state) { + if (state.selectedTariff == null) return const SizedBox.shrink(); + return Center( + child: Column( + children: [ + Text( + 'Купить со скидкой ${state.selectedTariff!.discount?.toInt()}%:', + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/icons/money_icon.png", width: 24, height: 24), + const SizedBox(width: 8), + Text( + '${state.selectedTariff!.price.toStringAsFixed(2)} BYN', + style: const TextStyle( + color: Color(0xFF80FFC1), + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildCardSelector(TopUpState state, BuildContext context) { + return state.selectedCard != null + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: PaymentOption( + title: state.selectedCard!.type, + subtitle: '****${state.selectedCard!.cardLastNumber}', + isSelected: true, + onTap: () async { + // Открываем модалку как вложенную, не закрывая текущую + final selectedCard = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => + PaymentMethodSheetBloc(getIt()) + ..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(), + ), + ); + + if (selectedCard != null) { + context.read().add(SelectCard(selectedCard)); + } + }, + ), + ) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.pushReplacement('/home/payment-method-sheet'); + }, + borderRadius: BorderRadius.circular(24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + + const Text( + 'Способ оплаты', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.6), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.4), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.2), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildAgreement(TopUpState state, BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: state.isAgreed, + onChanged: (v) => + context.read().add(ToggleAgreement(v ?? false)), + side: const BorderSide(color: Colors.white54), + ), + const Expanded( + child: Text( + 'Я принимаю условия покупки бонусного пакета, при котором денежные средства не подлежат возврату...', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ), + ], + ); + } + + Widget _buildPayButton(TopUpState state) { + return GradientButton( + text: 'Оплатить', + onTap: state.isAgreed ? () {} : null, + enabled: state.isAgreed, + showArrows: true, + height: 56, + width: double.infinity, + fontSize: 16, + ); + } + + Widget _buildAddCardButton(VoidCallback onPressed) { + return Padding( + padding: const EdgeInsets.only(top: 10), + child: OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Добавить платежную карту', + style: TextStyle(color: Colors.white), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white54), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/state/active_ride_state.dart b/lib/presentation/state/active_ride_state.dart new file mode 100644 index 0000000..b04c81a --- /dev/null +++ b/lib/presentation/state/active_ride_state.dart @@ -0,0 +1,56 @@ +import '../../domain/entities/scooter_order.dart'; + +enum ActiveRideStatus { initial, loading, success, failure } + +class ActiveRideState { + final ActiveRideStatus status; + final ScooterOrder? order; + final String? errorMessage; + final Duration elapsedTime; + final double speed; + final double distance; + final double cost; + final bool isPaused; + final bool inZone; + + const ActiveRideState({ + this.status = ActiveRideStatus.initial, + this.order, + this.errorMessage, + this.elapsedTime = Duration.zero, + this.speed = 0.0, + this.distance = 0.0, + this.cost = 0.0, + this.isPaused = false, + this.inZone = true, + }); + + ActiveRideState copyWith({ + ActiveRideStatus? status, + ScooterOrder? order, + String? errorMessage, + Duration? elapsedTime, + double? speed, + double? distance, + double? cost, + bool? isPaused, + bool? inZone, + }) { + return ActiveRideState( + status: status ?? this.status, + order: order ?? this.order, + errorMessage: errorMessage ?? this.errorMessage, + elapsedTime: elapsedTime ?? this.elapsedTime, + speed: speed ?? this.speed, + distance: distance ?? this.distance, + cost: cost ?? this.cost, + isPaused: isPaused ?? this.isPaused, + inZone: inZone ?? this.inZone, + ); + } + + @override + String toString() { + return 'ActiveRideState{status: $status, cost: $cost, isPaused: $isPaused}'; + } +} diff --git a/lib/presentation/state/add_card_state.dart b/lib/presentation/state/add_card_state.dart new file mode 100644 index 0000000..1300274 --- /dev/null +++ b/lib/presentation/state/add_card_state.dart @@ -0,0 +1,45 @@ +enum AddCardStatus { initial, loading, success, failure } + +class AddCardState { + final AddCardStatus status; + final String cardNumber; + final String expiryDate; + final String cvv; + final String cardHolder; + final String errorMessage; + + const AddCardState({ + this.status = AddCardStatus.initial, + this.cardNumber = '', + this.expiryDate = '', + this.cvv = '', + this.cardHolder = '', + this.errorMessage = '', + }); + + AddCardState copyWith({ + AddCardStatus? status, + String? cardNumber, + String? expiryDate, + String? cvv, + String? cardHolder, + String? errorMessage, + }) { + return AddCardState( + status: status ?? this.status, + cardNumber: cardNumber ?? this.cardNumber, + expiryDate: expiryDate ?? this.expiryDate, + cvv: cvv ?? this.cvv, + cardHolder: cardHolder ?? this.cardHolder, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isFormValid { + final cleanCardNumber = cardNumber.replaceAll(' ', ''); + return cleanCardNumber.length == 16 && + expiryDate.length == 5 && + cvv.length == 3 && + cardHolder.trim().isNotEmpty; + } +} \ No newline at end of file diff --git a/lib/presentation/state/auth_state.dart b/lib/presentation/state/auth_state.dart new file mode 100644 index 0000000..4b5c98d --- /dev/null +++ b/lib/presentation/state/auth_state.dart @@ -0,0 +1,45 @@ +class PhoneAuthState { + final String phone; + final bool isAdult; + final bool privacyAccepted; + final bool isSubmitting; + final bool isSuccess; + final String? error; + + PhoneAuthState({ + required this.phone, + required this.isAdult, + required this.privacyAccepted, + required this.isSubmitting, + required this.isSuccess, + this.error, + }); + + factory PhoneAuthState.initial() { + return PhoneAuthState( + phone: '', + isAdult: false, + privacyAccepted: false, + isSubmitting: false, + isSuccess: false, + ); + } + + PhoneAuthState copyWith({ + String? phone, + bool? isAdult, + bool? privacyAccepted, + bool? isSubmitting, + bool? isSuccess, + String? error, + }) { + return PhoneAuthState( + phone: phone ?? this.phone, + isAdult: isAdult ?? this.isAdult, + privacyAccepted: privacyAccepted ?? this.privacyAccepted, + isSubmitting: isSubmitting ?? this.isSubmitting, + isSuccess: isSuccess ?? this.isSuccess, + error: error, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/state/current_rides_state.dart b/lib/presentation/state/current_rides_state.dart new file mode 100644 index 0000000..4128145 --- /dev/null +++ b/lib/presentation/state/current_rides_state.dart @@ -0,0 +1,27 @@ +import '../../domain/entities/scooter_order.dart'; + +enum CurrentRidesStatus { initial, loading, success, failure } + +class CurrentRidesState { + final CurrentRidesStatus status; + final List orders; + final String? errorMessage; + + const CurrentRidesState({ + this.status = CurrentRidesStatus.initial, + this.orders = const [], + this.errorMessage, + }); + + CurrentRidesState copyWith({ + CurrentRidesStatus? status, + List? orders, + String? errorMessage, + }) { + return CurrentRidesState( + status: status ?? this.status, + orders: orders ?? this.orders, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} diff --git a/lib/presentation/state/edit_profile_state.dart b/lib/presentation/state/edit_profile_state.dart new file mode 100644 index 0000000..0d6f4f4 --- /dev/null +++ b/lib/presentation/state/edit_profile_state.dart @@ -0,0 +1,42 @@ +import '../../domain/entities/user_profile.dart'; + +class EditProfileState { + final bool isSaving; + final bool isSuccess; + final bool isLoading; + final UserProfile? profile; + final String? error; + + const EditProfileState({ + required this.isSaving, + required this.isSuccess, + required this.isLoading, + this.profile, + this.error, + }); + + factory EditProfileState.initial() { + return const EditProfileState( + isSaving: false, + isSuccess: false, + isLoading: false, + ); + } + + EditProfileState copyWith({ + bool? isSaving, + bool? isSuccess, + bool? isLoading, + UserProfile? profile, + String? error, + }) { + return EditProfileState( + isSaving: isSaving ?? this.isSaving, + isSuccess: isSuccess ?? this.isSuccess, + isLoading: isLoading ?? this.isLoading, + profile: profile ?? this.profile, + error: error, + ); + } +} + diff --git a/lib/presentation/state/map_settings_modal_state.dart b/lib/presentation/state/map_settings_modal_state.dart new file mode 100644 index 0000000..fcbbd0e --- /dev/null +++ b/lib/presentation/state/map_settings_modal_state.dart @@ -0,0 +1,29 @@ +class MapSettingsModalState { + final bool isAllGeomarksActive; + final bool isAllGeozonesActive; + final bool isRestrictedDrivingZoneActive; + final bool isParkingZoneActive; + final bool isRestrictedParkingZoneActive; + + MapSettingsModalState({ + required this.isAllGeomarksActive, + required this.isAllGeozonesActive, + required this.isRestrictedDrivingZoneActive, + required this.isParkingZoneActive, + required this.isRestrictedParkingZoneActive, + }); + + MapSettingsModalState copyWith({ + bool? isGeomarksActive, + bool? isAllGeozonesActive, + bool? isRestrictedDrivingZoneActive, + bool? isParkingZoneActive, + bool? isRestrictedParkingZoneActive, + }) => MapSettingsModalState( + isAllGeomarksActive: isGeomarksActive ?? this.isAllGeomarksActive, + isAllGeozonesActive: isAllGeozonesActive ?? this.isAllGeozonesActive, + isRestrictedDrivingZoneActive: isRestrictedDrivingZoneActive ?? this.isRestrictedDrivingZoneActive, + isParkingZoneActive: isParkingZoneActive ?? this.isParkingZoneActive, + isRestrictedParkingZoneActive: isRestrictedParkingZoneActive ?? this.isRestrictedParkingZoneActive, + ); +} diff --git a/lib/presentation/state/map_state.dart b/lib/presentation/state/map_state.dart new file mode 100644 index 0000000..3c411a4 --- /dev/null +++ b/lib/presentation/state/map_state.dart @@ -0,0 +1,68 @@ +import 'package:be_happy/domain/entities/user_check_flags.dart'; + +import '../../domain/entities/point.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/zone.dart'; +import '../../domain/entities/client_notification.dart'; + +enum ScooterStatus { initial, loading, success, failure } + +class ScooterState { + final List scooters; + final List zones; + final List area; + final List areaScooters; + final ScooterStatus status; + final bool isGeomarksShowed; + final String? address; + final String? errorMessage; + final String phoneNumber; + final int balance; + final UserCheckFlags flags; + final ClientNotification? lastNotification; + + ScooterState({ + this.scooters = const [], + this.zones = const [], + this.area = const [], + this.areaScooters = const [], + this.status = ScooterStatus.initial, + required this.isGeomarksShowed, + this.address, + this.errorMessage, + this.phoneNumber = "+375XXXXXXXXX", + this.balance = 999, + this.flags = const UserCheckFlags(hasFine: false, hasUnpaidOrder: false, hasCard: false), + this.lastNotification, + }); + + ScooterState copyWith({ + List? scooters, + List? zones, + List? area, + List? areaScooters, + ScooterStatus? status, + bool? isGeomarksShowed, + String? address, + String? errorMessage, + String? phoneNumber, + int? balance, + UserCheckFlags? flags, + ClientNotification? lastNotification, + }) { + return ScooterState( + scooters: scooters ?? this.scooters, + zones: zones ?? this.zones, + area: area ?? this.area, + areaScooters: areaScooters ?? this.areaScooters, + status: status ?? this.status, + address: address ?? this.address, + isGeomarksShowed: isGeomarksShowed?? this.isGeomarksShowed, + errorMessage: errorMessage ?? this.errorMessage, + phoneNumber: phoneNumber ?? this.phoneNumber, + balance: balance ?? this.balance, + flags: flags ?? this.flags, + lastNotification: lastNotification ?? this.lastNotification, + ); + } +} diff --git a/lib/presentation/state/news_state.dart b/lib/presentation/state/news_state.dart new file mode 100644 index 0000000..ecd8679 --- /dev/null +++ b/lib/presentation/state/news_state.dart @@ -0,0 +1,31 @@ +import '../../../domain/entities/news.dart'; + +enum NewsStatus { initial, loading, success, failure } + +class NewsState { + final NewsStatus status; + final List news; + final String? errorMessage; + + const NewsState({ + required this.status, + this.news = const [], + this.errorMessage, + }); + + NewsState copyWith({ + NewsStatus? status, + List? news, + String? errorMessage, + }) { + return NewsState( + status: status ?? this.status, + news: news ?? this.news, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isLoading => status == NewsStatus.loading; + bool get isSuccess => status == NewsStatus.success; + bool get isFailure => status == NewsStatus.failure; +} \ No newline at end of file diff --git a/lib/presentation/state/payment_confirm_state.dart b/lib/presentation/state/payment_confirm_state.dart new file mode 100644 index 0000000..2164595 --- /dev/null +++ b/lib/presentation/state/payment_confirm_state.dart @@ -0,0 +1,45 @@ +import 'package:be_happy/domain/entities/payment_card.dart'; + +import '../../domain/entities/scooter_order.dart'; + +enum PaymentConfirmStatus { initial, loading, success, failure } + +class PaymentConfirmState { + final PaymentConfirmStatus status; + final PaymentCard? selectedCard; + final String? errorMessage; + final bool paymentCompleted; + final ScooterOrder? order; + final bool useBalance; + final int userBalance; + + const PaymentConfirmState({ + this.status = PaymentConfirmStatus.initial, + this.errorMessage, + this.selectedCard, + this.paymentCompleted = false, + this.order, + this.useBalance = false, + this.userBalance = 0, + }); + + PaymentConfirmState copyWith({ + PaymentConfirmStatus? status, + String? errorMessage, + PaymentCard? selectedCard, + bool? paymentCompleted, + ScooterOrder? order, + bool? useBalance, + int? userBalance, + }) { + return PaymentConfirmState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + selectedCard: selectedCard ?? this.selectedCard, + paymentCompleted: paymentCompleted ?? this.paymentCompleted, + order: order ?? this.order, + useBalance: useBalance ?? this.useBalance, + userBalance: userBalance ?? this.userBalance, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/state/payment_method_sheet_state.dart b/lib/presentation/state/payment_method_sheet_state.dart new file mode 100644 index 0000000..66291a7 --- /dev/null +++ b/lib/presentation/state/payment_method_sheet_state.dart @@ -0,0 +1,30 @@ +import '../../domain/entities/payment_card.dart'; + +enum PaymentMethodSheetStatus { initial, loading, success, failure } + +class PaymentMethodSheetState { + final PaymentMethodSheetStatus status; + final List cards; + final double balance; + final String? errorMessage; + + PaymentMethodSheetState({ + this.status = PaymentMethodSheetStatus.initial, + this.cards = const [], + this.balance = 0.0, + this.errorMessage, + }); + + PaymentMethodSheetState copyWith({ + PaymentMethodSheetStatus? status, + List? cards, + double? balance, + String? errorMessage, + }) => + PaymentMethodSheetState( + status: status ?? this.status, + cards: cards ?? this.cards, + balance: balance ?? this.balance, + errorMessage: errorMessage ?? this.errorMessage, + ); +} diff --git a/lib/presentation/state/payment_methods_state.dart b/lib/presentation/state/payment_methods_state.dart new file mode 100644 index 0000000..993d86f --- /dev/null +++ b/lib/presentation/state/payment_methods_state.dart @@ -0,0 +1,38 @@ +import '../../domain/entities/payment_card.dart'; + +enum PaymentMethodsStatus { initial, loading, success, failure } + +class PaymentMethodsState { + final PaymentMethodsStatus status; + final List cards; + final int balance; + final String? errorMessage; + final bool isDeleting; + final bool isSettingMain; + + PaymentMethodsState({ + this.status = PaymentMethodsStatus.initial, + this.cards = const [], + this.balance = 99, + this.errorMessage, + this.isDeleting = false, + this.isSettingMain = false, + }); + + PaymentMethodsState copyWith({ + PaymentMethodsStatus? status, + List? cards, + int? balance, + String? errorMessage, + bool? isDeleting, + bool? isSettingMain, + }) => + PaymentMethodsState( + status: status ?? this.status, + cards: cards ?? this.cards, + balance: balance ?? this.balance, + errorMessage: errorMessage ?? this.errorMessage, + isDeleting: isDeleting ?? this.isDeleting, + isSettingMain: isSettingMain ?? this.isSettingMain, + ); +} diff --git a/lib/presentation/state/pin_state.dart b/lib/presentation/state/pin_state.dart new file mode 100644 index 0000000..ec54959 --- /dev/null +++ b/lib/presentation/state/pin_state.dart @@ -0,0 +1,27 @@ +sealed class PinState { + final String pin; + final String? error; + + const PinState({required this.pin, this.error}); +} + +// Состояние создания нового ПИН-кода +class PinCreateInProgress extends PinState { + const PinCreateInProgress({required String pin, String? error}) + : super(pin: pin, error: error); +} + +// Состояние ввода существующего ПИН-кода для входа +class PinLoginInProgress extends PinState { + const PinLoginInProgress({required String pin, String? error}) + : super(pin: pin, error: error); +} + +// Технические состояния +class PinLoading extends PinState { + const PinLoading() : super(pin: ''); +} + +class PinSuccess extends PinState { + const PinSuccess() : super(pin: ''); +} \ No newline at end of file diff --git a/lib/presentation/state/profile_state.dart b/lib/presentation/state/profile_state.dart new file mode 100644 index 0000000..5f54d6a --- /dev/null +++ b/lib/presentation/state/profile_state.dart @@ -0,0 +1,29 @@ +import '../../domain/entities/user_profile.dart'; + +class ProfileState { + final bool isLoading; + final UserProfile? profile; + final String? error; + + const ProfileState({ + required this.isLoading, + this.profile, + this.error, + }); + + factory ProfileState.initial() { + return const ProfileState(isLoading: true); + } + + ProfileState copyWith({ + bool? isLoading, + UserProfile? profile, + String? error, + }) { + return ProfileState( + isLoading: isLoading ?? this.isLoading, + profile: profile ?? this.profile, + error: error, + ); + } +} diff --git a/lib/presentation/state/reserved_ride_state.dart b/lib/presentation/state/reserved_ride_state.dart new file mode 100644 index 0000000..a54a593 --- /dev/null +++ b/lib/presentation/state/reserved_ride_state.dart @@ -0,0 +1,29 @@ +enum ReservedRideStatus { initial, loading, success, failure } + +class ReservedRideState { + final ReservedRideStatus status; + final String? errorMessage; + final bool rideStarted; + final bool rideCancelled; + + const ReservedRideState({ + this.status = ReservedRideStatus.initial, + this.errorMessage, + this.rideStarted = false, + this.rideCancelled = false, + }); + + ReservedRideState copyWith({ + ReservedRideStatus? status, + String? errorMessage, + bool? rideStarted, + bool? rideCancelled, + }) { + return ReservedRideState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + rideStarted: rideStarted ?? this.rideStarted, + rideCancelled: rideCancelled ?? this.rideCancelled, + ); + } +} diff --git a/lib/presentation/state/route_state.dart b/lib/presentation/state/route_state.dart new file mode 100644 index 0000000..c7eeb55 --- /dev/null +++ b/lib/presentation/state/route_state.dart @@ -0,0 +1,18 @@ +// route_state.dart +import '../../domain/entities/point.dart'; + +abstract class RouteState {} + +class RouteInitial extends RouteState {} + +class RouteLoading extends RouteState {} + +class RouteLoaded extends RouteState { + final List points; + RouteLoaded(this.points); +} + +class RouteError extends RouteState { + final String message; + RouteError(this.message); +} \ No newline at end of file diff --git a/lib/presentation/state/scooter_code_state.dart b/lib/presentation/state/scooter_code_state.dart new file mode 100644 index 0000000..0ba7112 --- /dev/null +++ b/lib/presentation/state/scooter_code_state.dart @@ -0,0 +1,23 @@ +abstract class ScooterCodeState { + final String code; + final String? error; + + const ScooterCodeState({this.code = '', this.error}); +} + +class ScooterCodeInitial extends ScooterCodeState { + const ScooterCodeInitial({super.code, super.error}); +} + +class ScooterCodeLoading extends ScooterCodeState { + const ScooterCodeLoading({super.code}); +} + +class ScooterCodeSuccess extends ScooterCodeState { + final dynamic scooter; // Замените dynamic на вашу модель Scooter + const ScooterCodeSuccess(this.scooter, {super.code}); +} + +class ScooterCodeFailure extends ScooterCodeState { + const ScooterCodeFailure(String error, {super.code}) : super(error: error); +} \ No newline at end of file diff --git a/lib/presentation/state/scooter_detail_modal_state.dart b/lib/presentation/state/scooter_detail_modal_state.dart new file mode 100644 index 0000000..7b78f07 --- /dev/null +++ b/lib/presentation/state/scooter_detail_modal_state.dart @@ -0,0 +1,32 @@ + +import '../../domain/entities/scooter.dart'; + +enum ScooterDetailModalStatus { initial, loading, success, failure } + +class ScooterDetailModalState { + final ScooterDetailModalStatus status; + final String? address; + final String? errorMessage; + final List? scooters; + + ScooterDetailModalState({ + this.status = ScooterDetailModalStatus.initial, + this.address, + this.errorMessage, + this.scooters, + }); + + ScooterDetailModalState copyWith({ + ScooterDetailModalStatus? status, + double? distance, + String? address, + String? errorMessage, + List? scooters, + + }) => ScooterDetailModalState( + status: status ?? this.status, + address: address ?? this.address, + errorMessage: errorMessage ?? this.errorMessage, + scooters: scooters ?? this.scooters, + ); +} \ No newline at end of file diff --git a/lib/presentation/state/scooter_detail_state.dart b/lib/presentation/state/scooter_detail_state.dart new file mode 100644 index 0000000..a22fb01 --- /dev/null +++ b/lib/presentation/state/scooter_detail_state.dart @@ -0,0 +1,27 @@ +import '../../domain/entities/scooter.dart'; + +enum ScooterStatus { initial, loading, success, failure } + +class ScooterDetailState { + final ScooterStatus status; + final Scooter? scooter; + final String? errorMessage; + + const ScooterDetailState({ + this.status = ScooterStatus.initial, + this.scooter, + this.errorMessage, + }); + + ScooterDetailState copyWith({ + ScooterStatus? status, + Scooter? scooter, + String? errorMessage, + }) { + return ScooterDetailState( + status: status ?? this.status, + scooter: scooter ?? this.scooter, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} diff --git a/lib/presentation/state/send_photo_state.dart b/lib/presentation/state/send_photo_state.dart new file mode 100644 index 0000000..faabecf --- /dev/null +++ b/lib/presentation/state/send_photo_state.dart @@ -0,0 +1,31 @@ +enum SendPhotoStatus { initial, loading, success, failure } + +class SendPhotoState { + final SendPhotoStatus status; + final List selectedImages; + final List recievedPhotoIds; + final String errorMessage; + + const SendPhotoState({ + this.status = SendPhotoStatus.initial, + this.selectedImages = const [], + this.recievedPhotoIds = const [], + this.errorMessage = '', + }); + + SendPhotoState copyWith({ + SendPhotoStatus? status, + List? selectedImages, + List? recievedPhotoIds, + String? errorMessage, + }) { + return SendPhotoState( + status: status ?? this.status, + selectedImages: selectedImages ?? this.selectedImages, + recievedPhotoIds: recievedPhotoIds ?? this.recievedPhotoIds, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get hasSelectedImages => selectedImages.isNotEmpty; +} diff --git a/lib/presentation/state/splash_state.dart b/lib/presentation/state/splash_state.dart new file mode 100644 index 0000000..f523a7f --- /dev/null +++ b/lib/presentation/state/splash_state.dart @@ -0,0 +1,25 @@ +import 'package:equatable/equatable.dart'; + +abstract class SplashState extends Equatable { + const SplashState(); + + @override + List get props => []; +} + +// Начальное состояние, когда мы еще не знаем, авторизован ли пользователь +class AuthInitial extends SplashState {} + +class AuthInProgress extends SplashState {} + +// Пользователь успешно авторизован (токен обновлен) +class AuthAuthenticated extends SplashState {} + +// Пользователь не авторизован (нет токена или его не удалось обновить) +class AuthUnauthenticated extends SplashState {} + +// успешно ввел пин код +class AuthPinVerified extends SplashState {} + +// Специальное состояние для первого запуска приложения +class AuthFirstLaunch extends SplashState {} diff --git a/lib/presentation/state/subscription_list_state.dart b/lib/presentation/state/subscription_list_state.dart new file mode 100644 index 0000000..24596a1 --- /dev/null +++ b/lib/presentation/state/subscription_list_state.dart @@ -0,0 +1,20 @@ +import '../../domain/entities/subscription.dart'; + +abstract class SubscriptionState {} + +class SubscriptionsLoading extends SubscriptionState {} + +class SubscriptionsLoaded extends SubscriptionState { + final List subscriptions; + final List activeSubscriptions; + + SubscriptionsLoaded({ + required this.subscriptions, + required this.activeSubscriptions, + }); +} + +class SubscriptionsError extends SubscriptionState { + final String message; + SubscriptionsError(this.message); +} \ No newline at end of file diff --git a/lib/presentation/state/susbcription_details_state.dart b/lib/presentation/state/susbcription_details_state.dart new file mode 100644 index 0000000..1bc7d58 --- /dev/null +++ b/lib/presentation/state/susbcription_details_state.dart @@ -0,0 +1,35 @@ +import 'package:be_happy/domain/entities/subscription.dart'; + +import '../../domain/entities/subscription_period.dart'; + +abstract class SubscriptionDetailsState {} + +class DetailsLoading extends SubscriptionDetailsState {} + +class DetailsError extends SubscriptionDetailsState { + final String message; + DetailsError(this.message); +} + +class DetailsContentState extends SubscriptionDetailsState { + final Subscription subscription; + final SubscriptionPeriod selectedPeriod; + final bool isAgreed; + + DetailsContentState({ + required this.subscription, + required this.selectedPeriod, + this.isAgreed = false, + }); + + DetailsContentState copyWith({ + SubscriptionPeriod? selectedPeriod, + bool? isAgreed, + }) { + return DetailsContentState( + subscription: this.subscription, + selectedPeriod: selectedPeriod ?? this.selectedPeriod, + isAgreed: isAgreed ?? this.isAgreed, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/state/tariff_sheet_state.dart b/lib/presentation/state/tariff_sheet_state.dart new file mode 100644 index 0000000..b7146ce --- /dev/null +++ b/lib/presentation/state/tariff_sheet_state.dart @@ -0,0 +1,40 @@ +import 'package:be_happy/domain/entities/payment_card.dart'; + +import '../../domain/entities/tariff.dart'; + +enum TariffSheetStatus { initial, loading, success, failure } + +class TariffSheetState { + final TariffSheetStatus status; + final List tariffs; + final String? errorMessage; + final PaymentCard? selectedCard; + final int userBalance; + final bool useBalance ; + + TariffSheetState({ + this.status = TariffSheetStatus.initial, + this.tariffs = const [], + this.selectedCard, + this.errorMessage, + this.userBalance = 0, + this.useBalance = false, + }); + + TariffSheetState copyWith({ + TariffSheetStatus? status, + List? tariffs, + PaymentCard? selectedCard, + String? errorMessage, + int? userBalance, + bool? useBalance, + + }) => TariffSheetState( + status: status ?? this.status, + tariffs: tariffs ?? this.tariffs, + selectedCard: selectedCard ?? this.selectedCard, + useBalance: useBalance ?? this.useBalance, + userBalance: userBalance ?? this.userBalance, + errorMessage: errorMessage ?? this.errorMessage, + ); +} diff --git a/lib/presentation/state/top_up_state.dart b/lib/presentation/state/top_up_state.dart new file mode 100644 index 0000000..32af0e0 --- /dev/null +++ b/lib/presentation/state/top_up_state.dart @@ -0,0 +1,40 @@ +import 'package:be_happy/domain/entities/certificate.dart'; +import 'package:be_happy/domain/entities/payment_card.dart'; + +import '../../domain/entities/top_up_tariff.dart'; + +class TopUpState { + final List certificates; + final List cards; + final Certificate? selectedTariff; + final PaymentCard? selectedCard; + final bool isLoading; + final bool isAgreed; + + TopUpState({ + this.certificates = const [], + this.cards = const [], + this.selectedTariff, + this.selectedCard, + this.isLoading = false, + this.isAgreed = false, + }); + + TopUpState copyWith({ + List? certificates, + List? cards, + Certificate? selectedTariff, + PaymentCard? selectedCard, + bool? isLoading, + bool? isAgreed, + }) { + return TopUpState( + certificates: certificates ?? this.certificates, + cards: cards ?? this.cards, + selectedTariff: selectedTariff ?? this.selectedTariff, + selectedCard: selectedCard ?? this.selectedCard, + isLoading: isLoading ?? this.isLoading, + isAgreed: isAgreed ?? this.isAgreed, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/state/verify_code_state.dart b/lib/presentation/state/verify_code_state.dart new file mode 100644 index 0000000..f1aa96a --- /dev/null +++ b/lib/presentation/state/verify_code_state.dart @@ -0,0 +1,60 @@ +class VerifyCodeState { + final String phoneNumber; + final String tempToken; + final String code; + final int secondsLeft; + final int attemptsLeft; + final bool isSubmitting; + final bool isSuccess; + final bool isBlocked; + final String? error; + + VerifyCodeState({ + required this.phoneNumber, + required this.tempToken, + required this.code, + required this.secondsLeft, + required this.attemptsLeft, + required this.isSubmitting, + required this.isSuccess, + required this.isBlocked, + this.error, + }); + + factory VerifyCodeState.initial() { + return VerifyCodeState( + phoneNumber: '', + tempToken: '', + code: '', + secondsLeft: 60, + attemptsLeft: 3, + isSubmitting: false, + isSuccess: false, + isBlocked: false, + ); + } + + VerifyCodeState copyWith({ + String? phoneNumber, + String? tempToken, + String? code, + int? secondsLeft, + int? attemptsLeft, + bool? isSubmitting, + bool? isSuccess, + bool? isBlocked, + String? error, + }) { + return VerifyCodeState( + phoneNumber: phoneNumber ?? this.phoneNumber, + tempToken: tempToken ?? this.tempToken, + code: code ?? this.code, + secondsLeft: secondsLeft ?? this.secondsLeft, + attemptsLeft: attemptsLeft ?? this.attemptsLeft, + isSubmitting: isSubmitting ?? this.isSubmitting, + isSuccess: isSuccess ?? this.isSuccess, + isBlocked: isBlocked ?? this.isBlocked, + error: error, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/active_ride_bloc.dart b/lib/presentation/viewmodel/active_ride_bloc.dart new file mode 100644 index 0000000..1d49fc1 --- /dev/null +++ b/lib/presentation/viewmodel/active_ride_bloc.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'package:be_happy/domain/entities/active_scooter_order.dart'; +import 'package:be_happy/domain/usecase/update_scooter_order_data_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/result.dart'; +import '../../../domain/entities/scooter_order.dart'; +import '../../../domain/usecase/finish_ride_usecase.dart'; +import '../../../domain/usecase/pause_ride_usecase.dart'; +import '../../../domain/usecase/resume_ride_usecase.dart'; +import '../../../domain/usecase/get_scooter_order_by_id_usecase.dart'; +import '../event/active_ride_event.dart'; +import '../state/active_ride_state.dart'; + +class ActiveRideBloc extends Bloc { + final FinishRideUsecase _finishRideUsecase; + final PauseRideUsecase _pauseRideUsecase; + final ResumeRideUsecase _resumeRideUsecase; + final GetScooterOrderByIdUsecase _getScooterOrderByIdUsecase; + final UpdateScooterOrderDataUsecase _updateScooterOrderDataUsecase; + Timer? _syncTimer; + + ActiveRideBloc( + this._finishRideUsecase, + this._pauseRideUsecase, + this._resumeRideUsecase, + this._getScooterOrderByIdUsecase, + this._updateScooterOrderDataUsecase, + ) : super(const ActiveRideState()) { + on(_onLoadScooterOrder); + on(_onPauseRide); + on(_onResumeRide); + on(_onFinishRide); + on(_onSyncScooterOrder); + } + + @override + Future close() { + _syncTimer?.cancel(); + return super.close(); + } + + Future _onLoadScooterOrder( + LoadScooterOrder event, + Emitter emit, + ) async { + emit(state.copyWith(status: ActiveRideStatus.loading)); + + final results = await Future.wait([ + _getScooterOrderByIdUsecase(event.orderId), + _updateScooterOrderDataUsecase(orderId: event.orderId), + ]); + + final orderResult = results[0]; + final activeOrderData = results[1]; + + if (orderResult is Success) { + final order = orderResult.data; + + + // Пытаемся достать доп. данные, если они Success + final orderData = activeOrderData is Success + ? activeOrderData.data + : null; + + final startTime = order?.startAt ?? order?.createdAt; + final elapsedTime = DateTime.now().difference(startTime!); + final isPaused = order?.status.toLowerCase() == 'pause'; + + print("ORDER DATA2: $orderData"); + + emit(state.copyWith( + status: ActiveRideStatus.success, + order: order, + elapsedTime: elapsedTime, + speed: orderData?.speed ?? 0.0, + distance: orderData?.mileage ?? 0.0, + cost: orderData?.price ?? 0.0, + isPaused: isPaused, + inZone: orderData?.zone, + )); + + //synchronize + _syncTimer?.cancel(); + _syncTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + add(SyncScooterOrder(event.orderId)); + }); + } else if (orderResult is Failure) { + emit(state.copyWith( + status: ActiveRideStatus.failure, + errorMessage: 'Не удалось загрузить информацию о поездке', + )); + } + print("CURRENT STATE $state"); + + } + + Future _onPauseRide( + PauseRide event, + Emitter emit, + ) async { + emit(state.copyWith(status: ActiveRideStatus.loading)); + + final result = await _pauseRideUsecase(event.orderId); + + if (result is Success) { + emit(state.copyWith( + status: ActiveRideStatus.success, + order: result.data, + isPaused: true, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ActiveRideStatus.failure, + errorMessage: 'Не удалось поставить поездку на паузу', + )); + } + print("CURRENT STATE $state"); + } + + Future _onResumeRide( + ResumeRide event, + Emitter emit, + ) async { + emit(state.copyWith(status: ActiveRideStatus.loading)); + + final result = await _resumeRideUsecase(event.orderId); + + if (result is Success) { + emit(state.copyWith( + status: ActiveRideStatus.success, + order: result.data, + isPaused: false, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ActiveRideStatus.failure, + errorMessage: 'Не удалось возобновить поездку', + )); + } + print("CURRENT STATE $state"); + } + + Future _onFinishRide( + FinishRide event, + Emitter emit, + ) async { + // emit(state.copyWith(status: ActiveRideStatus.loading)); + + /*final result = await _finishRideUsecase(event.orderId); + + + + if (result is Success) { + _syncTimer?.cancel(); + _syncTimer = null; + + emit(state.copyWith( + status: ActiveRideStatus.success, + order: result.data, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ActiveRideStatus.failure, + errorMessage: 'Не удалось завершить поездку', + )); + }*/ + } + + Future _onSyncScooterOrder( + SyncScooterOrder event, + Emitter emit, + ) async { + final results = await Future.wait([ + _getScooterOrderByIdUsecase(event.orderId), + _updateScooterOrderDataUsecase(orderId: event.orderId), + ]); + + final orderResult = results[0]; + final activeOrderData = results[1]; + + if (orderResult is Success) { + final order = orderResult.data; + + // Пытаемся достать доп. данные, если они Success + final orderData = activeOrderData is Success + ? activeOrderData.data + : null; + + final startTime = order?.startAt ?? order?.createdAt; + final elapsedTime = DateTime.now().difference(startTime!); + final isPaused = order?.status.toLowerCase() == 'pause'; + + emit(state.copyWith( + order: order, + elapsedTime: elapsedTime, + speed: orderData?.speed ?? 0.0, + distance: orderData?.mileage ?? 0.0, + cost: orderData?.price ?? state.cost, + isPaused: isPaused, + inZone: orderData?.zone, + )); + } + print("CURRENT STATE $state"); + + } + + +} diff --git a/lib/presentation/viewmodel/add_card_bloc.dart b/lib/presentation/viewmodel/add_card_bloc.dart new file mode 100644 index 0000000..91a2ba9 --- /dev/null +++ b/lib/presentation/viewmodel/add_card_bloc.dart @@ -0,0 +1,112 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecase/add_payment_card_usecase.dart'; +import '../event/add_card_event.dart'; +import '../state/add_card_state.dart'; + +// 🔹 Импорты для Result и Failure +import '../../core/result.dart'; +import '../../core/failures.dart'; + +class AddCardBloc extends Bloc { + final AddPaymentCardUsecase addPaymentCardUsecase; + + AddCardBloc(this.addPaymentCardUsecase) : super(const AddCardState()) { + on(_onCardNumberChanged); + on(_onExpiryDateChanged); + on(_onCvvChanged); + on(_onCardHolderChanged); + on(_onAddCardSubmitted); + } + + void _onCardNumberChanged(CardNumberChanged event, Emitter emit) { + final formatted = _formatCardNumber(event.cardNumber); + emit(state.copyWith(cardNumber: formatted)); + } + + void _onExpiryDateChanged(ExpiryDateChanged event, Emitter emit) { + final formatted = _formatExpiryDate(event.expiryDate); + emit(state.copyWith(expiryDate: formatted)); + } + + void _onCvvChanged(CvvChanged event, Emitter emit) { + final formatted = event.cvv.replaceAll(RegExp(r'\D'), '').substring(0, 3); + emit(state.copyWith(cvv: formatted)); + } + + void _onCardHolderChanged(CardHolderChanged event, Emitter emit) { + emit(state.copyWith(cardHolder: event.cardHolder)); + } + + Future _onAddCardSubmitted(AddCardSubmitted event, Emitter emit) async { + if (state.cardNumber.isEmpty || state.expiryDate.isEmpty) return; + + emit(state.copyWith(status: AddCardStatus.loading)); + + final expiryParts = state.expiryDate.split('/'); // Используем state + if (expiryParts.length != 2) { + emit(state.copyWith( + status: AddCardStatus.failure, + errorMessage: 'Неверный формат даты', + )); + return; + } + + final result = await addPaymentCardUsecase( + cardNumber: state.cardNumber.replaceAll(' ', ''), + expiryMonth: expiryParts[0], + expiryYear: expiryParts[1], + cardHolder: state.cardHolder.trim(), + cvv: state.cvv, + ); + + if (result is Success) { + emit(state.copyWith(status: AddCardStatus.success)); + } else if (result is Failure) { + final failure = result.failure; + + String errorMessage = 'Ошибка добавления карты'; + + if (failure is AuthFailure) { + errorMessage = 'Неверные данные карты'; + } else if (failure is AuthBlockFailure) { + errorMessage = 'Доступ заблокирован'; + } else if (failure is UnknownFailure) { + errorMessage = failure.message ?? 'Неизвестная ошибка'; + } + + emit(state.copyWith( + status: AddCardStatus.failure, + errorMessage: errorMessage, + )); + } + } + + String _formatCardNumber(String value) { + final cleaned = value.replaceAll(RegExp(r'\D'), ''); + final limited = cleaned.substring(0, cleaned.length > 16 ? 16 : cleaned.length); + + String formatted = ''; + for (int i = 0; i < limited.length; i++) { + if (i > 0 && i % 4 == 0) { + formatted += ' '; + } + formatted += limited[i]; + } + + return formatted; + } + + String _formatExpiryDate(String value) { + final cleaned = value.replaceAll(RegExp(r'\D'), ''); + if (cleaned.isEmpty) return ''; + + if (cleaned.length <= 2) { + return cleaned; + } + + final month = cleaned.substring(0, 2); + final year = cleaned.substring(2, cleaned.length > 4 ? 4 : cleaned.length); + + return '$month/$year'; + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/auth_bloc.dart b/lib/presentation/viewmodel/auth_bloc.dart new file mode 100644 index 0000000..afbc67e --- /dev/null +++ b/lib/presentation/viewmodel/auth_bloc.dart @@ -0,0 +1,51 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecase/login_usecase.dart'; +import '../event/auth_event.dart'; +import '../state/auth_state.dart'; + +class PhoneAuthBloc extends Bloc { + final LoginUseCase _loginUseCase; + + PhoneAuthBloc(this._loginUseCase) : super(PhoneAuthState.initial()) { + on((event, emit) { + emit(state.copyWith(phone: event.phone)); + }); + + on((event, emit) { + emit(state.copyWith(isAdult: event.isAdult)); + }); + + on((event, emit) { + emit(state.copyWith(privacyAccepted: event.accepted)); + }); + + on(_onSubmitPhonePressed); + } + + Future _onSubmitPhonePressed( + SubmitPhonePressed event, + Emitter emit, + ) async { + if (state.isSubmitting) return; + + final phone = state.phone; + final isAdult = state.isAdult; + final privacyAccepted = state.privacyAccepted; + + if (phone.isEmpty || !isAdult || !privacyAccepted) { + emit(state.copyWith(error: "Пожалуйста, заполните все поля.")); + return; + } + + emit(state.copyWith(isSubmitting: true, error: null)); + + //готовы отправить данные, вызываем юзкейс + try { + final tempToken = await _loginUseCase.execute('+375$phone'); + print("TOKEN ON PRESENTATION LAYER: $tempToken"); + emit(state.copyWith(isSubmitting: false, isSuccess: true)); + } catch (e) { + emit(state.copyWith(isSubmitting: false, error: e.toString())); + } + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/current_rides_bloc.dart b/lib/presentation/viewmodel/current_rides_bloc.dart new file mode 100644 index 0000000..79f0901 --- /dev/null +++ b/lib/presentation/viewmodel/current_rides_bloc.dart @@ -0,0 +1,40 @@ +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/scooter_order.dart'; +import '../../domain/usecase/get_client_orders_usecase.dart'; +import '../event/current_rides_event.dart'; +import '../state/current_rides_state.dart'; + +class CurrentRidesBloc extends Bloc { + final GetClientOrdersUsecase _getClientOrdersUsecase; + + CurrentRidesBloc(this._getClientOrdersUsecase) : super(const CurrentRidesState()) { + on(_onLoadClientOrders); + } + + Future _onLoadClientOrders( + LoadClientOrders event, + Emitter emit, + ) async { + emit(state.copyWith(status: CurrentRidesStatus.loading)); + + final result = await _getClientOrdersUsecase(); + + if (result is Success>) { + + print("RESULT: ${result.data}"); + + emit(state.copyWith( + status: CurrentRidesStatus.success, + orders: result.data ?? [], + )); + } else if (result is Failure) { + emit(state.copyWith( + status: CurrentRidesStatus.failure, + errorMessage: "Не удалось загрузить заказы", + )); + } + } +} diff --git a/lib/presentation/viewmodel/edit_profile_bloc.dart b/lib/presentation/viewmodel/edit_profile_bloc.dart new file mode 100644 index 0000000..276e7e7 --- /dev/null +++ b/lib/presentation/viewmodel/edit_profile_bloc.dart @@ -0,0 +1,59 @@ +import 'package:be_happy/domain/entities/user_profile.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + +import '../../domain/usecase/get_profile_usecase.dart'; +import '../../domain/usecase/update_profile_usecase.dart'; +import '../event/edit_profile_event.dart'; +import '../event/profile_event.dart'; +import '../state/edit_profile_state.dart'; +import '../state/profile_state.dart'; + +class EditProfileBloc + extends Bloc { + final UpdateProfileUseCase updateProfileUseCase; + final GetProfileUseCase getProfileUseCase; + + EditProfileBloc(this.updateProfileUseCase, this.getProfileUseCase) + : super(EditProfileState.initial()) { + + on(_onStarted); + + on(_onSubmitted); + } + + Future _onSubmitted( + EditProfileSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(isSaving: true)); + + try { + final result = await updateProfileUseCase(event.profile); + + if (result != null) { + emit(state.copyWith(isSaving: false, isSuccess: true)); + } else { + emit(state.copyWith(isSaving: false, error: "Не удалось обновить профиль")); + } + } catch (e) { + emit(state.copyWith(isSaving: false, error: e.toString())); + } + } + + Future _onStarted( + EditProfileStarted event, + Emitter emit, + ) async { + emit(state.copyWith(isSaving: true)); + + print("EDIT BLOC STARTED"); + try { + final profile = await getProfileUseCase(); + + emit(state.copyWith(profile: profile, isSaving: false, isSuccess: false)); + } catch (e) { + emit(state.copyWith(isSaving: false, error: e.toString())); + } + } +} diff --git a/lib/presentation/viewmodel/map_bloc.dart b/lib/presentation/viewmodel/map_bloc.dart new file mode 100644 index 0000000..d06169a --- /dev/null +++ b/lib/presentation/viewmodel/map_bloc.dart @@ -0,0 +1,283 @@ +import 'dart:async'; + +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/client_notification.dart'; +import 'package:be_happy/domain/entities/map_settings.dart'; +import 'package:be_happy/domain/usecase/check_user_usecase.dart'; +import 'package:be_happy/domain/usecase/get_available_zones_usecase.dart'; +import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; +import 'package:be_happy/domain/usecase/get_notifications_stream_usecase.dart'; +import 'package:be_happy/domain/usecase/logout_usecase.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/entities/point.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/entities/zone.dart'; +import '../../domain/usecase/get_available_scooters_usecase.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; +import '../event/map_event.dart'; +import '../event/spalsh_event.dart'; +import '../state/map_state.dart'; +import 'package:maps_toolkit/maps_toolkit.dart' as mt; + +class MapBloc extends Bloc { + final GetAvailableScootersUsecase getScootersUsecase; + final GetAvailableZonesUsecase getAvailableZonesUsecase; + final GetMapSettingsUsecase getMapSettingsUsecase; + final GetNotificationsStreamUseCase getNotificationsStreamUseCase; + final GetProfileUseCase getProfileUseCase; + final CheckUserUseCase checkUserUseCase; + final LogoutUseCase logoutUseCase; + final SplashBloc splashBloc; + StreamSubscription? _notificationSubscription; + + MapBloc( + this.getAvailableZonesUsecase, + this.getScootersUsecase, + this.getMapSettingsUsecase, + this.getNotificationsStreamUseCase, + this.getProfileUseCase, + this.checkUserUseCase, + this.logoutUseCase, + this.splashBloc, + ) : super(ScooterState(isGeomarksShowed: true)) { + on(_onFetchScooters); + on(_onUpdateMap); + on(_onUpdateUserLocation); + on(_onNotificationReceived); + on(_onFetchProfileData); + on(_onCheckUser); + on(_onLogoutPressed); + } + + void startNotificationStream() { + _notificationSubscription?.cancel(); + + _notificationSubscription = getNotificationsStreamUseCase().listen( + (notification) { + add(NotificationReceived(notification)); + }, + onError: (error) { + print(" SSE BLOC ERROR: $error"); + _handleReconnect(); + }, + onDone: () { + print(" SSE Stream closed by server (onDone)"); + _handleReconnect(); + }, + cancelOnError: true, + ); + } + + void _handleReconnect() { + if (isClosed) return; + + Future.delayed(const Duration(seconds: 5), () { + if (!isClosed) { + print(" Attempting to reconnect to SSE..."); + startNotificationStream(); + } + }); + } + + void stopNotificationStream() { + _notificationSubscription?.cancel(); + _notificationSubscription = null; + } + + Future _onFetchScooters( + FetchScooters event, + Emitter emit, + ) async { + emit(state.copyWith(status: ScooterStatus.loading)); + + try { + final results = await Future.wait([ + getScootersUsecase(event.areaScooters, 0, 100), + getAvailableZonesUsecase(event.area, 0, 100), + getMapSettingsUsecase(), + ]); + + final scooters = results[0] as List; + final zones = results[1] as List; + final settings = results[2] as MapSettings; + + zones.forEach(print); + + List filteredZones = []; + + if (settings.all_zones) { + if (settings.parking_zones) { + filteredZones.addAll(zones.where((el) => el.type == "Finish")); + } + if (settings.restricted_parking_zones) { + filteredZones.addAll(zones.where((el) => el.type == "Drive")); + } + if (settings.restricted_driving_zones) { + filteredZones.addAll(zones.where((el) => el.type == "NotDrive")); + } + } + + print( + " FETCH: filteredZones.length = ${filteredZones.length}, all_zones=${settings.all_zones}", + ); + + emit( + state.copyWith( + status: ScooterStatus.success, + scooters: scooters, + zones: filteredZones, + area: event.area, + areaScooters: event.areaScooters, + isGeomarksShowed: settings.all_placemarks, + ), + ); + } catch (e) { + print("FETCH ERROR: $e"); + emit( + state.copyWith( + status: ScooterStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onUpdateMap(UpdateMap event, Emitter emit) async { + emit(state.copyWith(status: ScooterStatus.loading)); + + try { + final results = await Future.wait([ + getScootersUsecase(state.areaScooters, 0, 100), + getAvailableZonesUsecase(state.area, 0, 100), + getMapSettingsUsecase(), + ]); + + final scooters = results[0] as List; + final zones = results[1] as List; + final settings = results[2] as MapSettings; + + List filteredZones = []; + + if (settings.all_zones) { + if (settings.parking_zones) { + filteredZones.addAll(zones.where((el) => el.type == "Finish")); + } + if (settings.restricted_parking_zones) { + filteredZones.addAll(zones.where((el) => el.type == "Drive")); + } + if (settings.restricted_driving_zones) { + filteredZones.addAll(zones.where((el) => el.type == "NotDrive")); + } + } + + print( + " UPDATE MAP: filteredZones.length = ${filteredZones.length}, all_zones=${settings.all_zones}", + ); + + final zonesToEmit = filteredZones.isNotEmpty + ? filteredZones + : state.zones; + + emit( + state.copyWith( + status: ScooterStatus.success, + scooters: scooters, + zones: zonesToEmit, + isGeomarksShowed: settings.all_placemarks, + ), + ); + } catch (e) { + print("UPDATE ERROR: $e"); + emit( + state.copyWith( + status: ScooterStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + FutureOr _onUpdateUserLocation( + UpdateUserLocation event, + Emitter emit, + ) { + print("USER LOCATION UPDATED EVENT start"); + state.zones.forEach((z) { + if (checkUserInZone(Point(event.latitude, event.longitude), z.points)) { + print("USER IN ZONE - ${z.type}"); + } + }); + } + + FutureOr _onNotificationReceived( + NotificationReceived event, + Emitter emit, + ) { + print("NOTIFICATION RECEIVED: ${event.notification.content}"); + emit(state.copyWith(lastNotification: event.notification)); + } + + bool checkUserInZone(Point userPos, List zonePoints) { + List polygon = zonePoints + .map((p) => mt.LatLng(p.latitude, p.longitude)) + .toList(); + + mt.LatLng userLatLng = mt.LatLng(userPos.latitude, userPos.longitude); + + return mt.PolygonUtil.containsLocation(userLatLng, polygon, true); + } + + FutureOr _onFetchProfileData( + FetchProfileData event, + Emitter emit, + ) async { + try { + final profile = await getProfileUseCase(); + + emit(state.copyWith(phoneNumber: profile.phone, balance: profile.balance)); + } catch (e) { + emit( + state.copyWith( + status: ScooterStatus.failure, + errorMessage: "FetchProfileData for SideMenu: ${e.toString()}", + ), + ); + } + } + + FutureOr _onLogoutPressed( + LogoutPressed event, + Emitter emit, + ) { + logoutUseCase(); + splashBloc.add(AuthCheckRequested()); + } + + FutureOr _onCheckUser( + CheckUser event, + Emitter emit, + ) async { + try { + final flags = await checkUserUseCase(); + + print("flags: $flags"); + + if (flags == null) { + return; + } + + print("check user success"); + + emit(state.copyWith(flags: flags)); + } catch (e) { + emit( + state.copyWith( + status: ScooterStatus.failure, + errorMessage: "CheckUser: ${e.toString()}", + ), + ); + } + } +} diff --git a/lib/presentation/viewmodel/map_settings_modal_bloc.dart b/lib/presentation/viewmodel/map_settings_modal_bloc.dart new file mode 100644 index 0000000..db55122 --- /dev/null +++ b/lib/presentation/viewmodel/map_settings_modal_bloc.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:be_happy/domain/entities/map_settings.dart'; +import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; +import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart'; +import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; +import 'package:be_happy/presentation/state/map_settings_modal_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:yandex_mapkit/yandex_mapkit.dart'; + +class MapSettingsModalBloc extends Bloc { + final GetMapSettingsUsecase getMapSettingsUsecase; + final SaveMapSettingsUsecase saveMapSettingsUsecase; + + MapSettingsModalBloc(this.getMapSettingsUsecase, this.saveMapSettingsUsecase) + : super(MapSettingsModalState( + isAllGeomarksActive: true, + isAllGeozonesActive: true, + isParkingZoneActive: true, + isRestrictedParkingZoneActive: true, + isRestrictedDrivingZoneActive: true, + )) { + on(_onAllGeozonesToggled); + on(_onAllGeomarksToggled); + on(_onParkingZonesToggled); + on(_onRestrictedParkingZonesToggled); + on(_onRestrictedDrivingZonesToggled); + on(_onApplyClick); + on(_onModalStarted); + } + + FutureOr _onAllGeozonesToggled(AllGeozonesToggled event, Emitter emit) { + emit(state.copyWith( + isAllGeozonesActive: event.value, + isRestrictedParkingZoneActive: event.value, + isParkingZoneActive: event.value, + isRestrictedDrivingZoneActive: event.value, + )); + } + + FutureOr _onParkingZonesToggled(ParkingZonesToggled event, Emitter emit) { + final newState = state.copyWith(isParkingZoneActive: event.value); + emit(_calculateParentState(newState)); + } + + FutureOr _onRestrictedParkingZonesToggled(RestrictedParkingZonesToggled event, Emitter emit) { + final newState = state.copyWith(isRestrictedParkingZoneActive: event.value); + emit(_calculateParentState(newState)); + } + + FutureOr _onRestrictedDrivingZonesToggled(RestrictedDrivingZonesToggled event, Emitter emit) { + final newState = state.copyWith(isRestrictedDrivingZoneActive: event.value); + emit(_calculateParentState(newState)); + } + + MapSettingsModalState _calculateParentState(MapSettingsModalState currentState) { + final bool anyChildActive = currentState.isParkingZoneActive || + currentState.isRestrictedParkingZoneActive || + currentState.isRestrictedDrivingZoneActive; + + return currentState.copyWith(isAllGeozonesActive: anyChildActive); + } + + FutureOr _onAllGeomarksToggled(AllGeomarksToggled event, Emitter emit) { + emit(state.copyWith(isGeomarksActive: event.value)); + } + + FutureOr _onApplyClick(ApllyButtonClick event, Emitter emit) async { + MapSettings settings = MapSettings( + all_placemarks: state.isAllGeomarksActive, + all_zones: state.isAllGeozonesActive, + parking_zones: state.isParkingZoneActive, + restricted_parking_zones: state.isRestrictedParkingZoneActive, + restricted_driving_zones: state.isRestrictedDrivingZoneActive); + await saveMapSettingsUsecase(settings); + } + + FutureOr _onModalStarted(MapSettingsModalStarted event, Emitter emit) async { + final settings = await getMapSettingsUsecase(); + emit(state.copyWith( + isGeomarksActive: settings.all_placemarks, + isAllGeozonesActive: settings.all_zones, + isParkingZoneActive: settings.parking_zones, + isRestrictedParkingZoneActive: settings.restricted_parking_zones, + isRestrictedDrivingZoneActive: settings.restricted_driving_zones, + )); + } +} diff --git a/lib/presentation/viewmodel/news_bloc.dart b/lib/presentation/viewmodel/news_bloc.dart new file mode 100644 index 0000000..dbfb912 --- /dev/null +++ b/lib/presentation/viewmodel/news_bloc.dart @@ -0,0 +1,40 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'dart:developer' as dev; +import '../../../domain/repositories/news_repository.dart'; +import '../event/news_event.dart'; +import '../state/news_state.dart'; + +class NewsBloc extends Bloc { + final NewsRepository _newsRepository; + + NewsBloc(this._newsRepository) : super(const NewsState(status: NewsStatus.initial)) { + on(_onFetchRequested); + } + + Future _onFetchRequested( + NewsFetchRequested event, + Emitter emit, + ) async { + dev.log('NewsBloc: Получено событие NewsFetchRequested'); + + emit(state.copyWith(status: NewsStatus.loading)); + + try { + final news = await _newsRepository.getNews(); + + dev.log('NewsBloc: Успешно загружено ${news.length} новостей'); + + emit(state.copyWith( + status: NewsStatus.success, + news: news, + )); + } catch (e, stackTrace) { + dev.log('NewsBloc: Ошибка: $e', stackTrace: stackTrace); + + emit(state.copyWith( + status: NewsStatus.failure, + errorMessage: e.toString(), + )); + } + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/order_history_bloc.dart b/lib/presentation/viewmodel/order_history_bloc.dart new file mode 100644 index 0000000..5956994 --- /dev/null +++ b/lib/presentation/viewmodel/order_history_bloc.dart @@ -0,0 +1,107 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import 'package:be_happy/domain/usecase/get_scooter_order_history_usecase.dart'; +import 'package:be_happy/core/result.dart'; + +// 🔹 EVENTS +abstract class OrderHistoryEvent {} + +class OrderHistoryFetchRequested extends OrderHistoryEvent { + final int page; + OrderHistoryFetchRequested({this.page = 1}); +} + +class OrderHistoryRefreshRequested extends OrderHistoryEvent {} + +// 🔹 STATES +enum OrderHistoryStatus { initial, loading, success, failure, empty } + +class OrderHistoryState { + final OrderHistoryStatus status; + final List orders; + final String? errorMessage; + final int currentPage; + final bool hasMore; + + OrderHistoryState({ + required this.status, + this.orders = const [], + this.errorMessage, + this.currentPage = 1, + this.hasMore = true, + }); + + OrderHistoryState copyWith({ + OrderHistoryStatus? status, + List? orders, + String? errorMessage, + int? currentPage, + bool? hasMore, + }) { + return OrderHistoryState( + status: status ?? this.status, + orders: orders ?? this.orders, + errorMessage: errorMessage ?? this.errorMessage, + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class OrderHistoryBloc extends Bloc { + final GetScooterOrderHistoryUsecase _usecase; + + OrderHistoryBloc(this._usecase) : super(OrderHistoryState(status: OrderHistoryStatus.initial)) { + on(_onFetchRequested); + on(_onRefreshRequested); + } + + Future _onFetchRequested( + OrderHistoryFetchRequested event, + Emitter emit, + ) async { + if (event.page == 1) { + emit(state.copyWith(status: OrderHistoryStatus.loading)); + } + + final result = await _usecase.call(page: event.page); + + // ✅ Явная проверка с правильным типом + if (result is Success>) { + final orders = result.data; + + if (orders == null || orders.isEmpty) { + emit(state.copyWith( + status: OrderHistoryStatus.empty, + orders: [], + )); + } else { + final newOrders = event.page == 1 + ? orders + : [...state.orders, ...orders]; + + emit(state.copyWith( + status: OrderHistoryStatus.success, + orders: newOrders, + currentPage: event.page, + hasMore: orders.length == 20, + )); + } + } + // ✅ Явно указываем тип Failure + else if (result is Failure>) { + emit(state.copyWith( + status: OrderHistoryStatus.failure, + errorMessage: result.failure.message ?? 'Неизвестная ошибка', + )); + } + } + + Future _onRefreshRequested( + OrderHistoryRefreshRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: OrderHistoryStatus.loading)); + add(OrderHistoryFetchRequested(page: 1)); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/payment_confirm_bloc.dart b/lib/presentation/viewmodel/payment_confirm_bloc.dart new file mode 100644 index 0000000..826de8e --- /dev/null +++ b/lib/presentation/viewmodel/payment_confirm_bloc.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_order_by_id_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/result.dart'; +import '../../../domain/entities/scooter_order.dart'; +import '../../../domain/usecase/pay_ride_usecase.dart'; +import '../../domain/entities/payment_card.dart'; +import '../event/payment_confirm_event.dart'; +import '../state/payment_confirm_state.dart'; + +class PaymentConfirmBloc + extends Bloc { + final PayRideUsecase _payRideUsecase; + final GetScooterOrderByIdUsecase _getScooterOrderByIdUsecase; + final GetPaymentCardsUsecase _getPaymentCardsUsecase; + final GetProfileUseCase _getProfileUseCase; + + PaymentConfirmBloc( + this._payRideUsecase, + this._getScooterOrderByIdUsecase, + this._getPaymentCardsUsecase, + this._getProfileUseCase, + ) : super(const PaymentConfirmState()) { + on(_onPayRide); + on(_onStarted); + on(_onCardChanged); + on(_onSelectBalancePressed); + } + + Future _onStarted( + PaymentConfirmStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PaymentConfirmStatus.loading)); + + PaymentCard? mainCard; + final result = await _getScooterOrderByIdUsecase(event.orderId); + final cards_result = await _getPaymentCardsUsecase(); + + if (cards_result is Success>) { + mainCard = cards_result.data?.firstWhere( + (card) => card.isMain, + orElse: () => cards_result.data!.first, + ); + } + + switch (result) { + case Success(data: final order): + emit( + state.copyWith( + status: PaymentConfirmStatus.initial, + order: order, + selectedCard: mainCard, + ), + ); + break; + case Failure(): + emit( + state.copyWith( + status: PaymentConfirmStatus.failure, + errorMessage: 'Не удалось загрузить данные поездки', + ), + ); + } + } + + void _onCardChanged( + PaymentCardChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedCard: event.card, useBalance: false)); + } + + Future _onPayRide( + PayRide event, + Emitter emit, + ) async { + emit(state.copyWith(status: PaymentConfirmStatus.loading)); + + final photoIds = event.photoIds.isEmpty ? [1] : event.photoIds; + + final result = await _payRideUsecase( + event.orderId, + event.cardId, + event.isBalance, + ); + + if (result is Success) { + emit( + state.copyWith( + status: PaymentConfirmStatus.success, + paymentCompleted: true, + + ), + ); + } else if (result is Failure) { + emit( + state.copyWith( + status: PaymentConfirmStatus.failure, + errorMessage: 'Не удалось оплатить поездку', + ), + ); + } + } + + FutureOr _onSelectBalancePressed( + SelectBalancePressed event, + Emitter emit, + ) async { + final profile = await _getProfileUseCase(); + + emit( + state.copyWith( + useBalance: true, + userBalance: profile.balance, + selectedCard: null, + ), + ); + } +} diff --git a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart new file mode 100644 index 0000000..77069f3 --- /dev/null +++ b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart @@ -0,0 +1,44 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../event/payment_method_sheet_event.dart'; +import '../state/payment_method_sheet_state.dart'; + +class PaymentMethodSheetBloc extends Bloc { + final GetPaymentCardsUsecase _getPaymentCardsUsecase; + + PaymentMethodSheetBloc(this._getPaymentCardsUsecase) + : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { + on(_onStarted); + } + + Future _onStarted( + PaymentMethodSheetStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PaymentMethodSheetStatus.loading)); + + try { + final result = await _getPaymentCardsUsecase(); + + if (result is Success>) { + emit(state.copyWith( + status: PaymentMethodSheetStatus.success, + cards: result.data ?? [], + )); + } else { + emit(state.copyWith( + status: PaymentMethodSheetStatus.failure, + errorMessage: 'Failed to load payment cards', + )); + } + } catch (e) { + emit(state.copyWith( + status: PaymentMethodSheetStatus.failure, + errorMessage: e.toString(), + )); + } + } +} diff --git a/lib/presentation/viewmodel/payment_methods_bloc.dart b/lib/presentation/viewmodel/payment_methods_bloc.dart new file mode 100644 index 0000000..48bca02 --- /dev/null +++ b/lib/presentation/viewmodel/payment_methods_bloc.dart @@ -0,0 +1,136 @@ +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../core/failures.dart'; +import '../../domain/entities/payment_card.dart'; +import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/remove_payment_card_usecase.dart'; +import '../../domain/usecase/set_main_payment_card_usecase.dart'; +import '../event/payment_methods_event.dart'; +import '../state/payment_methods_state.dart'; + +class PaymentMethodsBloc extends Bloc { + final GetPaymentCardsUsecase _getPaymentCardsUsecase; + final RemovePaymentCardUsecase _removePaymentCardUsecase; + final SetMainPaymentCardUsecase _setMainPaymentCardUsecase; + final GetProfileUseCase _getProfileUseCase; + + PaymentMethodsBloc( + this._getPaymentCardsUsecase, + this._removePaymentCardUsecase, + this._setMainPaymentCardUsecase, + this._getProfileUseCase, + ) : super(PaymentMethodsState(status: PaymentMethodsStatus.initial)) { + on(_onStarted); + on(_onDeleteCard); + on(_onSetMainCard); + } + + Future _onStarted( + PaymentMethodsStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PaymentMethodsStatus.loading)); + + try { + final result = await _getPaymentCardsUsecase(); + final profile = await _getProfileUseCase(); + + + if (result is Success>) { + emit(state.copyWith( + status: PaymentMethodsStatus.success, + cards: result.data ?? [], + balance: profile.balance, + )); + } else if (result is Failure) { + String errorMessage = 'Не удалось загрузить карты'; + + /*if (result.failure is AuthFailure) { + errorMessage = 'Ошибка авторизации'; + } else if (result.failure is UnknownFailure) { + errorMessage = result.failure.message ?? 'Неизвестная ошибка'; + }*/ + + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: errorMessage, + )); + } + } catch (e) { + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: e.toString(), + )); + } + } + + Future _onDeleteCard( + PaymentMethodsDeleteCard event, + Emitter emit, + ) async { + emit(state.copyWith(isDeleting: true)); + + try { + final result = await _removePaymentCardUsecase(event.cardId); + + if (result is Success) { + emit(state.copyWith(isDeleting: false)); + add(PaymentMethodsStarted()); + } else if (result is Failure) { + String errorMessage = 'Не удалось удалить карту'; + + if (result.failure is AuthFailure) { + errorMessage = 'Ошибка авторизации'; + } else if (result.failure is UnknownFailure) { + errorMessage = result.failure.message ?? 'Неизвестная ошибка'; + } + + emit(state.copyWith( + isDeleting: false, + errorMessage: errorMessage, + )); + } + } catch (e) { + emit(state.copyWith( + isDeleting: false, + errorMessage: e.toString(), + )); + } + } + + Future _onSetMainCard( + PaymentMethodsSetMainCard event, + Emitter emit, + ) async { + emit(state.copyWith(isSettingMain: true)); + + try { + final result = await _setMainPaymentCardUsecase(event.cardId); + + if (result is Success) { + emit(state.copyWith(isSettingMain: false)); + add(PaymentMethodsStarted()); + } else if (result is Failure) { + String errorMessage = 'Не удалось установить основную карту'; + + if (result.failure is AuthFailure) { + errorMessage = 'Ошибка авторизации'; + } else if (result.failure is UnknownFailure) { + errorMessage = result.failure.message ?? 'Неизвестная ошибка'; + } + + emit(state.copyWith( + isSettingMain: false, + errorMessage: errorMessage, + )); + } + } catch (e) { + emit(state.copyWith( + isSettingMain: false, + errorMessage: e.toString(), + )); + } + } +} diff --git a/lib/presentation/viewmodel/pin_bloc.dart b/lib/presentation/viewmodel/pin_bloc.dart new file mode 100644 index 0000000..3138d94 --- /dev/null +++ b/lib/presentation/viewmodel/pin_bloc.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:be_happy/domain/usecase/verify_pin_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecase/is_pin_set_usecase.dart'; +import '../event/pin_event.dart'; +import '../state/pin_state.dart'; +import '../../domain/usecase/create_pin_usecase.dart'; + +class PinBloc extends Bloc { + final CreatePinUseCase createPinUseCase; + final VerifyPinUseCase verifyPinUsecase; + final IsPinSetUsecase isPinSetUsecase; + + PinBloc({ + required this.createPinUseCase, + required this.verifyPinUsecase, + required this.isPinSetUsecase, + bool isRegistration = true, + }) : super(const PinLoading()) { + on(_onPinChanged); + on(_onPinSubmitted); + on(_onPinScreenStarted); + } + + void _onPinChanged( + PinDigitChanged event, + Emitter emit, + ) { + if (state is PinCreateInProgress) { + emit(PinCreateInProgress(pin: event.pin)); + } else if (state is PinLoginInProgress) { + emit(PinLoginInProgress(pin: event.pin)); + } + } + + Future _onPinSubmitted( + PinSubmitted event, + Emitter emit, + ) async { + final currentPin = event.pin; + final bool isCreateMode = state is PinCreateInProgress; + + emit(const PinLoading()); + + try { + if (isCreateMode) { + await createPinUseCase(currentPin); + emit(const PinSuccess()); + } else { + final isValid = await verifyPinUsecase(currentPin); + if (isValid) { + emit(const PinSuccess()); + } else { + throw Exception("Неверный ПИН-код"); + } + } + } catch (e) { + if (isCreateMode) { + emit(PinCreateInProgress(pin: '', error: e.toString())); + } else { + emit(PinLoginInProgress(pin: '', error: e.toString().contains("Неверный") + ? "Неверный ПИН-код" + : "Ошибка при проверке")); + } + } + } + + FutureOr _onPinScreenStarted(PinScreenStarted event, Emitter emit) async { + try { + final bool hasPin = await isPinSetUsecase(); + + if (hasPin) { + // Пин ЕСТЬ в базе — значит, нужно его ВВЕСТИ (Login) + emit(const PinLoginInProgress(pin: '')); + } else { + // Пина НЕТ в базе — значит, нужно его СОЗДАТЬ (Create) + emit(const PinCreateInProgress(pin: '')); + } + } catch (e) { + emit(PinLoginInProgress(pin: '', error: "Ошибка доступа к хранилищу")); + } + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/profile_bloc.dart b/lib/presentation/viewmodel/profile_bloc.dart new file mode 100644 index 0000000..16bcef9 --- /dev/null +++ b/lib/presentation/viewmodel/profile_bloc.dart @@ -0,0 +1,75 @@ +import 'package:be_happy/domain/usecase/upload_profile_photo_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/entities/user_profile.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; +import '../../domain/usecase/update_profile_usecase.dart'; +import '../event/profile_event.dart'; +import '../state/profile_state.dart'; + +class ProfileBloc extends Bloc { + final GetProfileUseCase getProfileUseCase; + final UploadProfilePhotoUsecase uploadProfilePhotoUsecase; + final UpdateProfileUseCase updateProfileUseCase; + + + ProfileBloc(this.getProfileUseCase, + this.uploadProfilePhotoUsecase, + this.updateProfileUseCase) + : super(ProfileState.initial()) { + on(_onStarted); + on(_onUpdated); + on(_onPhotoUploaded); + } + + Future _onStarted( + ProfileStarted event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + print("_onStarted method was start"); + try { + final profile = await getProfileUseCase(); + print(profile.name); + + emit(state.copyWith(isLoading: false, profile: profile)); + } catch (e) { + emit(state.copyWith(isLoading: false, error: "STARTED: ${e.toString()}")); + } + } + + Future _onUpdated( + ProfileUpdated event, + Emitter emit, + ) async { + final profile = await getProfileUseCase(); + emit(state.copyWith(profile: profile)); + } + + Future _onPhotoUploaded( + ProfilePhotoUpdated event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + try { + final avatarId = await uploadProfilePhotoUsecase(event.imageFile); + + if (avatarId != null) { + // Отправляем PATCH запрос с новым avatarId + await updateProfileUseCase(UserProfile( + name: '', + birthDate: '', + phone: '', + email: '', + avatarId: avatarId, + )); + } + + final updatedProfile = await getProfileUseCase(); + + emit(state.copyWith(isLoading: false, profile: updatedProfile, error: null)); + } catch (e) { + emit(state.copyWith(isLoading: false, error: "Ошибка загрузки: ${e.toString()}")); + } + } +} diff --git a/lib/presentation/viewmodel/reserved_ride_bloc.dart b/lib/presentation/viewmodel/reserved_ride_bloc.dart new file mode 100644 index 0000000..76c7679 --- /dev/null +++ b/lib/presentation/viewmodel/reserved_ride_bloc.dart @@ -0,0 +1,63 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/result.dart'; +import '../../../domain/entities/scooter_order.dart'; +import '../../../domain/usecase/cancel_ride_usecase.dart'; +import '../../../domain/usecase/start_ride_usecase.dart'; +import '../event/reserved_ride_event.dart'; +import '../state/reserved_ride_state.dart'; + +class ReservedRideBloc extends Bloc { + final StartRideUsecase _startRideUsecase; + final CancelRideUsecase _cancelRideUsecase; + + ReservedRideBloc( + this._startRideUsecase, + this._cancelRideUsecase, + ) : super(const ReservedRideState()) { + on(_onStartRide); + on(_onCancelRide); + } + + Future _onStartRide( + StartRide event, + Emitter emit, + ) async { + emit(state.copyWith(status: ReservedRideStatus.loading)); + + final result = await _startRideUsecase(event.orderId); + + if (result is Success) { + emit(state.copyWith( + status: ReservedRideStatus.success, + rideStarted: true, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ReservedRideStatus.failure, + errorMessage: 'Не удалось начать поездку', + )); + } + } + + Future _onCancelRide( + CancelRide event, + Emitter emit, + ) async { + emit(state.copyWith(status: ReservedRideStatus.loading)); + + final result = await _cancelRideUsecase(event.orderId); + + if (result is Success) { + emit(state.copyWith( + status: ReservedRideStatus.success, + rideCancelled: true, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ReservedRideStatus.failure, + errorMessage: 'Не удалось отменить бронирование', + )); + } + } +} diff --git a/lib/presentation/viewmodel/route_bloc.dart b/lib/presentation/viewmodel/route_bloc.dart new file mode 100644 index 0000000..d0c0e6f --- /dev/null +++ b/lib/presentation/viewmodel/route_bloc.dart @@ -0,0 +1,31 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/point.dart'; +import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../event/route_event.dart'; +import '../state/route_state.dart'; + +class RouteBloc extends Bloc { + final GetScooterOrderRouteHistoryUsecase getRouteUseCase; + + RouteBloc({required this.getRouteUseCase}) : super(RouteInitial()) { + on((event, emit) async { + emit(RouteLoading()); + try { + final result = await getRouteUseCase(event.orderId); + + if (result is Success>) { + + final List points = result.data ?? []; + + emit(RouteLoaded(points)); + + } + + } catch (e) { + emit(RouteError("Ошибка загрузки пути")); + } + }); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/scooter_code_bloc.dart b/lib/presentation/viewmodel/scooter_code_bloc.dart new file mode 100644 index 0000000..7060486 --- /dev/null +++ b/lib/presentation/viewmodel/scooter_code_bloc.dart @@ -0,0 +1,27 @@ +import 'package:be_happy/domain/entities/scooter.dart'; +import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../event/scooter_code_event.dart'; +import '../state/scooter_code_state.dart'; + +class ScooterCodeBloc extends Bloc { + final GetScooterByTitleUsecase getScooterByTitleUsecase; + + ScooterCodeBloc({required this.getScooterByTitleUsecase}) : super(const ScooterCodeInitial()) { + on((event, emit) { + emit(ScooterCodeInitial(code: event.code)); + }); + + on((event, emit) async { + emit(ScooterCodeLoading(code: event.code)); + + final result = await getScooterByTitleUsecase(event.code); + + /*result.fold( + (error) => emit(ScooterCodeFailure(error.message ?? "Ошибка", code: event.code)), + (scooter) => emit(ScooterCodeSuccess(scooter, code: event.code)), + );*/ + }); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/scooter_detail_bloc.dart b/lib/presentation/viewmodel/scooter_detail_bloc.dart new file mode 100644 index 0000000..269445d --- /dev/null +++ b/lib/presentation/viewmodel/scooter_detail_bloc.dart @@ -0,0 +1,37 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/usecase/get_scooter_usecase.dart'; +import '../event/scooter_detail_event.dart'; +import '../state/scooter_detail_state.dart'; + +class ScooterDetailBloc extends Bloc { + final GetScooterUsecase _getScooterUsecase; + + ScooterDetailBloc(this._getScooterUsecase) : super(const ScooterDetailState()) { + on(_onLoadScooterDetails); + } + + Future _onLoadScooterDetails( + LoadScooterDetails event, + Emitter emit, + ) async { + emit(state.copyWith(status: ScooterStatus.loading)); + + final result = await _getScooterUsecase(event.scooterId); + + if (result is Success) { + print("SCOOTER: ${result.data}"); + emit(state.copyWith( + status: ScooterStatus.success, + scooter: result.data, + )); + } else if (result is Failure) { + emit(state.copyWith( + status: ScooterStatus.failure, + errorMessage: "Не удалось загрузить данные", + )); + } + } +} diff --git a/lib/presentation/viewmodel/scooter_detail_modal_bloc.dart b/lib/presentation/viewmodel/scooter_detail_modal_bloc.dart new file mode 100644 index 0000000..ab3592b --- /dev/null +++ b/lib/presentation/viewmodel/scooter_detail_modal_bloc.dart @@ -0,0 +1,91 @@ +import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; +import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:yandex_mapkit/yandex_mapkit.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/scooter.dart'; +import '../../domain/usecase/get_address_by_point_usecase.dart'; +import '../event/scooter_detail_event.dart'; +import '../event/scooter_detail_modal_event.dart'; +import '../state/map_state.dart'; +import '../state/scooter_detail_modal_state.dart'; + +class ScooterDetailModalBloc + extends Bloc { + final GetAddressByPointUsecase _getAddressUsecase; + final GetPedestrianRoutesUsecase _getPedestrianRoutesUsecase; + final GetScooterUsecase _getScooterUsecase; + final Map _addressCache = {}; + final List fetch_scooters = []; + double? distance = 0.0; + + ScooterDetailModalBloc( + this._getAddressUsecase, + this._getScooterUsecase, + this._getPedestrianRoutesUsecase, + ) : super( + ScooterDetailModalState( + status: ScooterDetailModalStatus.initial, + ), + ) { + on(_onStarted); + } + + Future _onStarted( + ScooterDetailModalStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: ScooterDetailModalStatus.loading)); + + final List updatedScooters = []; + String? firstAddress; + + try { + for (var scooter in event.scooters) { + final result = await _getScooterUsecase(scooter.id); + + String? currentAddress = _addressCache[scooter.id]; + if (currentAddress == null) { + currentAddress = await _getAddressUsecase(scooter.longitude, scooter.latitude); + _addressCache[scooter.id] = currentAddress; + } + + firstAddress ??= currentAddress; + + final routes = await _getPedestrianRoutesUsecase( + Point(latitude: event.userLatitude, longitude: event.userLongitude), + Point(latitude: scooter.latitude, longitude: scooter.longitude), + ); + + final distance = routes?.firstOrNull?.metadata.weight.walkingDistance.value; + final time = routes?.firstOrNull?.metadata.weight.time.value; + + if (result is Success) { + final data = result.data!; + data.distance = distance; + data.timeToTravel = time; + updatedScooters.add(data); + } else { + updatedScooters.add(scooter); + } + } + + emit( + state.copyWith( + status: ScooterDetailModalStatus.success, + scooters: updatedScooters, + address: firstAddress ?? "Unknown address", + distance: updatedScooters.firstOrNull?.distance, + ), + ); + } catch (e) { + print('Error in Bloc: $e'); + emit(state.copyWith( + status: ScooterDetailModalStatus.failure, + errorMessage: e.toString(), + )); + } + } + +} diff --git a/lib/presentation/viewmodel/send_photo_bloc.dart b/lib/presentation/viewmodel/send_photo_bloc.dart new file mode 100644 index 0000000..1893b8b --- /dev/null +++ b/lib/presentation/viewmodel/send_photo_bloc.dart @@ -0,0 +1,78 @@ +import 'dart:io'; +import 'package:be_happy/domain/entities/scooter_order.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecase/finish_ride_usecase.dart'; +import '../../domain/usecase/upload_scooter_photos_usecase.dart'; +import '../event/send_photo_event.dart'; +import '../state/send_photo_state.dart'; +import '../../core/result.dart'; +import '../../core/failures.dart'; + +class SendPhotoBloc extends Bloc { + final UploadScooterPhotosUsecase uploadScooterPhotosUsecase; + final FinishRideUsecase _finishRideUsecase; + + + SendPhotoBloc(this.uploadScooterPhotosUsecase, this._finishRideUsecase) : super(const SendPhotoState()) { + on(_onPhotoSelected); + on(_onPhotoUploadSubmitted); + } + + void _onPhotoSelected(PhotoSelected event, Emitter emit) { + emit(state.copyWith(selectedImages: event.imagePaths)); + } + + Future _onPhotoUploadSubmitted( + PhotoUploadSubmitted event, Emitter emit) async { + if (state.selectedImages.isEmpty) { + emit(state.copyWith( + status: SendPhotoStatus.failure, + errorMessage: 'Выберите хотя бы одно фото', + )); + return; + } + + emit(state.copyWith(status: SendPhotoStatus.loading)); + + final imageFiles = state.selectedImages.map((path) => File(path)).toList(); + + final result = await uploadScooterPhotosUsecase(imageFiles); + + switch (result) { + case Success>(): + // ✅ Защита: если data null или пустой — не вызываем finish + if (result.data == null || result.data!.isEmpty) { + emit(state.copyWith( + status: SendPhotoStatus.failure, + errorMessage: 'Не удалось получить ID загруженных фото', + )); + return; + } + + // ✅ Передаём два параметра в UseCase + final finishResult = await _finishRideUsecase( + event.orderId, + result.data! // ✅ ! т.к. проверили выше + ); + + switch (finishResult) { + case Success(): + emit(state.copyWith( + status: SendPhotoStatus.success, + recievedPhotoIds: result.data, + )); + case Failure(): + print("❌ FINISH ERROR: ${finishResult.failure.message}"); + emit(state.copyWith( + status: SendPhotoStatus.failure, + errorMessage: 'Ошибка завершения поездки', + )); + } + case Failure(): + emit(state.copyWith( + status: SendPhotoStatus.failure, + errorMessage: 'Ошибка загрузки фото: ${result.failure.message}', + )); + } + } +} diff --git a/lib/presentation/viewmodel/splash_bloc.dart b/lib/presentation/viewmodel/splash_bloc.dart new file mode 100644 index 0000000..c131671 --- /dev/null +++ b/lib/presentation/viewmodel/splash_bloc.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:be_happy/domain/usecase/refresh_token_usecase.dart'; +import 'package:be_happy/presentation/event/spalsh_event.dart'; +import 'package:be_happy/presentation/state/splash_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SplashBloc extends Bloc { + final RefreshTokenUseCase _refreshTokenUseCase; + + SplashBloc(this._refreshTokenUseCase): + super(AuthInitial()) { + on(_onAuthCheckRequested); + on(_onAuthStarted); + on((event, emit) { + emit(AuthPinVerified()); + }); + } + + Future _onAuthCheckRequested( + AuthCheckRequested event, + Emitter emit, + ) async { + print('AuthBloc: Получено событие AuthCheckRequested.'); + final prefs = await SharedPreferences.getInstance(); + final bool isFirstLaunch = prefs.getBool('is_first_launch') ?? true; + + if (isFirstLaunch) { + await prefs.setBool('is_first_launch', false); + emit(AuthFirstLaunch()); + return; + } + + // 2. Если не первый запуск, пытаемся обновить токен + try { + print('AuthBloc: Пытаюсь обновить токен...'); + // Здесь вызывается ваш метод refreshToken() + await _refreshTokenUseCase.execute(); + print('AuthBloc: Токен успешно обновлен. Отправляю AuthAuthenticated.'); + // Если метод выполнился без исключений, значит токен успешно обновлен + emit(AuthAuthenticated()); + } catch (e) { + print('AuthBloc: Ошибка при обновлении токена: $e'); + // Если refreshToken() бросил исключение, значит что-то пошло не так + emit(AuthUnauthenticated()); + } + } + + FutureOr _onAuthStarted(AuthStarted event, Emitter emit) { + emit(AuthInProgress()); + } +} diff --git a/lib/presentation/viewmodel/subscription_list_bloc.dart b/lib/presentation/viewmodel/subscription_list_bloc.dart new file mode 100644 index 0000000..eee7195 --- /dev/null +++ b/lib/presentation/viewmodel/subscription_list_bloc.dart @@ -0,0 +1,42 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/subscription.dart'; +import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart'; +import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../event/subscription_list_event.dart'; +import '../state/subscription_list_state.dart'; + +class SubscriptionListBloc extends Bloc { + final GetAvailableSubscriptionsUsecase getAvailableSubscriptionsUsecase; + final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; + + SubscriptionListBloc({ + required this.getAvailableSubscriptionsUsecase, + required this.getClientSubscriptionsUsecase, + }) : super(SubscriptionsLoading()) { + on((event, emit) async { + emit(SubscriptionsLoading()); + try { + final results = await Future.wait([ + getAvailableSubscriptionsUsecase(), + getClientSubscriptionsUsecase(), + ]); + + final allResult = results[0]; + final activeResult = results[1] ; + + if (allResult is Success> && activeResult is Success>) { + emit(SubscriptionsLoaded( + subscriptions: allResult.data ?? [], + activeSubscriptions: activeResult.data ?? [], + )); + } else { + emit(SubscriptionsError("Не удалось загрузить данные из API")); + } + } catch (e) { + emit(SubscriptionsError(e.toString())); + } + }); + } +} diff --git a/lib/presentation/viewmodel/susbcription_details_bloc.dart b/lib/presentation/viewmodel/susbcription_details_bloc.dart new file mode 100644 index 0000000..68f2265 --- /dev/null +++ b/lib/presentation/viewmodel/susbcription_details_bloc.dart @@ -0,0 +1,66 @@ +import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/subscription.dart'; +import 'package:be_happy/domain/usecase/activate_subscription_usecase.dart'; +import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../event/subscription_details_event.dart'; +import '../state/susbcription_details_state.dart'; + +class SubscriptionDetailsBloc extends Bloc { + final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase; + final ActivateSubscriptionUsecase activateSubscriptionUsecase; + + SubscriptionDetailsBloc(this.getSubscriptionByIdUsecase, + this.activateSubscriptionUsecase) : super(DetailsLoading()) { + on((event, emit) async { + emit(DetailsLoading()); + try { + + final result = await getSubscriptionByIdUsecase(event.subscriptionId); + + switch (result) { + + case Success(): + final sub = result.data; + + if (sub == null) return; + + emit(DetailsContentState( + subscription: sub, + selectedPeriod: sub.options.first, + )); + case Failure(): + emit(DetailsError("Ошибка при запросе данных")); + + } + + + } catch (e) { + emit(DetailsError("Не удалось загрузить данные")); + } + }); + + on((event, emit) { + if (state is DetailsContentState) { + emit((state as DetailsContentState).copyWith(selectedPeriod: event.period)); + } + }); + + on((event, emit) { + if (state is DetailsContentState) { + emit((state as DetailsContentState).copyWith(isAgreed: event.value)); + } + }); + + on((event, emit) { + switch(state) { + case DetailsContentState contentState: + activateSubscriptionUsecase(contentState.selectedPeriod.id); + break; + default: + break; + } + }); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/tariff_sheet_bloc.dart b/lib/presentation/viewmodel/tariff_sheet_bloc.dart new file mode 100644 index 0000000..b00dcbd --- /dev/null +++ b/lib/presentation/viewmodel/tariff_sheet_bloc.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; +import 'package:collection/collection.dart'; + +import 'package:be_happy/domain/entities/payment_card.dart'; +import 'package:be_happy/domain/usecase/book_scooter_usecase.dart'; +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/tariff.dart'; +import '../../domain/usecase/get_available_tariffs_usecase.dart'; +import '../event/tariff_sheet_event.dart'; +import '../state/tariff_sheet_state.dart'; + +class TariffSheetBloc extends Bloc { + final GetAvailableTariffsUsecase _getAvailableTariffsUsecase; + final GetProfileUseCase _getProfileUseCase; + final GetPaymentCardsUsecase _getPaymentCardsUsecase; + final BookScooterUsecase _bookScooterUsecase; + + TariffSheetBloc( + this._getAvailableTariffsUsecase, + this._getProfileUseCase, + this._getPaymentCardsUsecase, + this._bookScooterUsecase, + ) : super(TariffSheetState(status: TariffSheetStatus.initial)) { + on(_onStarted); + on(_onPaymentCardChanged); + on(_onBookScooterPressed); + on(_onSelectBalancePressed); + } + + Future _onStarted( + TariffSheetStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: TariffSheetStatus.loading)); + + try { + final result = await _getAvailableTariffsUsecase(event.scooterId); + final cards_result = await _getPaymentCardsUsecase(); + + if (result is Success> && + cards_result is Success>) { + emit( + state.copyWith( + status: TariffSheetStatus.success, + tariffs: result.data ?? [], + selectedCard: cards_result.data?.firstWhereOrNull( + (element) => element.isMain, + ), + ), + ); + } else { + emit( + state.copyWith( + status: TariffSheetStatus.failure, + errorMessage: 'Failed to load tariffs', + ), + ); + } + } catch (e, stackTrace) { + emit( + state.copyWith( + status: TariffSheetStatus.failure, + errorMessage: "ERROR: ${e.toString()} \n $stackTrace", + ), + ); + } + } + + FutureOr _onPaymentCardChanged( + PaymentCardChanged event, + Emitter emit, + ) { + try { + emit( + state.copyWith( + status: TariffSheetStatus.success, + selectedCard: event.card, + useBalance: false, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: TariffSheetStatus.failure, + errorMessage: 'Failed to change card', + ), + ); + } + } + + FutureOr _onBookScooterPressed( + BookScooterPressed event, + Emitter emit, + ) { + try { + _bookScooterUsecase( + scooterId: event.scooterId, + planId: event.planId, + cardId: event.cardId, + subscriptionId: event.subscriptionId, + isBalance: event.isBalance, + isInsurance: event.isInsurance, + ); + Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + emit( + state.copyWith( + status: TariffSheetStatus.failure, + errorMessage: 'Failed to book scooter', + ), + ); + } + } + + FutureOr _onSelectBalancePressed(SelectBalancePressed event, Emitter emit) async { + final profile = await _getProfileUseCase(); + + emit(state.copyWith( + useBalance: true, + userBalance: profile.balance, + selectedCard: null, + )); + } +} diff --git a/lib/presentation/viewmodel/top_up_bloc.dart b/lib/presentation/viewmodel/top_up_bloc.dart new file mode 100644 index 0000000..4ef9799 --- /dev/null +++ b/lib/presentation/viewmodel/top_up_bloc.dart @@ -0,0 +1,81 @@ +import 'package:be_happy/domain/usecase/get_certificates_usecase.dart'; +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; +import 'package:be_happy/domain/usecase/purchase_certificate_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/result.dart'; +import '../../domain/entities/certificate.dart'; +import '../../domain/entities/payment_card.dart'; +import '../event/top_up_event.dart'; +import '../state/top_up_state.dart'; + +class TopUpBloc extends Bloc { + final GetCertificatesUsecase getCertificatesUsecase; + final PurchaseCertificateUsecase purchaseCertificateUsecase; + final GetPaymentCardsUsecase getUserCards; + + TopUpBloc({ + required this.getCertificatesUsecase, + required this.purchaseCertificateUsecase, + required this.getUserCards, + }) : super(TopUpState(isLoading: true)) { + on((event, emit) async { + emit(state.copyWith(isLoading: true)); + + // Запускаем оба запроса параллельно + final results = await Future.wait([ + getCertificatesUsecase(), + getUserCards(), + ]); + + final certResult = results[0] as Result>; + final cardResult = results[1] as Result>; + + switch ((certResult, cardResult)) { + case (Success(data: final certs), Success(data: final cards)): + emit( + state.copyWith( + certificates: certs, + cards: cards, + selectedTariff: certs!.length > 1 + ? certs[1] + : (certs.isNotEmpty ? certs.first : null), + + selectedCard: cards!.isEmpty + ? null + : cards.firstWhere( + (c) => c.isMain, + orElse: () => cards.first, + ), + isLoading: false, + ), + ); + + case ( + Success>(data: null), + Failure>(), + ): + // TODO: Handle this case. + throw UnimplementedError(); + case ( + Success>(data: [...]), + Failure>(), + ): + // TODO: Handle this case. + throw UnimplementedError(); + case (Failure>(), _): + // TODO: Handle this case. + throw UnimplementedError(); + } + }); + on( + (event, emit) => emit(state.copyWith(selectedTariff: event.certificate)), + ); + on( + (event, emit) => emit(state.copyWith(selectedCard: event.card)), + ); + on( + (event, emit) => emit(state.copyWith(isAgreed: event.value)), + ); + } +} diff --git a/lib/presentation/viewmodel/verify_code_bloc.dart b/lib/presentation/viewmodel/verify_code_bloc.dart new file mode 100644 index 0000000..5b7f9fb --- /dev/null +++ b/lib/presentation/viewmodel/verify_code_bloc.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:be_happy/core/failures.dart'; +import 'package:be_happy/core/result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/usecase/verify_code_usecase.dart'; +import '../event/verify_code_event.dart'; +import '../state/verify_code_state.dart'; + +class VerifyCodeBloc extends Bloc { + final VerifyCodeUseCase _verifyCodeUseCase; + Timer? _timer; + + VerifyCodeBloc(this._verifyCodeUseCase) + : super(VerifyCodeState.initial()) { + on((event, emit) { + emit(state.copyWith( + phoneNumber: event.phoneNumber, + tempToken: event.tempToken, + secondsLeft: 60, + )); + _startTimer(); + }); + + on((event, emit) { + emit(state.copyWith(code: event.code)); + }); + + on((event, emit) { + if (state.secondsLeft == 0) { + emit(state.copyWith(secondsLeft: 60)); + _startTimer(); + } + }); + + on(_onVerifyCodeSubmitted); + } + + void _startTimer() { + _timer?.cancel(); + + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (state.secondsLeft <= 1) { + timer.cancel(); + emit(state.copyWith(secondsLeft: 0)); + } else { + emit(state.copyWith(secondsLeft: state.secondsLeft - 1)); + } + }); + } + + Future _onVerifyCodeSubmitted(VerifyCodeSubmitted event, + Emitter emit,) async { + if (state.isSubmitting || state.code.length != 6) return; + + emit(state.copyWith(isSubmitting: true, error: null)); + + final result = await _verifyCodeUseCase.execute(state.code, state.tempToken); + + final newState = switch (result) { + Success() => state.copyWith( + isSubmitting: false, + isSuccess: true, + ), + Failure(failure: final f) => switch (f) { + AuthFailure(attemptsLeft: final count) => state.copyWith( + isSubmitting: false, + attemptsLeft: count, + error: 'Неверный код. Осталось попыток: $count', + ), + AuthBlockFailure() => state.copyWith( + isSubmitting: false, + isBlocked: true, + error: 'Вы заблокированы за слишком частые попытки', + ), + _ => state.copyWith( + isSubmitting: false, + error: 'Произошла ошибка. Попробуйте позже', + ), + }, + }; + + emit(newState); + } + + @override + Future close() { + _timer?.cancel(); + return super.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..b16d3fa --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,978 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_id: + dependency: "direct main" + description: + name: android_id + sha256: f6489be4c4380cdb55a99db5b03764d936e84e9d5258a26571aa500bd1159fe0 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: "direct main" + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 + url: "https://pub.dev" + source: hosted + version: "9.2.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + bot_toast: + dependency: "direct main" + description: + name: bot_toast + sha256: "6b93030a99a98335b8827ecd83021e92e885ffc61d261d3825ffdecdd17f3bdf" + url: "https://pub.dev" + source: hosted + version: "4.1.3" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" + flutter_client_sse: + dependency: "direct main" + description: + name: flutter_client_sse + sha256: "4ce0297206473dfc064b255fe086713240002e149f52519bd48c21423e4aa5d2" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" + url: "https://pub.dev" + source: hosted + version: "11.1.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + maps_toolkit: + dependency: "direct main" + description: + name: maps_toolkit + sha256: ea5915a8e66737471030cd3e44ba688cc45b932972d1a3b485eaa57c4b68ec28 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + url: "https://pub.dev" + source: hosted + version: "6.3.20" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yandex_mapkit: + dependency: "direct main" + description: + name: yandex_mapkit + sha256: "30a6f7e1d99871338071425a389c53491018602ee24be16e9cf63e640d57dda1" + url: "https://pub.dev" + source: hosted + version: "4.2.1" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..4ab4aac --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,69 @@ +name: be_happy +description: "" +publish_to: 'none' + +version: 1.0.0+11 + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + flutter_secure_storage: ^9.2.4 + shared_preferences: ^2.5.3 + http: ^1.2.0 + equatable: ^2.0.0 + flutter_bloc: ^9.1.1 + get_it: ^7.6.7 + image_picker: ^1.0.7 + path: ^1.9.0 + async: ^2.11.0 + mime: ^1.0.5 + intl: ^0.19.0 + url_launcher: ^6.2.5 + go_router: ^14.0.0 + android_id: ^0.4.0 + device_info_plus: ^12.3.0 + mobile_scanner: ^5.1.1 + yandex_mapkit: ^4.2.1 + geolocator: ^11.0.0 + crypto: ^3.0.3 + dio: ^5.9.2 + maps_toolkit: ^3.0.0 + bot_toast: ^4.1.3 + flutter_client_sse: ^2.0.1 + html: ^0.15.4 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + flutter_launcher_icons: ^0.14.4 + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/main-logo.png" + +flutter: + uses-material-design: true + + assets: + - assets/ + - assets/icons/ + + + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 +