From 8631e6f729d5149b46012ee8da2b107b629566eb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:45:42 -0500 Subject: [PATCH] fix: port 7 web features and repair the endless-loading screens Root cause behind most "stuck loading" reports: the backend changed shape (schedules became an array, location_preferences a string) while the mobile parser still hard-cast to Map/int. The TypeError was swallowed by a silent catch that returned null, and screens only handled the success state, so a parse failure rendered as a permanent spinner. Same class of bug appeared across service lists via non-null map lookups and a total absence of request timeouts. Ported from prosappweb: - in-app suggestions (POST /suggestions) - policies/terms from GET /settings/policies - configurable appointment length (slot_duration_minutes) - block/unblock calendar slots (POST /services/block) - GPS city detection on the profile (Nominatim) - server-side professional search with haversine distance - retry cooldown after a rejected professional application Reliability: - parse schedules array (day_of_week 0=Mon) and string location_preferences - read times as wall clock, so 08:00 stays 08:00 across timezones - carry minutes into hours in TimeOfDay.add; a minute-based step used to loop forever and freeze the calendar (covered by test/time_slots_test.dart) - semver update check instead of string equality, which blocked every build that did not exactly match the configured version - request timeouts across all repositories - surface HTTP >= 400 instead of reporting failed writes as success - error states with retry instead of an indefinite shimmer Includes pre-existing uncommitted work from the UI redesign. Co-Authored-By: Claude Opus 5 --- android/app/src/main/AndroidManifest.xml | 1 + ios/.gitignore | 3 + ios/Flutter/Debug.xcconfig | 2 + ios/Flutter/Release.xcconfig | 2 + ios/Podfile | 6 +- ios/Podfile.lock | 1356 ++-------------- ios/Runner/AppDelegate.swift | 2 +- .../professional_bloc/professional_bloc.dart | 34 +- .../professional_bloc/professional_event.dart | 4 + .../professional_bloc/professional_state.dart | 17 + .../professional_list_bloc.dart | 67 +- .../professional_list_event.dart | 14 +- .../professional_profile_bloc.dart | 1 + .../professional_profile_event.dart | 5 +- lib/blocs/service_bloc/service_bloc.dart | 36 +- lib/blocs/service_bloc/service_event.dart | 10 + lib/blocs/sign_up_bloc/sign_up_bloc.dart | 3 +- lib/blocs/sign_up_bloc/sign_up_state.dart | 8 +- .../suggestion_bloc/suggestion_bloc.dart | 30 + .../suggestion_bloc/suggestion_event.dart | 17 + .../suggestion_bloc/suggestion_state.dart | 23 + lib/components/general_drawer.dart | 644 ++++---- lib/components/general_drawer_header.dart | 161 +- lib/components/general_drawer_item.dart | 17 +- lib/dependency/app_di.dart | 9 + .../authentication/sign_up_screen.dart | 7 +- lib/screens/chat/chat_screen.dart | 4 +- .../configuration_about_screen.dart | 67 +- .../configuration/configuration_screen.dart | 203 +-- .../configuration_support_screen.dart | 8 +- .../configuration/policy_text_screen.dart | 26 + lib/screens/home/home_screen.dart | 19 +- .../lists/professional_list_screen.dart | 91 +- .../professional_pending_service_list.dart | 36 + ...fessional_service_history_list_screen.dart | 34 + .../professional_service_list_screen.dart | 34 + .../user_service_history_list_screen.dart | 34 + .../lists/user_service_list_screen.dart | 32 + .../professional_calendar_screen.dart | 135 +- .../professional_denied_screen.dart | 294 +++- .../professional_form_screen.dart | 85 +- .../professional_profile_screen.dart | 116 +- lib/screens/profile/profile_screen.dart | 649 ++++---- lib/screens/score/score_screen.dart | 603 ++++--- .../service/professional_service_screen.dart | 1228 ++++++-------- lib/screens/service/user_service_screen.dart | 1410 +++++++---------- .../suggestions/suggestion_screen.dart | 133 ++ lib/screens/user/user_calendar_screen.dart | 3 + lib/screens/user/user_map_screen.dart | 61 +- .../user/user_view_profile_screen.dart | 403 +++-- lib/utils/nominatim_geocoder.dart | 40 + lib/utils/time_of_day_extension.dart | 9 +- lib/utils/time_of_day_utils.dart | 7 +- lib/utils/version_utils.dart | 48 + .../src/repositories/api_chat_repository.dart | 10 +- packages/chat_repository/pubspec.lock | 210 ++- .../src/repositories/api_city_repository.dart | 8 +- packages/city_repository/pubspec.lock | 210 ++- .../lib/src/models/models.dart | 1 + .../lib/src/models/professions.dart | 10 + .../api_profession_repository.dart | 4 +- packages/profession_repository/pubspec.lock | 210 ++- .../src/entities/payment_method_entity.dart | 6 +- .../lib/src/entities/professional_entity.dart | 10 +- .../lib/src/entities/schedule_entity.dart | 12 +- .../api_professional_repository.dart | 211 ++- packages/professional_repository/pubspec.lock | 242 +-- .../repositories/api_score_repository.dart | 8 +- packages/score_repository/pubspec.lock | 224 +-- .../repositories/api_service_repository.dart | 30 +- packages/service_repository/pubspec.lock | 210 ++- .../lib/src/entities/entities.dart | 1 + .../lib/src/entities/policies_entity.dart | 21 + .../lib/src/entities/setting_entity.dart | 4 + .../repositories/api_setting_repository.dart | 49 +- .../lib/src/repositories/setting_repo.dart | 1 + packages/setting_repository/pubspec.lock | 210 ++- .../api_suggestion_repository.dart | 36 + .../lib/suggestion_repository.dart | 3 + packages/suggestion_repository/pubspec.yaml | 24 + .../lib/src/models/my_user.dart | 5 + .../src/repositories/api_user_repository.dart | 16 +- packages/user_repository/pubspec.lock | 238 +-- pubspec.lock | 183 +-- pubspec.yaml | 6 +- test/time_slots_test.dart | 57 + 86 files changed, 5470 insertions(+), 5291 deletions(-) create mode 100644 lib/blocs/suggestion_bloc/suggestion_bloc.dart create mode 100644 lib/blocs/suggestion_bloc/suggestion_event.dart create mode 100644 lib/blocs/suggestion_bloc/suggestion_state.dart create mode 100644 lib/screens/configuration/policy_text_screen.dart create mode 100644 lib/screens/suggestions/suggestion_screen.dart create mode 100644 lib/utils/nominatim_geocoder.dart create mode 100644 lib/utils/version_utils.dart create mode 100644 packages/profession_repository/lib/src/models/professions.dart create mode 100644 packages/setting_repository/lib/src/entities/policies_entity.dart create mode 100644 packages/suggestion_repository/lib/src/repositories/api_suggestion_repository.dart create mode 100644 packages/suggestion_repository/lib/suggestion_repository.dart create mode 100644 packages/suggestion_repository/pubspec.yaml create mode 100644 test/time_slots_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 396c397..fec1dfe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/ios/.gitignore b/ios/.gitignore index 7a7f987..6a11004 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -32,3 +32,6 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 + +# Vendored third-party source tree (466MB), not part of the app build +boringssl/ diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index ec97fc6..fc631d9 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,4 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" + +CLANG_ENABLE_EXPLICIT_MODULES = NO diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index c4855bf..157f142 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,4 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" + +CLANG_ENABLE_EXPLICIT_MODULES = NO diff --git a/ios/Podfile b/ios/Podfile index 4211922..0e21b29 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,5 +39,9 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES' + config.build_settings['CLANG_ENABLE_EXPLICIT_MODULES'] = 'NO' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2bd58dc..135dc99 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,1089 +1,70 @@ PODS: - - abseil/algorithm (1.20240116.2): - - abseil/algorithm/algorithm (= 1.20240116.2) - - abseil/algorithm/container (= 1.20240116.2) - - abseil/algorithm/algorithm (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/algorithm/container (1.20240116.2): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/base/nullability - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/base (1.20240116.2): - - abseil/base/atomic_hook (= 1.20240116.2) - - abseil/base/base (= 1.20240116.2) - - abseil/base/base_internal (= 1.20240116.2) - - abseil/base/config (= 1.20240116.2) - - abseil/base/core_headers (= 1.20240116.2) - - abseil/base/cycleclock_internal (= 1.20240116.2) - - abseil/base/dynamic_annotations (= 1.20240116.2) - - abseil/base/endian (= 1.20240116.2) - - abseil/base/errno_saver (= 1.20240116.2) - - abseil/base/fast_type_id (= 1.20240116.2) - - abseil/base/log_severity (= 1.20240116.2) - - abseil/base/malloc_internal (= 1.20240116.2) - - abseil/base/no_destructor (= 1.20240116.2) - - abseil/base/nullability (= 1.20240116.2) - - abseil/base/prefetch (= 1.20240116.2) - - abseil/base/pretty_function (= 1.20240116.2) - - abseil/base/raw_logging_internal (= 1.20240116.2) - - abseil/base/spinlock_wait (= 1.20240116.2) - - abseil/base/strerror (= 1.20240116.2) - - abseil/base/throw_delegate (= 1.20240116.2) - - abseil/base/atomic_hook (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/base/base (1.20240116.2): - - abseil/base/atomic_hook - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/cycleclock_internal - - abseil/base/dynamic_annotations - - abseil/base/log_severity - - abseil/base/nullability - - abseil/base/raw_logging_internal - - abseil/base/spinlock_wait - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/base/base_internal (1.20240116.2): - - abseil/base/config - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/base/config (1.20240116.2): - - abseil/xcprivacy - - abseil/base/core_headers (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/base/cycleclock_internal (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/xcprivacy - - abseil/base/dynamic_annotations (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/base/endian (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/nullability - - abseil/xcprivacy - - abseil/base/errno_saver (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/base/fast_type_id (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/base/log_severity (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/base/malloc_internal (1.20240116.2): - - abseil/base/base - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/base/no_destructor (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/base/nullability (1.20240116.2): - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/base/prefetch (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/base/pretty_function (1.20240116.2): - - abseil/xcprivacy - - abseil/base/raw_logging_internal (1.20240116.2): - - abseil/base/atomic_hook - - abseil/base/config - - abseil/base/core_headers - - abseil/base/errno_saver - - abseil/base/log_severity - - abseil/xcprivacy - - abseil/base/spinlock_wait (1.20240116.2): - - abseil/base/base_internal - - abseil/base/core_headers - - abseil/base/errno_saver - - abseil/xcprivacy - - abseil/base/strerror (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/errno_saver - - abseil/xcprivacy - - abseil/base/throw_delegate (1.20240116.2): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/cleanup/cleanup (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/cleanup/cleanup_internal - - abseil/xcprivacy - - abseil/cleanup/cleanup_internal (1.20240116.2): - - abseil/base/base_internal - - abseil/base/core_headers - - abseil/utility/utility - - abseil/xcprivacy - - abseil/container/common (1.20240116.2): - - abseil/meta/type_traits - - abseil/types/optional - - abseil/xcprivacy - - abseil/container/common_policy_traits (1.20240116.2): - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/container/compressed_tuple (1.20240116.2): - - abseil/utility/utility - - abseil/xcprivacy - - abseil/container/container_memory (1.20240116.2): - - abseil/base/config - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/utility/utility - - abseil/xcprivacy - - abseil/container/fixed_array (1.20240116.2): - - abseil/algorithm/algorithm - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/throw_delegate - - abseil/container/compressed_tuple - - abseil/memory/memory - - abseil/xcprivacy - - abseil/container/flat_hash_map (1.20240116.2): - - abseil/algorithm/container - - abseil/base/core_headers - - abseil/container/container_memory - - abseil/container/hash_function_defaults - - abseil/container/raw_hash_map - - abseil/memory/memory - - abseil/xcprivacy - - abseil/container/flat_hash_set (1.20240116.2): - - abseil/algorithm/container - - abseil/base/core_headers - - abseil/container/container_memory - - abseil/container/hash_function_defaults - - abseil/container/raw_hash_set - - abseil/memory/memory - - abseil/xcprivacy - - abseil/container/hash_function_defaults (1.20240116.2): - - abseil/base/config - - abseil/hash/hash - - abseil/strings/cord - - abseil/strings/strings - - abseil/xcprivacy - - abseil/container/hash_policy_traits (1.20240116.2): - - abseil/container/common_policy_traits - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/container/hashtable_debug_hooks (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/container/hashtablez_sampler (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/debugging/stacktrace - - abseil/memory/memory - - abseil/profiling/exponential_biased - - abseil/profiling/sample_recorder - - abseil/synchronization/synchronization - - abseil/time/time - - abseil/utility/utility - - abseil/xcprivacy - - abseil/container/inlined_vector (1.20240116.2): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/base/throw_delegate - - abseil/container/inlined_vector_internal - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/container/inlined_vector_internal (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/container/compressed_tuple - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/types/span - - abseil/xcprivacy - - abseil/container/layout (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/debugging/demangle_internal - - abseil/meta/type_traits - - abseil/strings/strings - - abseil/types/span - - abseil/utility/utility - - abseil/xcprivacy - - abseil/container/raw_hash_map (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/throw_delegate - - abseil/container/container_memory - - abseil/container/raw_hash_set - - abseil/xcprivacy - - abseil/container/raw_hash_set (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/endian - - abseil/base/prefetch - - abseil/base/raw_logging_internal - - abseil/container/common - - abseil/container/compressed_tuple - - abseil/container/container_memory - - abseil/container/hash_policy_traits - - abseil/container/hashtable_debug_hooks - - abseil/container/hashtablez_sampler - - abseil/hash/hash - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/utility/utility - - abseil/xcprivacy - - abseil/crc/cpu_detect (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/xcprivacy - - abseil/crc/crc32c (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/prefetch - - abseil/crc/cpu_detect - - abseil/crc/crc_internal - - abseil/crc/non_temporal_memcpy - - abseil/strings/str_format - - abseil/strings/strings - - abseil/xcprivacy - - abseil/crc/crc_cord_state (1.20240116.2): - - abseil/base/config - - abseil/crc/crc32c - - abseil/numeric/bits - - abseil/strings/strings - - abseil/xcprivacy - - abseil/crc/crc_internal (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/prefetch - - abseil/base/raw_logging_internal - - abseil/crc/cpu_detect - - abseil/memory/memory - - abseil/numeric/bits - - abseil/xcprivacy - - abseil/crc/non_temporal_arm_intrinsics (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/crc/non_temporal_memcpy (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/crc/non_temporal_arm_intrinsics - - abseil/xcprivacy - - abseil/debugging/debugging_internal (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/errno_saver - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/debugging/demangle_internal (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/debugging/stacktrace (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/raw_logging_internal - - abseil/debugging/debugging_internal - - abseil/xcprivacy - - abseil/debugging/symbolize (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/malloc_internal - - abseil/base/raw_logging_internal - - abseil/debugging/debugging_internal - - abseil/debugging/demangle_internal - - abseil/strings/strings - - abseil/xcprivacy - - abseil/flags/commandlineflag (1.20240116.2): - - abseil/base/config - - abseil/base/fast_type_id - - abseil/flags/commandlineflag_internal - - abseil/strings/strings - - abseil/types/optional - - abseil/xcprivacy - - abseil/flags/commandlineflag_internal (1.20240116.2): - - abseil/base/config - - abseil/base/fast_type_id - - abseil/xcprivacy - - abseil/flags/config (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/flags/path_util - - abseil/flags/program_name - - abseil/strings/strings - - abseil/synchronization/synchronization - - abseil/xcprivacy - - abseil/flags/flag (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/flags/config - - abseil/flags/flag_internal - - abseil/flags/reflection - - abseil/strings/strings - - abseil/xcprivacy - - abseil/flags/flag_internal (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/flags/commandlineflag - - abseil/flags/commandlineflag_internal - - abseil/flags/config - - abseil/flags/marshalling - - abseil/flags/reflection - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/strings/strings - - abseil/synchronization/synchronization - - abseil/utility/utility - - abseil/xcprivacy - - abseil/flags/marshalling (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/log_severity - - abseil/numeric/int128 - - abseil/strings/str_format - - abseil/strings/strings - - abseil/types/optional - - abseil/xcprivacy - - abseil/flags/path_util (1.20240116.2): - - abseil/base/config - - abseil/strings/strings - - abseil/xcprivacy - - abseil/flags/private_handle_accessor (1.20240116.2): - - abseil/base/config - - abseil/flags/commandlineflag - - abseil/flags/commandlineflag_internal - - abseil/strings/strings - - abseil/xcprivacy - - abseil/flags/program_name (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/flags/path_util - - abseil/strings/strings - - abseil/synchronization/synchronization - - abseil/xcprivacy - - abseil/flags/reflection (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/no_destructor - - abseil/container/flat_hash_map - - abseil/flags/commandlineflag - - abseil/flags/commandlineflag_internal - - abseil/flags/config - - abseil/flags/private_handle_accessor - - abseil/strings/strings - - abseil/synchronization/synchronization - - abseil/xcprivacy - - abseil/functional/any_invocable (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/utility/utility - - abseil/xcprivacy - - abseil/functional/bind_front (1.20240116.2): - - abseil/base/base_internal - - abseil/container/compressed_tuple - - abseil/meta/type_traits - - abseil/utility/utility - - abseil/xcprivacy - - abseil/functional/function_ref (1.20240116.2): - - abseil/base/base_internal - - abseil/base/core_headers - - abseil/functional/any_invocable - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/hash/city (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/xcprivacy - - abseil/hash/hash (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/container/fixed_array - - abseil/functional/function_ref - - abseil/hash/city - - abseil/hash/low_level_hash - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/strings/strings - - abseil/types/optional - - abseil/types/variant - - abseil/utility/utility - - abseil/xcprivacy - - abseil/hash/low_level_hash (1.20240116.2): - - abseil/base/config - - abseil/base/endian - - abseil/base/prefetch - - abseil/numeric/int128 - - abseil/xcprivacy - - abseil/memory (1.20240116.2): - - abseil/memory/memory (= 1.20240116.2) - - abseil/memory/memory (1.20240116.2): - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/meta (1.20240116.2): - - abseil/meta/type_traits (= 1.20240116.2) - - abseil/meta/type_traits (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/numeric/bits (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/numeric/int128 (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/numeric/bits - - abseil/xcprivacy - - abseil/numeric/representation (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/profiling/exponential_biased (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/xcprivacy - - abseil/profiling/sample_recorder (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/synchronization/synchronization - - abseil/time/time - - abseil/xcprivacy - - abseil/random/bit_gen_ref (1.20240116.2): - - abseil/base/core_headers - - abseil/base/fast_type_id - - abseil/meta/type_traits - - abseil/random/internal/distribution_caller - - abseil/random/internal/fast_uniform_bits - - abseil/random/random - - abseil/xcprivacy - - abseil/random/distributions (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/random/internal/distribution_caller - - abseil/random/internal/fast_uniform_bits - - abseil/random/internal/fastmath - - abseil/random/internal/generate_real - - abseil/random/internal/iostream_state_saver - - abseil/random/internal/traits - - abseil/random/internal/uniform_helper - - abseil/random/internal/wide_multiply - - abseil/strings/strings - - abseil/xcprivacy - - abseil/random/internal/distribution_caller (1.20240116.2): - - abseil/base/config - - abseil/base/fast_type_id - - abseil/utility/utility - - abseil/xcprivacy - - abseil/random/internal/fast_uniform_bits (1.20240116.2): - - abseil/base/config - - abseil/meta/type_traits - - abseil/random/internal/traits - - abseil/xcprivacy - - abseil/random/internal/fastmath (1.20240116.2): - - abseil/numeric/bits - - abseil/xcprivacy - - abseil/random/internal/generate_real (1.20240116.2): - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/random/internal/fastmath - - abseil/random/internal/traits - - abseil/xcprivacy - - abseil/random/internal/iostream_state_saver (1.20240116.2): - - abseil/meta/type_traits - - abseil/numeric/int128 - - abseil/xcprivacy - - abseil/random/internal/nonsecure_base (1.20240116.2): - - abseil/base/core_headers - - abseil/container/inlined_vector - - abseil/meta/type_traits - - abseil/random/internal/pool_urbg - - abseil/random/internal/salted_seed_seq - - abseil/random/internal/seed_material - - abseil/types/span - - abseil/xcprivacy - - abseil/random/internal/pcg_engine (1.20240116.2): - - abseil/base/config - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/random/internal/fastmath - - abseil/random/internal/iostream_state_saver - - abseil/xcprivacy - - abseil/random/internal/platform (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/random/internal/pool_urbg (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/raw_logging_internal - - abseil/random/internal/randen - - abseil/random/internal/seed_material - - abseil/random/internal/traits - - abseil/random/seed_gen_exception - - abseil/types/span - - abseil/xcprivacy - - abseil/random/internal/randen (1.20240116.2): - - abseil/base/raw_logging_internal - - abseil/random/internal/platform - - abseil/random/internal/randen_hwaes - - abseil/random/internal/randen_slow - - abseil/xcprivacy - - abseil/random/internal/randen_engine (1.20240116.2): - - abseil/base/endian - - abseil/meta/type_traits - - abseil/random/internal/iostream_state_saver - - abseil/random/internal/randen - - abseil/xcprivacy - - abseil/random/internal/randen_hwaes (1.20240116.2): - - abseil/base/config - - abseil/random/internal/platform - - abseil/random/internal/randen_hwaes_impl - - abseil/xcprivacy - - abseil/random/internal/randen_hwaes_impl (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/numeric/int128 - - abseil/random/internal/platform - - abseil/xcprivacy - - abseil/random/internal/randen_slow (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/numeric/int128 - - abseil/random/internal/platform - - abseil/xcprivacy - - abseil/random/internal/salted_seed_seq (1.20240116.2): - - abseil/container/inlined_vector - - abseil/meta/type_traits - - abseil/random/internal/seed_material - - abseil/types/optional - - abseil/types/span - - abseil/xcprivacy - - abseil/random/internal/seed_material (1.20240116.2): - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/raw_logging_internal - - abseil/random/internal/fast_uniform_bits - - abseil/strings/strings - - abseil/types/optional - - abseil/types/span - - abseil/xcprivacy - - abseil/random/internal/traits (1.20240116.2): - - abseil/base/config - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/xcprivacy - - abseil/random/internal/uniform_helper (1.20240116.2): - - abseil/base/config - - abseil/meta/type_traits - - abseil/numeric/int128 - - abseil/random/internal/traits - - abseil/xcprivacy - - abseil/random/internal/wide_multiply (1.20240116.2): - - abseil/base/config - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/random/internal/traits - - abseil/xcprivacy - - abseil/random/random (1.20240116.2): - - abseil/random/distributions - - abseil/random/internal/nonsecure_base - - abseil/random/internal/pcg_engine - - abseil/random/internal/pool_urbg - - abseil/random/internal/randen_engine - - abseil/random/seed_sequences - - abseil/xcprivacy - - abseil/random/seed_gen_exception (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/random/seed_sequences (1.20240116.2): - - abseil/base/config - - abseil/random/internal/pool_urbg - - abseil/random/internal/salted_seed_seq - - abseil/random/internal/seed_material - - abseil/random/seed_gen_exception - - abseil/types/span - - abseil/xcprivacy - - abseil/status/status (1.20240116.2): - - abseil/base/atomic_hook - - abseil/base/config - - abseil/base/core_headers - - abseil/base/no_destructor - - abseil/base/nullability - - abseil/base/raw_logging_internal - - abseil/base/strerror - - abseil/container/inlined_vector - - abseil/debugging/stacktrace - - abseil/debugging/symbolize - - abseil/functional/function_ref - - abseil/memory/memory - - abseil/strings/cord - - abseil/strings/str_format - - abseil/strings/strings - - abseil/types/optional - - abseil/types/span - - abseil/xcprivacy - - abseil/status/statusor (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/nullability - - abseil/base/raw_logging_internal - - abseil/meta/type_traits - - abseil/status/status - - abseil/strings/has_ostream_operator - - abseil/strings/str_format - - abseil/strings/strings - - abseil/types/variant - - abseil/utility/utility - - abseil/xcprivacy - - abseil/strings/charset (1.20240116.2): - - abseil/base/core_headers - - abseil/strings/string_view - - abseil/xcprivacy - - abseil/strings/cord (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/nullability - - abseil/base/raw_logging_internal - - abseil/container/inlined_vector - - abseil/crc/crc32c - - abseil/crc/crc_cord_state - - abseil/functional/function_ref - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/strings/cord_internal - - abseil/strings/cordz_functions - - abseil/strings/cordz_info - - abseil/strings/cordz_statistics - - abseil/strings/cordz_update_scope - - abseil/strings/cordz_update_tracker - - abseil/strings/internal - - abseil/strings/strings - - abseil/types/optional - - abseil/types/span - - abseil/xcprivacy - - abseil/strings/cord_internal (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/raw_logging_internal - - abseil/base/throw_delegate - - abseil/container/compressed_tuple - - abseil/container/container_memory - - abseil/container/inlined_vector - - abseil/container/layout - - abseil/crc/crc_cord_state - - abseil/functional/function_ref - - abseil/meta/type_traits - - abseil/strings/strings - - abseil/types/span - - abseil/xcprivacy - - abseil/strings/cordz_functions (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/profiling/exponential_biased - - abseil/xcprivacy - - abseil/strings/cordz_handle (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/synchronization/synchronization - - abseil/xcprivacy - - abseil/strings/cordz_info (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/container/inlined_vector - - abseil/debugging/stacktrace - - abseil/strings/cord_internal - - abseil/strings/cordz_functions - - abseil/strings/cordz_handle - - abseil/strings/cordz_statistics - - abseil/strings/cordz_update_tracker - - abseil/synchronization/synchronization - - abseil/time/time - - abseil/types/span - - abseil/xcprivacy - - abseil/strings/cordz_statistics (1.20240116.2): - - abseil/base/config - - abseil/strings/cordz_update_tracker - - abseil/xcprivacy - - abseil/strings/cordz_update_scope (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/strings/cord_internal - - abseil/strings/cordz_info - - abseil/strings/cordz_update_tracker - - abseil/xcprivacy - - abseil/strings/cordz_update_tracker (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/strings/has_ostream_operator (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/strings/internal (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/raw_logging_internal - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/strings/str_format (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/nullability - - abseil/strings/str_format_internal - - abseil/strings/string_view - - abseil/types/span - - abseil/xcprivacy - - abseil/strings/str_format_internal (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/container/fixed_array - - abseil/container/inlined_vector - - abseil/functional/function_ref - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/numeric/representation - - abseil/strings/strings - - abseil/types/optional - - abseil/types/span - - abseil/utility/utility - - abseil/xcprivacy - - abseil/strings/string_view (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/nullability - - abseil/base/throw_delegate - - abseil/xcprivacy - - abseil/strings/strings (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/endian - - abseil/base/nullability - - abseil/base/raw_logging_internal - - abseil/base/throw_delegate - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/numeric/bits - - abseil/numeric/int128 - - abseil/strings/charset - - abseil/strings/internal - - abseil/strings/string_view - - abseil/xcprivacy - - abseil/synchronization/graphcycles_internal (1.20240116.2): - - abseil/base/base - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/malloc_internal - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/synchronization/kernel_timeout_internal (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/time/time - - abseil/xcprivacy - - abseil/synchronization/synchronization (1.20240116.2): - - abseil/base/atomic_hook - - abseil/base/base - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/dynamic_annotations - - abseil/base/malloc_internal - - abseil/base/raw_logging_internal - - abseil/debugging/stacktrace - - abseil/debugging/symbolize - - abseil/synchronization/graphcycles_internal - - abseil/synchronization/kernel_timeout_internal - - abseil/time/time - - abseil/xcprivacy - - abseil/time (1.20240116.2): - - abseil/time/internal (= 1.20240116.2) - - abseil/time/time (= 1.20240116.2) - - abseil/time/internal (1.20240116.2): - - abseil/time/internal/cctz (= 1.20240116.2) - - abseil/time/internal/cctz (1.20240116.2): - - abseil/time/internal/cctz/civil_time (= 1.20240116.2) - - abseil/time/internal/cctz/time_zone (= 1.20240116.2) - - abseil/time/internal/cctz/civil_time (1.20240116.2): - - abseil/base/config - - abseil/xcprivacy - - abseil/time/internal/cctz/time_zone (1.20240116.2): - - abseil/base/config - - abseil/time/internal/cctz/civil_time - - abseil/xcprivacy - - abseil/time/time (1.20240116.2): - - abseil/base/base - - abseil/base/config - - abseil/base/core_headers - - abseil/base/raw_logging_internal - - abseil/numeric/int128 - - abseil/strings/strings - - abseil/time/internal/cctz/civil_time - - abseil/time/internal/cctz/time_zone - - abseil/types/optional - - abseil/xcprivacy - - abseil/types (1.20240116.2): - - abseil/types/any (= 1.20240116.2) - - abseil/types/bad_any_cast (= 1.20240116.2) - - abseil/types/bad_any_cast_impl (= 1.20240116.2) - - abseil/types/bad_optional_access (= 1.20240116.2) - - abseil/types/bad_variant_access (= 1.20240116.2) - - abseil/types/compare (= 1.20240116.2) - - abseil/types/optional (= 1.20240116.2) - - abseil/types/span (= 1.20240116.2) - - abseil/types/variant (= 1.20240116.2) - - abseil/types/any (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/base/fast_type_id - - abseil/meta/type_traits - - abseil/types/bad_any_cast - - abseil/utility/utility - - abseil/xcprivacy - - abseil/types/bad_any_cast (1.20240116.2): - - abseil/base/config - - abseil/types/bad_any_cast_impl - - abseil/xcprivacy - - abseil/types/bad_any_cast_impl (1.20240116.2): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/types/bad_optional_access (1.20240116.2): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/types/bad_variant_access (1.20240116.2): - - abseil/base/config - - abseil/base/raw_logging_internal - - abseil/xcprivacy - - abseil/types/compare (1.20240116.2): - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/types/optional (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/base/nullability - - abseil/memory/memory - - abseil/meta/type_traits - - abseil/types/bad_optional_access - - abseil/utility/utility - - abseil/xcprivacy - - abseil/types/span (1.20240116.2): - - abseil/algorithm/algorithm - - abseil/base/core_headers - - abseil/base/nullability - - abseil/base/throw_delegate - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/types/variant (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/base/core_headers - - abseil/meta/type_traits - - abseil/types/bad_variant_access - - abseil/utility/utility - - abseil/xcprivacy - - abseil/utility/utility (1.20240116.2): - - abseil/base/base_internal - - abseil/base/config - - abseil/meta/type_traits - - abseil/xcprivacy - - abseil/xcprivacy (1.20240116.2) - - AppAuth (1.6.2): - - AppAuth/Core (= 1.6.2) - - AppAuth/ExternalUserAgent (= 1.6.2) - - AppAuth/Core (1.6.2) - - AppAuth/ExternalUserAgent (1.6.2): - - AppAuth/Core - - BoringSSL-GRPC (0.0.32): - - BoringSSL-GRPC/Implementation (= 0.0.32) - - BoringSSL-GRPC/Interface (= 0.0.32) - - BoringSSL-GRPC/Implementation (0.0.32): - - BoringSSL-GRPC/Interface (= 0.0.32) - - BoringSSL-GRPC/Interface (0.0.32) - - cloud_firestore (4.15.4): - - Firebase/Firestore (= 10.24.0) - - firebase_core - - Flutter - - nanopb (< 2.30910.0, >= 2.30908.0) - - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.4) - - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): - DKImagePickerController/Core - DKPhotoGallery - - DKImagePickerController/Resource (4.3.4) - - DKPhotoGallery (0.0.17): - - DKPhotoGallery/Core (= 0.0.17) - - DKPhotoGallery/Model (= 0.0.17) - - DKPhotoGallery/Preview (= 0.0.17) - - DKPhotoGallery/Resource (= 0.0.17) + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) - SDWebImage - SwiftyGif - - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Core (0.0.19): - DKPhotoGallery/Model - DKPhotoGallery/Preview - SDWebImage - SwiftyGif - - DKPhotoGallery/Model (0.0.17): + - DKPhotoGallery/Model (0.0.19): - SDWebImage - SwiftyGif - - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Preview (0.0.19): - DKPhotoGallery/Model - DKPhotoGallery/Resource - SDWebImage - SwiftyGif - - DKPhotoGallery/Resource (0.0.17): + - DKPhotoGallery/Resource (0.0.19): - SDWebImage - SwiftyGif - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/Auth (10.24.0): + - Firebase/CoreOnly (11.0.0): + - FirebaseCore (= 11.0.0) + - Firebase/Messaging (11.0.0): - Firebase/CoreOnly - - FirebaseAuth (~> 10.24.0) - - Firebase/CoreOnly (10.24.0): - - FirebaseCore (= 10.24.0) - - Firebase/Firestore (10.24.0): - - Firebase/CoreOnly - - FirebaseFirestore (~> 10.24.0) - - Firebase/Messaging (10.24.0): - - Firebase/CoreOnly - - FirebaseMessaging (~> 10.24.0) - - Firebase/Storage (10.24.0): - - Firebase/CoreOnly - - FirebaseStorage (~> 10.24.0) - - firebase_auth (4.17.8): - - Firebase/Auth (= 10.24.0) + - FirebaseMessaging (~> 11.0.0) + - firebase_core (3.4.0): + - Firebase/CoreOnly (= 11.0.0) + - Flutter + - firebase_messaging (15.1.0): + - Firebase/Messaging (= 11.0.0) - firebase_core - Flutter - - firebase_core (2.30.0): - - Firebase/CoreOnly (= 10.24.0) - - Flutter - - firebase_messaging (14.8.2): - - Firebase/Messaging (= 10.24.0) - - firebase_core - - Flutter - - firebase_storage (11.6.5): - - Firebase/Storage (= 10.24.0) - - firebase_core - - Flutter - - FirebaseAppCheckInterop (10.24.0) - - FirebaseAuth (10.24.0): - - FirebaseAppCheckInterop (~> 10.17) - - FirebaseCore (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/Environment (~> 7.8) - - GTMSessionFetcher/Core (< 4.0, >= 2.1) - - RecaptchaInterop (~> 100.0) - - FirebaseAuthInterop (10.24.0) - - FirebaseCore (10.24.0): - - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.12) - - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreExtension (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.24.0): - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseFirestore (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreExtension (~> 10.0) - - FirebaseFirestoreInternal (= 10.24.0) - - FirebaseSharedSwift (~> 10.0) - - FirebaseFirestoreInternal (10.24.0): - - abseil/algorithm (~> 1.20240116.1) - - abseil/base (~> 1.20240116.1) - - abseil/container/flat_hash_map (~> 1.20240116.1) - - abseil/memory (~> 1.20240116.1) - - abseil/meta (~> 1.20240116.1) - - abseil/strings/strings (~> 1.20240116.1) - - abseil/time (~> 1.20240116.1) - - abseil/types (~> 1.20240116.1) - - FirebaseAppCheckInterop (~> 10.17) - - FirebaseCore (~> 10.0) - - "gRPC-C++ (~> 1.62.0)" - - gRPC-Core (~> 1.62.0) - - leveldb-library (~> 1.22) - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseInstallations (10.24.0): - - FirebaseCore (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - PromisesObjC (~> 2.1) - - FirebaseMessaging (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.3) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/Reachability (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseSharedSwift (10.24.0) - - FirebaseStorage (10.24.0): - - FirebaseAppCheckInterop (~> 10.0) - - FirebaseAuthInterop (~> 10.0) - - FirebaseCore (~> 10.0) - - FirebaseCoreExtension (~> 10.0) - - GTMSessionFetcher/Core (< 4.0, >= 2.1) + - FirebaseCore (11.0.0): + - FirebaseCoreInternal (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.4.0): + - FirebaseCore (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (11.0.0): + - FirebaseCore (~> 11.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Reachability (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - nanopb (~> 3.30910.0) - Flutter (1.0.0) - flutter_email_sender (0.0.1): - Flutter @@ -1095,170 +76,73 @@ PODS: - Flutter - google_maps_flutter_ios (0.0.1): - Flutter - - GoogleMaps (< 8.0) - - google_sign_in_ios (0.0.1): - - Flutter - - GoogleSignIn (~> 6.2) - - GoogleDataTransport (9.4.1): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleMaps (5.2.0): - - GoogleMaps/Maps (= 5.2.0) - - GoogleMaps/Base (5.2.0) - - GoogleMaps/Maps (5.2.0): + - GoogleMaps (< 9.0, >= 8.4) + - GoogleDataTransport (10.1.1): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMaps (8.4.0): + - GoogleMaps/Maps (= 8.4.0) + - GoogleMaps/Base (8.4.0) + - GoogleMaps/Maps (8.4.0): - GoogleMaps/Base - - GoogleSignIn (6.2.4): - - AppAuth (~> 1.5) - - GTMAppAuth (~> 1.3) - - GTMSessionFetcher/Core (< 3.0, >= 1.1) - - GoogleUtilities/AppDelegateSwizzler (7.13.0): + - GoogleUtilities/AppDelegateSwizzler (8.1.2): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Environment (8.1.2): - GoogleUtilities/Privacy - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.13.0): + - GoogleUtilities/Logger (8.1.2): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): + - GoogleUtilities/Network (8.1.2): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": + - "GoogleUtilities/NSData+zlib (8.1.2)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): + - GoogleUtilities/Privacy (8.1.2) + - GoogleUtilities/Reachability (8.1.2): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): + - GoogleUtilities/UserDefaults (8.1.2): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - "gRPC-C++ (1.62.5)": - - "gRPC-C++/Implementation (= 1.62.5)" - - "gRPC-C++/Interface (= 1.62.5)" - - "gRPC-C++/Implementation (1.62.5)": - - abseil/algorithm/container (~> 1.20240116.2) - - abseil/base/base (~> 1.20240116.2) - - abseil/base/config (~> 1.20240116.2) - - abseil/base/core_headers (~> 1.20240116.2) - - abseil/cleanup/cleanup (~> 1.20240116.2) - - abseil/container/flat_hash_map (~> 1.20240116.2) - - abseil/container/flat_hash_set (~> 1.20240116.2) - - abseil/container/inlined_vector (~> 1.20240116.2) - - abseil/flags/flag (~> 1.20240116.2) - - abseil/flags/marshalling (~> 1.20240116.2) - - abseil/functional/any_invocable (~> 1.20240116.2) - - abseil/functional/bind_front (~> 1.20240116.2) - - abseil/functional/function_ref (~> 1.20240116.2) - - abseil/hash/hash (~> 1.20240116.2) - - abseil/memory/memory (~> 1.20240116.2) - - abseil/meta/type_traits (~> 1.20240116.2) - - abseil/random/bit_gen_ref (~> 1.20240116.2) - - abseil/random/distributions (~> 1.20240116.2) - - abseil/random/random (~> 1.20240116.2) - - abseil/status/status (~> 1.20240116.2) - - abseil/status/statusor (~> 1.20240116.2) - - abseil/strings/cord (~> 1.20240116.2) - - abseil/strings/str_format (~> 1.20240116.2) - - abseil/strings/strings (~> 1.20240116.2) - - abseil/synchronization/synchronization (~> 1.20240116.2) - - abseil/time/time (~> 1.20240116.2) - - abseil/types/optional (~> 1.20240116.2) - - abseil/types/span (~> 1.20240116.2) - - abseil/types/variant (~> 1.20240116.2) - - abseil/utility/utility (~> 1.20240116.2) - - "gRPC-C++/Interface (= 1.62.5)" - - "gRPC-C++/Privacy (= 1.62.5)" - - gRPC-Core (= 1.62.5) - - "gRPC-C++/Interface (1.62.5)" - - "gRPC-C++/Privacy (1.62.5)" - - gRPC-Core (1.62.5): - - gRPC-Core/Implementation (= 1.62.5) - - gRPC-Core/Interface (= 1.62.5) - - gRPC-Core/Implementation (1.62.5): - - abseil/algorithm/container (~> 1.20240116.2) - - abseil/base/base (~> 1.20240116.2) - - abseil/base/config (~> 1.20240116.2) - - abseil/base/core_headers (~> 1.20240116.2) - - abseil/cleanup/cleanup (~> 1.20240116.2) - - abseil/container/flat_hash_map (~> 1.20240116.2) - - abseil/container/flat_hash_set (~> 1.20240116.2) - - abseil/container/inlined_vector (~> 1.20240116.2) - - abseil/flags/flag (~> 1.20240116.2) - - abseil/flags/marshalling (~> 1.20240116.2) - - abseil/functional/any_invocable (~> 1.20240116.2) - - abseil/functional/bind_front (~> 1.20240116.2) - - abseil/functional/function_ref (~> 1.20240116.2) - - abseil/hash/hash (~> 1.20240116.2) - - abseil/memory/memory (~> 1.20240116.2) - - abseil/meta/type_traits (~> 1.20240116.2) - - abseil/random/bit_gen_ref (~> 1.20240116.2) - - abseil/random/distributions (~> 1.20240116.2) - - abseil/random/random (~> 1.20240116.2) - - abseil/status/status (~> 1.20240116.2) - - abseil/status/statusor (~> 1.20240116.2) - - abseil/strings/cord (~> 1.20240116.2) - - abseil/strings/str_format (~> 1.20240116.2) - - abseil/strings/strings (~> 1.20240116.2) - - abseil/synchronization/synchronization (~> 1.20240116.2) - - abseil/time/time (~> 1.20240116.2) - - abseil/types/optional (~> 1.20240116.2) - - abseil/types/span (~> 1.20240116.2) - - abseil/types/variant (~> 1.20240116.2) - - abseil/utility/utility (~> 1.20240116.2) - - BoringSSL-GRPC (= 0.0.32) - - gRPC-Core/Interface (= 1.62.5) - - gRPC-Core/Privacy (= 1.62.5) - - gRPC-Core/Interface (1.62.5) - - gRPC-Core/Privacy (1.62.5) - - GTMAppAuth (1.3.1): - - AppAuth/Core (~> 1.6) - - GTMSessionFetcher/Core (< 3.0, >= 1.5) - - GTMSessionFetcher/Core (2.3.0) - image_picker_ios (0.0.1): - Flutter - - leveldb-library (1.22.5) - location (0.0.1): - Flutter - - nanopb (2.30909.1): - - nanopb/decode (= 2.30909.1) - - nanopb/encode (= 2.30909.1) - - nanopb/decode (2.30909.1) - - nanopb/encode (2.30909.1) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter - - PromisesObjC (2.4.0) - - RecaptchaInterop (100.0.0) - - SDWebImage (5.18.5): - - SDWebImage/Core (= 5.18.5) - - SDWebImage/Core (5.18.5) + - PromisesObjC (2.4.1) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - SwiftyGif (5.4.4) + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): - Flutter DEPENDENCIES: - - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - - firebase_storage (from `.symlinks/plugins/firebase_storage/ios`) - Flutter (from `Flutter`) - flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - geocoding_ios (from `.symlinks/plugins/geocoding_ios/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - location (from `.symlinks/plugins/location/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -1268,52 +152,28 @@ DEPENDENCIES: SPEC REPOS: trunk: - - abseil - - AppAuth - - BoringSSL-GRPC - DKImagePickerController - DKPhotoGallery - Firebase - - FirebaseAppCheckInterop - - FirebaseAuth - - FirebaseAuthInterop - FirebaseCore - - FirebaseCoreExtension - FirebaseCoreInternal - - FirebaseFirestore - - FirebaseFirestoreInternal - FirebaseInstallations - FirebaseMessaging - - FirebaseSharedSwift - - FirebaseStorage - GoogleDataTransport - GoogleMaps - - GoogleSignIn - GoogleUtilities - - "gRPC-C++" - - gRPC-Core - - GTMAppAuth - - GTMSessionFetcher - - leveldb-library - nanopb - PromisesObjC - - RecaptchaInterop - SDWebImage - SwiftyGif EXTERNAL SOURCES: - cloud_firestore: - :path: ".symlinks/plugins/cloud_firestore/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" - firebase_auth: - :path: ".symlinks/plugins/firebase_auth/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: :path: ".symlinks/plugins/firebase_messaging/ios" - firebase_storage: - :path: ".symlinks/plugins/firebase_storage/ios" Flutter: :path: Flutter flutter_email_sender: @@ -1326,8 +186,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/geolocator_apple/ios" google_maps_flutter_ios: :path: ".symlinks/plugins/google_maps_flutter_ios/ios" - google_sign_in_ios: - :path: ".symlinks/plugins/google_sign_in_ios/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" location: @@ -1342,58 +200,36 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: - abseil: d121da9ef7e2ff4cab7666e76c5a3e0915ae08c3 - AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 - BoringSSL-GRPC: 1e2348957acdbcad360b80a264a90799984b2ba6 - cloud_firestore: a0186892bf5ab4fce7f6ac73153fb10a4ead3438 - DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac - DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: ce3938a0df3cc1ef404671531facef740d03f920 - Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 - firebase_auth: fbab032fb9e0cad83046a547465b60d2a8b9ab21 - firebase_core: 66b99b4fb4e5d7cc4e88d4c195fe986681f3466a - firebase_messaging: c836cceaddf52552da6f6e8963fb21d1d1a0f0cf - firebase_storage: e303d34966d5cba2c7458d07b5a4de9d4855af02 - FirebaseAppCheckInterop: fecc08c89936c8acb1428d8088313aabedb348e4 - FirebaseAuth: 711d01cccefaf10035b3090a92956d0dd4f99088 - FirebaseAuthInterop: 29336ab84df12fc0f340ba5fe58d3e5811a4192d - FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 - FirebaseCoreExtension: af5fd85e817ea9d19f9a2659a376cf9cf99f03c0 - FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af - FirebaseFirestore: 6df1bc70a56c15921286ff2a3096fa2d350b8823 - FirebaseFirestoreInternal: d9a6e08e9bb4016ce7c0b3544f1cf7abcd7cf26f - FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e - FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d - FirebaseSharedSwift: 76e1529c32101d80e4f1ca2fba7c39d59f0a390a - FirebaseStorage: 03710f9a0e3824d3069ed1128601a3d3a5e7d817 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9 + firebase_core: ceec591a66629daaee82d3321551692c4a871493 + firebase_messaging: 15d8b557010f3bb7b98d0302e1c7c8fbcd244425 + FirebaseCore: 3cf438f431f18c12cdf2aaf64434648b63f7e383 + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 + FirebaseMessaging: d2d1d9c62c46dd2db49a952f7deb5b16ad2c9742 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b - flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 - geocoding_ios: a389ea40f6f548de6e63006a2e31bf66ff80769a - geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401 - google_maps_flutter_ios: abdac20d6ce8931f6ebc5f46616df241bfaa2cfd - google_sign_in_ios: 1256ff9d941db546373826966720b0c24804bcdd - GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a - GoogleMaps: 025272d5876d3b32604e5c080dc25eaf68764693 - GoogleSignIn: 5651ce3a61e56ca864160e79b484cd9ed3f49b7a - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 - "gRPC-C++": e725ef63c4475d7cdb7e2cf16eb0fde84bd9ee51 - gRPC-Core: eee4be35df218649fe66d721a05a7f27a28f069b - GTMAppAuth: 0ff230db599948a9ad7470ca667337803b3fc4dd - GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - leveldb-library: e8eadf9008a61f9e1dde3978c086d2b6d9b9dc28 + flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40 + flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 + geocoding_ios: d7460f56e80e118d57678efe5c2cdc888739ff18 + geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 + google_maps_flutter_ios: c454f18e0e22df6ac0e9f2a4df340858f5a3680c + GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539 + GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d + GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 location: d5cf8598915965547c3f36761ae9cc4f4e87d22e - nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 - package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 - SDWebImage: 7ac2b7ddc5e8484c79aa90fc4e30b149d6a2c88f - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86 - webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36 -PODFILE CHECKSUM: c34aeeb156e919dc0836ac3748a22519c754f4eb +PODFILE CHECKSUM: ec547244b0694f60746d06f280364578f1e3d8de COCOAPODS: 1.15.2 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1cac01c..1d41d28 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,7 +3,7 @@ import Flutter import GoogleMaps import Firebase -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/lib/blocs/professional_bloc/professional_bloc.dart b/lib/blocs/professional_bloc/professional_bloc.dart index 25a5a86..ef47e24 100644 --- a/lib/blocs/professional_bloc/professional_bloc.dart +++ b/lib/blocs/professional_bloc/professional_bloc.dart @@ -22,6 +22,9 @@ class ProfessionalBloc extends Bloc { bool isProModeActive = _professionalRepository.isProModeActive; final proInfo = _professionalRepository.lastProInfo(); emit(LoadedModeProState(isProModeActive, proInfo)); + if (isProModeActive && proInfo == null) { + _fetchMyProInfo(); + } _professionalRepository.streamProInfo().listen((proInfo) { if (proInfo == null) { @@ -40,14 +43,33 @@ class ProfessionalBloc extends Bloc { await _professionalRepository.switchProMode(); }); + on((event, emit) async { + emit(ResetProfessionalApplicationLoading()); + try { + await _professionalRepository.deleteProfessionalInfo(); + emit(ResetProfessionalApplicationSuccess()); + } catch (e) { + log(e.toString()); + emit(ProfessionalStateFailure(message: e.toString())); + } + }); + on((event, emit) async { final proInfo = _professionalRepository.lastProInfo(); emit(LoadedModeProState(event.isProModeActive, proInfo)); + if (event.isProModeActive && proInfo == null) { + await _fetchMyProInfo(); + } }); on((event, emit) async { + emit(SendProfessionalToReviewLoading()); final myUser = await _userRepository.lastUser(); - if (myUser == null) return; + if (myUser == null) { + emit(const SendProfessionalToReviewFailure( + message: 'No se pudo identificar tu usuario')); + return; + } try { final identificationPdfUrl = await _professionalRepository .uploadPdfCedula(event.identificationPicture, myUser.id); @@ -83,10 +105,18 @@ class ProfessionalBloc extends Bloc { schedules: Schedules.empty, paymentMethods: PaymentMethodEntity.empty, )); + + emit(SendProfessionalToReviewSuccess()); } catch (e) { log('error acceso a pro ${e.toString()}'); + emit(SendProfessionalToReviewFailure(message: e.toString())); } - // _userRepository.updateUserInfo(myUser.copyWith(proState: ProState.pending)); }); } + + Future _fetchMyProInfo() async { + final myUser = await _userRepository.lastUser(); + if (myUser == null) return; + await _professionalRepository.updateFromFirebase(userId: myUser.id); + } } diff --git a/lib/blocs/professional_bloc/professional_event.dart b/lib/blocs/professional_bloc/professional_event.dart index 1be9bb0..be73f35 100644 --- a/lib/blocs/professional_bloc/professional_event.dart +++ b/lib/blocs/professional_bloc/professional_event.dart @@ -19,6 +19,10 @@ class SwitchProModeEvent extends ProfessionalEvent { const SwitchProModeEvent(); } +class ResetProfessionalApplicationEvent extends ProfessionalEvent { + const ResetProfessionalApplicationEvent(); +} + class SendProfessionalToReviewEvent extends ProfessionalEvent { final String id; final String identification; diff --git a/lib/blocs/professional_bloc/professional_state.dart b/lib/blocs/professional_bloc/professional_state.dart index 6455dac..dfff624 100644 --- a/lib/blocs/professional_bloc/professional_state.dart +++ b/lib/blocs/professional_bloc/professional_state.dart @@ -17,6 +17,23 @@ class ProfessionalStateFailure extends ProfessionalState { class ProfessionalStateProcess extends ProfessionalState {} +class ResetProfessionalApplicationLoading extends ProfessionalState {} + +class ResetProfessionalApplicationSuccess extends ProfessionalState {} + +class SendProfessionalToReviewLoading extends ProfessionalState {} + +class SendProfessionalToReviewSuccess extends ProfessionalState {} + +class SendProfessionalToReviewFailure extends ProfessionalState { + final String? message; + + const SendProfessionalToReviewFailure({this.message}); + + @override + List get props => [message]; +} + class LoadedModeProState extends ProfessionalState { final bool isProModeActive; final ProfessionalEntity? proInfo; diff --git a/lib/blocs/professional_list_bloc/professional_list_bloc.dart b/lib/blocs/professional_list_bloc/professional_list_bloc.dart index ad62694..6265a86 100644 --- a/lib/blocs/professional_list_bloc/professional_list_bloc.dart +++ b/lib/blocs/professional_list_bloc/professional_list_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:professional_repository/professional_repository.dart'; @@ -24,27 +26,76 @@ class ProfessionalListBloc emit(ProfessionalListLoading()); final users = await _userRepository.getUsersProfessionalActive(); - final professionals = - await _firebaseProfessionalRepository.getProfessionalInfo(); + final hasSearchParams = event.search != null || + event.city != null || + event.lat != null || + event.lng != null; - final professionalInfoMap = {for (var doc in professionals) doc.id: doc}; + if (!hasSearchParams) { + final professionals = + await _firebaseProfessionalRepository.getProfessionalInfo(); - final usersMap = users - .map((user) => UserProfessional( - myUser: user, - professionalInfo: professionalInfoMap[user.id]!, + final professionalInfoMap = {for (var doc in professionals) doc.id: doc}; + + final usersMap = users + .where((user) => professionalInfoMap.containsKey(user.id)) + .map((user) => UserProfessional( + myUser: user, + professionalInfo: professionalInfoMap[user.id]!, + )) + .toList(); + + emit(ProfessionalListSuccess(users: usersMap)); + return; + } + + final usersDir = {for (var u in users) u.id: u}; + + final professionals = await _firebaseProfessionalRepository.searchProfessionals( + search: event.search, + city: event.city, + lat: event.lat, + lng: event.lng, + ); + + final usersMap = professionals + .where((p) => usersDir.containsKey(p.id)) + .map((p) => UserProfessional( + myUser: usersDir[p.id]!, + professionalInfo: p, + distanceKm: (event.lat != null && event.lng != null) + ? _haversineKm(event.lat!, event.lng!, p.latitude, p.longitude) + : null, )) .toList(); emit(ProfessionalListSuccess(users: usersMap)); } + + double? _haversineKm(double lat1, double lng1, double lat2, double lng2) { + if (lat2 == 0.0 && lng2 == 0.0) return null; + const r = 6371.0; + final dLat = (lat2 - lat1) * math.pi / 180; + final dLng = (lng2 - lng1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } } class UserProfessional { final MyUser myUser; final ProfessionalEntity professionalInfo; + final double? distanceKm; - UserProfessional({required this.myUser, required this.professionalInfo}); + UserProfessional({ + required this.myUser, + required this.professionalInfo, + this.distanceKm, + }); @override String toString() { diff --git a/lib/blocs/professional_list_bloc/professional_list_event.dart b/lib/blocs/professional_list_bloc/professional_list_event.dart index f9232e5..94d5cd3 100644 --- a/lib/blocs/professional_list_bloc/professional_list_event.dart +++ b/lib/blocs/professional_list_bloc/professional_list_event.dart @@ -8,8 +8,18 @@ abstract class ProfessionalListEvent extends Equatable { } class ProfessionalListFetch extends ProfessionalListEvent { - const ProfessionalListFetch(); + final String? search; + final String? city; + final double? lat; + final double? lng; + + const ProfessionalListFetch({this.search, this.city, this.lat, this.lng}); @override - List get props => []; + List get props => [ + search ?? '', + city ?? '', + lat ?? 0, + lng ?? 0, + ]; } diff --git a/lib/blocs/professional_profile_bloc/professional_profile_bloc.dart b/lib/blocs/professional_profile_bloc/professional_profile_bloc.dart index 6390b93..579986b 100644 --- a/lib/blocs/professional_profile_bloc/professional_profile_bloc.dart +++ b/lib/blocs/professional_profile_bloc/professional_profile_bloc.dart @@ -45,6 +45,7 @@ class ProfessionalProfileBloc event.longitude, event.schedules, event.paymentMethods, + slotDurationMinutes: event.slotDurationMinutes, ); emit(const UpdateProfessionalInfoSuccess()); diff --git a/lib/blocs/professional_profile_bloc/professional_profile_event.dart b/lib/blocs/professional_profile_bloc/professional_profile_event.dart index f372f6a..0fdada0 100644 --- a/lib/blocs/professional_profile_bloc/professional_profile_event.dart +++ b/lib/blocs/professional_profile_bloc/professional_profile_event.dart @@ -28,6 +28,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent { final double longitude; final Schedules schedules; final PaymentMethodEntity paymentMethods; + final int slotDurationMinutes; const UpdateProfessionalProfileInfo({ required this.address, @@ -39,6 +40,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent { required this.longitude, required this.schedules, required this.paymentMethods, + this.slotDurationMinutes = 120, }); @override @@ -51,6 +53,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent { latitude, longitude, schedules, - paymentMethods + paymentMethods, + slotDurationMinutes, ]; } diff --git a/lib/blocs/service_bloc/service_bloc.dart b/lib/blocs/service_bloc/service_bloc.dart index 67dfd49..9fd30fb 100644 --- a/lib/blocs/service_bloc/service_bloc.dart +++ b/lib/blocs/service_bloc/service_bloc.dart @@ -25,6 +25,7 @@ class ServiceBloc extends Bloc { _professionalRepository = professionRepository, super(CreateServiceInitial()) { on(_onCreateService); + on(_onBlockSlot); on(_onLoadService); on(_onUpdateServiceStatus); on(_onLoadServicesForUser); @@ -72,6 +73,17 @@ class ServiceBloc extends Bloc { } } + void _onBlockSlot(BlockSlot event, Emitter emit) async { + try { + emit(CreateServiceLoading()); + final serviceId = await _serviceRepository.blockSlot(event.day, event.hour1); + emit(CreateServiceSuccess(serviceId)); + } catch (e) { + log(e.toString()); + emit(CreateServiceFailure()); + } + } + void _onLoadService(LoadService event, Emitter emit) async { try { final serviceStream = _serviceRepository.getService(event.serviceId); @@ -108,7 +120,11 @@ class ServiceBloc extends Bloc { final professionsDir = {for (var e in professionsList) e.id: e}; - final servicesInfo = services.map((e) { + final servicesInfo = services + .where((e) => + usersDir.containsKey(e.professionalId) && + professionsDir.containsKey(e.professionalId)) + .map((e) { return ServiceInfoUI( service: e, user: usersDir[e.professionalId]!, @@ -141,7 +157,9 @@ class ServiceBloc extends Bloc { final usersDir = {for (var e in users) e.id: e}; - final servicesInfo = services.map((e) { + final servicesInfo = services + .where((e) => usersDir.containsKey(e.userId)) + .map((e) { return ServiceInfoUI( service: e, user: usersDir[e.userId]!, @@ -180,7 +198,11 @@ class ServiceBloc extends Bloc { final professionsDir = {for (var e in professionsList) e.id: e}; - final servicesInfo = services.map((e) { + final servicesInfo = services + .where((e) => + usersDir.containsKey(e.professionalId) && + professionsDir.containsKey(e.professionalId)) + .map((e) { return ServiceInfoUI( service: e, user: usersDir[e.professionalId]!, @@ -213,7 +235,9 @@ class ServiceBloc extends Bloc { final usersDir = {for (var e in users) e.id: e}; - final servicesInfo = services.map((e) { + final servicesInfo = services + .where((e) => usersDir.containsKey(e.userId)) + .map((e) { return ServiceInfoUI( service: e, user: usersDir[e.userId]!, @@ -246,7 +270,9 @@ class ServiceBloc extends Bloc { final usersDir = {for (var e in users) e.id: e}; - final servicesInfo = services.map((e) { + final servicesInfo = services + .where((e) => usersDir.containsKey(e.userId)) + .map((e) { return ServiceInfoUI( service: e, user: usersDir[e.userId]!, diff --git a/lib/blocs/service_bloc/service_event.dart b/lib/blocs/service_bloc/service_event.dart index c6f9a67..0073a37 100644 --- a/lib/blocs/service_bloc/service_event.dart +++ b/lib/blocs/service_bloc/service_event.dart @@ -26,6 +26,16 @@ class UpdateServiceStatus extends ServiceEvent { List get props => [serviceId, newStatus]; } +class BlockSlot extends ServiceEvent { + final String day; + final TimeOfDay hour1; + + const BlockSlot({required this.day, required this.hour1}); + + @override + List get props => [day, hour1]; +} + class LoadServicesForUser extends ServiceEvent { final String userId; diff --git a/lib/blocs/sign_up_bloc/sign_up_bloc.dart b/lib/blocs/sign_up_bloc/sign_up_bloc.dart index f2e9b1e..ff1e96d 100644 --- a/lib/blocs/sign_up_bloc/sign_up_bloc.dart +++ b/lib/blocs/sign_up_bloc/sign_up_bloc.dart @@ -22,7 +22,8 @@ class SignUpBloc extends Bloc { await _userRepository.setUserData(user); emit(SignUpSuccess()); } catch (e) { - emit(SignUpFailure()); + final msg = e.toString().replaceFirst('Exception: ', ''); + emit(SignUpFailure(message: msg)); } } } diff --git a/lib/blocs/sign_up_bloc/sign_up_state.dart b/lib/blocs/sign_up_bloc/sign_up_state.dart index 6c92251..b01a92c 100644 --- a/lib/blocs/sign_up_bloc/sign_up_state.dart +++ b/lib/blocs/sign_up_bloc/sign_up_state.dart @@ -11,6 +11,12 @@ class SignUpInitial extends SignUpState {} class SignUpSuccess extends SignUpState {} -class SignUpFailure extends SignUpState {} +class SignUpFailure extends SignUpState { + final String message; + const SignUpFailure({this.message = 'Error al registrarse. Intenta de nuevo.'}); + + @override + List get props => [message]; +} class SignUpProcess extends SignUpState {} diff --git a/lib/blocs/suggestion_bloc/suggestion_bloc.dart b/lib/blocs/suggestion_bloc/suggestion_bloc.dart new file mode 100644 index 0000000..0a21449 --- /dev/null +++ b/lib/blocs/suggestion_bloc/suggestion_bloc.dart @@ -0,0 +1,30 @@ +import 'dart:developer'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:suggestion_repository/suggestion_repository.dart'; + +part 'suggestion_event.dart'; +part 'suggestion_state.dart'; + +class SuggestionBloc extends Bloc { + final ApiSuggestionRepository _suggestionRepository; + + SuggestionBloc({required ApiSuggestionRepository suggestionRepository}) + : _suggestionRepository = suggestionRepository, + super(SuggestionInitial()) { + on(_onSubmitSuggestion); + } + + void _onSubmitSuggestion( + SubmitSuggestion event, Emitter emit) async { + emit(SuggestionLoading()); + try { + await _suggestionRepository.createSuggestion(event.message); + emit(SuggestionSuccess()); + } catch (e) { + log(e.toString()); + emit(SuggestionFailure(message: e.toString())); + } + } +} diff --git a/lib/blocs/suggestion_bloc/suggestion_event.dart b/lib/blocs/suggestion_bloc/suggestion_event.dart new file mode 100644 index 0000000..5feb9a6 --- /dev/null +++ b/lib/blocs/suggestion_bloc/suggestion_event.dart @@ -0,0 +1,17 @@ +part of 'suggestion_bloc.dart'; + +abstract class SuggestionEvent extends Equatable { + const SuggestionEvent(); + + @override + List get props => []; +} + +class SubmitSuggestion extends SuggestionEvent { + final String message; + + const SubmitSuggestion({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/blocs/suggestion_bloc/suggestion_state.dart b/lib/blocs/suggestion_bloc/suggestion_state.dart new file mode 100644 index 0000000..23d4733 --- /dev/null +++ b/lib/blocs/suggestion_bloc/suggestion_state.dart @@ -0,0 +1,23 @@ +part of 'suggestion_bloc.dart'; + +abstract class SuggestionState extends Equatable { + const SuggestionState(); + + @override + List get props => []; +} + +class SuggestionInitial extends SuggestionState {} + +class SuggestionLoading extends SuggestionState {} + +class SuggestionSuccess extends SuggestionState {} + +class SuggestionFailure extends SuggestionState { + final String? message; + + const SuggestionFailure({this.message}); + + @override + List get props => [message]; +} diff --git a/lib/components/general_drawer.dart b/lib/components/general_drawer.dart index 87f9fef..f29c9ed 100644 --- a/lib/components/general_drawer.dart +++ b/lib/components/general_drawer.dart @@ -12,7 +12,6 @@ import 'package:prosappco/components/general_drawer_header.dart'; import 'package:prosappco/components/general_drawer_item.dart'; import 'package:prosappco/screens/configuration/configuration_screen.dart'; import 'package:prosappco/screens/configuration/configuration_support_screen.dart'; -import 'package:prosappco/screens/lists/dropwdon.dart'; import 'package:prosappco/screens/lists/professional_score_list_screen.dart'; import 'package:prosappco/screens/lists/professional_service_history_list_screen.dart'; import 'package:prosappco/screens/lists/professional_service_list_screen.dart'; @@ -25,23 +24,22 @@ import 'package:prosappco/screens/professional/professional_form_screen.dart'; import 'package:prosappco/screens/professional/professional_pending_screen.dart'; import 'package:prosappco/screens/professional/professional_profile_screen.dart'; import 'package:prosappco/screens/profile/profile_screen.dart'; -import 'package:prosappco/screens/web/web_view_screen.dart'; +import 'package:prosappco/screens/suggestions/suggestion_screen.dart'; import 'package:service_repository/service_repository.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:user_repository/user_repository.dart'; +const _kPro = Color(0xFF0D9488); +const _kUser = Color(0xFF1565C0); + class GeneralDrawer extends StatelessWidget { const GeneralDrawer({super.key}); Future _irSugerencias() async { const url = 'https://admin.prosapp.co/sugerencias'; - - final Uri _url = Uri.parse(url); - - if (await canLaunchUrl(_url)) { - await launchUrl(_url); - } else { - throw 'No se pudo abrir la URL $url'; + final Uri uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); } } @@ -53,8 +51,9 @@ class GeneralDrawer extends StatelessWidget { builder: (context, userState) { return BlocBuilder( builder: (context, professionalState) { + final isProMode = _isProModeActive(professionalState); return Drawer( - backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundColor: Theme.of(context).colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -71,111 +70,100 @@ class GeneralDrawer extends StatelessWidget { child: SingleChildScrollView( child: Column( children: [ - shouldProModeActive(context, professionalState) - ? GeneralDrawerItem( - leading: Icons.person_outline_rounded, - label: 'Perfil profesional', - onTap: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ProfessionalProfileScreen(), - ), - ); - }, - ) - : const SizedBox(), + if (isProMode) + GeneralDrawerItem( + leading: Icons.person_outline_rounded, + label: 'Perfil profesional', + onTap: () => Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ProfessionalProfileScreen(), + ), + ), + ), GeneralDrawerItem( leading: Icons.checklist_outlined, label: 'Mis servicios', - onTap: () { - shouldProModeActive(context, professionalState) - ? Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ProfessionalServiceListScreen(), - // const UserServicesScreen(), - ), - ) - : Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const UserServiceListScreen(), - // const UserServicesScreen(), - ), - ); - }, + onTap: () => isProMode + ? Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ProfessionalServiceListScreen()), + ) + : Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const UserServiceListScreen()), + ), ), GeneralDrawerItem( leading: Icons.access_time_outlined, label: 'Historial', - onTap: () { - shouldProModeActive(context, professionalState) - ? Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ProfessionalServiceHistoryListScreen(), - ), - ) - : Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const UserServiceHistoryListScreen(), - ), - ); - }, + onTap: () => isProMode + ? Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ProfessionalServiceHistoryListScreen()), + ) + : Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const UserServiceHistoryListScreen()), + ), ), - shouldProModeActive(context, professionalState) - ? GeneralDrawerItem( - onTap: () { - if (professionalState - is LoadedModeProState) { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - ProfessionalCalendarScreen( - userProfessional: - professionalState.proInfo!, - ), - ), - ); - } - }, - label: 'Calendario', - leading: Icons.calendar_month_outlined, - ) - : const SizedBox(), + if (isProMode) + GeneralDrawerItem( + leading: Icons.calendar_month_outlined, + label: 'Calendario', + onTap: () { + if (professionalState + is LoadedModeProState && + professionalState.proInfo != null) { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + ProfessionalCalendarScreen( + userProfessional: + professionalState.proInfo!, + ), + ), + ); + } else { + ScaffoldMessenger.of(context) + .clearSnackBars(); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar( + content: Text( + 'Cargando tu información de profesional, intenta de nuevo en un momento'), + )); + } + }, + ), GeneralDrawerItem( leading: Icons.settings_outlined, label: 'Configuración', - onTap: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ConfigurationScreen(), - ), - ); - }, + onTap: () => Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ConfigurationScreen()), + ), ), GeneralDrawerItem( leading: Icons.help_outline, label: 'Soporte', - onTap: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ConfigurationSupportScreen(), - ), - ); - }, + onTap: () => Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ConfigurationSupportScreen()), + ), ), GeneralDrawerItem( leading: Icons.campaign_outlined, @@ -187,137 +175,45 @@ class GeneralDrawer extends StatelessWidget { Navigator.push( context, CupertinoPageRoute( - builder: (BuildContext context) { - return WebViewScreen( - label: 'Sugerencias', - link: - 'https://admin.prosapp.co/sugerencias'); - }, + builder: (_) => const SuggestionScreen(), ), ); } }, ), - Container( - color: const Color(0xFF2BA4EC), - child: ListTile( - onTap: () { - Navigator.pop(context); - }, - trailing: shouldProModeActive(context, professionalState) - ? FutureBuilder(builder: (context, AsyncSnapshot snapshot) { - if (!snapshot.hasData || snapshot.data! < 1) { - return const SizedBox(); - } - final int notificationCount = snapshot.data!; - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - notificationCount > 9 - ? '+9' - : notificationCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ); - }, - future: Injector.appInstance - .get() - .countPendingServicesForProfessional( - ApiUserRepository.currentUserId ?? ''), - ) - : const Icon( - Icons.keyboard_arrow_right, - color: Colors.white, - size: 25, - ), - title: Text( - shouldProModeActive(context, professionalState) - ? 'Solicitudes' - : 'Solicitar servicio', - style: const TextStyle( - fontSize: 16, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), + // CTA item — go to main action + _CtaItem( + isProMode: isProMode, + professionalState: professionalState, ), + // Rating row DrawerReputation(builder: (reputation) { - final isProModeActive = - (professionalState is LoadedModeProState) && - professionalState.isProModeActive; - - final total = isProModeActive + final total = isProMode ? reputation.totalPro : reputation.total; - - final average = isProModeActive + final average = isProMode ? reputation.averagePro : reputation.average; - return ListTile( - onTap: () { - final isProModeActive = (professionalState is LoadedModeProState) && professionalState.isProModeActive; - isProModeActive ? Navigator.push(context, - CupertinoPageRoute( - builder: (context) { - return const ProfessionalScoreListScreen(); - })) - : Navigator.push(context, - CupertinoPageRoute( - builder: (context) { - return const UserScoreListScreen(); - })); - }, - trailing: const Icon(Icons.keyboard_arrow_right, - color: Colors.black), - title: Row( - children: [ - RatingBar.builder( - initialRating: calculoRating(average), - minRating: 1, - direction: Axis.horizontal, - allowHalfRating: true, - itemCount: 5, - itemSize: 25, - maxRating: 5, - itemBuilder: (context, _) => const Icon( - Icons.star, - color: Color(0xFF2BA4EC), - ), - onRatingUpdate: (rating) {}, - ignoreGestures: true, - ), - const SizedBox(width: 5), - Text( - '${average.toStringAsFixed(1)} (${total.toString()})', - ), - ], - ), + return _RatingRow( + average: average, + total: total, + isProMode: isProMode, + professionalState: professionalState, ); }), - const Divider( - height: 1, - thickness: 0.5, - ), + const Divider(height: 1, thickness: 0.5), Padding( - padding: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.only(top: 10, bottom: 4), child: Text( 'Prosapp ® todos los derechos reservados', + textAlign: TextAlign.center, style: TextStyle( fontSize: 10, - color: Colors.grey[700], + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.4), ), ), ), @@ -336,7 +232,11 @@ class GeneralDrawer extends StatelessWidget { const SizedBox(height: 15), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: buttonOfState(context, professionalState), + child: _ModeButton( + professionalState: professionalState, + userState: userState, + isProMode: isProMode, + ), ), const SizedBox(height: 15), ], @@ -349,145 +249,273 @@ class GeneralDrawer extends StatelessWidget { ); } - double calculoRating(double average) { - String numeroString = average.toString(); - List partes = numeroString.split('.'); - int parteEntera = int.parse(partes[0]); - int parteFraccionaria = partes.length > 1 ? int.parse(partes[1]) : 0; + bool _isProModeActive(ProfessionalState state) => + (state is LoadedModeProState) ? state.isProModeActive : false; +} - if (parteFraccionaria >= 3) { - parteFraccionaria = 5; - } else { - parteFraccionaria = 0; - } +// CTA tile navigating to solicitudes / solicitar servicio +class _CtaItem extends StatelessWidget { + final bool isProMode; + final ProfessionalState professionalState; + _CtaItem( + {required this.isProMode, required this.professionalState}); - // Unir la parte entera y fraccionaria y convertirlo nuevamente a double - double resultado = double.parse('$parteEntera.$parteFraccionaria'); - return resultado; + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isProMode + ? _kPro.withOpacity(0.1) + : _kUser.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isProMode + ? _kPro.withOpacity(0.25) + : _kUser.withOpacity(0.25)), + ), + child: ListTile( + onTap: () => Navigator.pop(context), + leading: Icon( + isProMode ? Icons.inbox_outlined : Icons.search_rounded, + color: isProMode ? _kPro : _kUser, + ), + title: Text( + isProMode ? 'Solicitudes' : 'Solicitar servicio', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isProMode ? _kPro : _kUser, + ), + ), + trailing: isProMode + ? FutureBuilder( + future: Injector.appInstance + .get() + .countPendingServicesForProfessional( + ApiUserRepository.currentUserId ?? ''), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data! < 1) { + return Icon(Icons.keyboard_arrow_right, + color: _kPro.withOpacity(0.5)); + } + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + snapshot.data! > 9 + ? '+9' + : snapshot.data!.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600), + ), + ); + }, + ) + : Icon(Icons.keyboard_arrow_right, + color: _kUser.withOpacity(0.5)), + ), + ); + } +} + +class _RatingRow extends StatelessWidget { + final double average; + final int total; + final bool isProMode; + final ProfessionalState professionalState; + _RatingRow( + {required this.average, + required this.total, + required this.isProMode, + required this.professionalState}); + + double _calcRating(double avg) { + final parts = avg.toString().split('.'); + final frac = parts.length > 1 ? int.parse(parts[1]) : 0; + return double.parse( + '${parts[0]}.${frac >= 3 ? 5 : 0}'); } - buttonOfState(BuildContext context, ProfessionalState state) { + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + isProMode + ? Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const ProfessionalScoreListScreen())) + : Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const UserScoreListScreen())); + }, + leading: Icon(Icons.star_outline_rounded, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6)), + trailing: Icon(Icons.keyboard_arrow_right, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4)), + title: Row( + children: [ + RatingBar.builder( + initialRating: _calcRating(average), + minRating: 1, + direction: Axis.horizontal, + allowHalfRating: true, + itemCount: 5, + itemSize: 20, + maxRating: 5, + itemBuilder: (_, __) => const Icon(Icons.star, + color: Color(0xFF1565C0)), + onRatingUpdate: (_) {}, + ignoreGestures: true, + ), + const SizedBox(width: 6), + Text( + '${average.toStringAsFixed(1)} ($total)', + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7)), + ), + ], + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + final ProfessionalState professionalState; + final MyUserState userState; + final bool isProMode; + + _ModeButton({ + required this.professionalState, + required this.userState, + required this.isProMode, + }); + + @override + Widget build(BuildContext context) { return Stack( children: [ SizedBox( - width: MediaQuery.of(context).size.width * 0.7, - child: ElevatedButton( + width: double.infinity, + child: ElevatedButton.icon( onPressed: () { - final myUserState = context.read().state; - if (myUserState.status == MyUserStatus.success) { - final user = myUserState.user!; - - if (user.name != null && user.email != null && user.city != null && user.phone != null) { + if (userState.status == MyUserStatus.success) { + final user = userState.user!; + if (user.name != null && + user.city != null && + user.phone != null) { switch (user.proState) { - case ProState.active: Navigator.pop(context); - - context.read().add(const SwitchProModeEvent()); - + case ProState.active: + Navigator.pop(context); + context + .read() + .add(const SwitchProModeEvent()); break; case ProState.inactive: Navigator.push( context, CupertinoPageRoute( - builder: (context) => const ProfessionalFormScreen(), - ), + builder: (_) => const ProfessionalFormScreen()), ); break; case ProState.pending: Navigator.push( context, CupertinoPageRoute( - builder: (context) => const ProfessionalPendingScreen(), - ), + builder: (_) => + const ProfessionalPendingScreen()), ); break; case ProState.denied: Navigator.push( context, CupertinoPageRoute( - builder: (context) => const ProfessionalDeniedScreen(), - ), + builder: (_) => + const ProfessionalDeniedScreen()), ); break; } } else { + final missing = [ + if (user.name == null) 'nombre', + if (user.city == null) 'ciudad', + if (user.phone == null) 'teléfono', + ]; ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Por favor, completa tu perfil'), - )); - + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Completa tu perfil: falta ${missing.join(', ')}')), + ); Navigator.push( context, CupertinoPageRoute( - builder: (context) => const ProfileScreen(), - ), + builder: (_) => const ProfileScreen()), ); } - } else {} + } }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.symmetric(vertical: 15), + icon: const Icon(Icons.swap_horiz, size: 20), + label: Text( + isProMode ? 'Cambiar a modo cliente' : 'Cambiar a modo profesional', + style: const TextStyle(fontSize: 15), + ), + style: ElevatedButton.styleFrom( + backgroundColor: + isProMode ? _kPro : _kUser, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), ), ), - child: (state is LoadedModeProState) - ? Text( - state.isProModeActive ? 'Modo cliente' : 'Modo profesional', - style: const TextStyle(color: Colors.white, fontSize: 18), - ) - : const Text( - 'Modo profesional', - style: TextStyle(color: Colors.white, fontSize: 18), - ), ), ), - (state is LoadedModeProState) - ? Visibility( - visible: !state.isProModeActive, - child: Positioned( - top: 0, - right: 0, - child: FutureBuilder( - builder: (context, AsyncSnapshot snapshot) { - if (!snapshot.hasData || snapshot.data! < 1) { - return const SizedBox(); - } - final int notificationCount = snapshot.data!; - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - notificationCount > 9 - ? '+9' - : notificationCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ); - }, - future: Injector.appInstance - .get() - .countPendingServicesForProfessional( - ApiUserRepository.currentUserId ?? ''), + if (!isProMode && professionalState is LoadedModeProState) + Positioned( + top: 0, + right: 0, + child: FutureBuilder( + future: Injector.appInstance + .get() + .countPendingServicesForProfessional( + ApiUserRepository.currentUserId ?? ''), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data! < 1) { + return const SizedBox(); + } + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), ), - ), - ) - : const SizedBox(), + child: Text( + snapshot.data! > 9 ? '+9' : snapshot.data!.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ); + }, + ), + ), ], ); } - - shouldProModeActive(BuildContext context, ProfessionalState state) { - return (state is LoadedModeProState) ? state.isProModeActive : false; - } } diff --git a/lib/components/general_drawer_header.dart b/lib/components/general_drawer_header.dart index 9cc4849..a81968e 100644 --- a/lib/components/general_drawer_header.dart +++ b/lib/components/general_drawer_header.dart @@ -12,98 +12,139 @@ class GeneralDrawerHeader extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, state) { - if (state.status == MyUserStatus.success) { - final user = state.user!; + builder: (context, userState) { + if (userState.status == MyUserStatus.success) { + final user = userState.user!; return BlocBuilder( - builder: (context, state) { - return ListTile( + builder: (context, proState) { + final isProMode = (proState is LoadedModeProState) && + proState.isProModeActive; + return InkWell( onTap: () { - shouldProModeActive(context, state) + isProMode ? Navigator.push( context, CupertinoPageRoute( - builder: (BuildContext context) { - return const ProfessionalProfileScreen(); - }, + builder: (_) => const ProfessionalProfileScreen(), ), ) : Navigator.push( context, CupertinoPageRoute( - builder: (BuildContext context) { - return const ProfileScreen(); - }, + builder: (_) => const ProfileScreen(), ), ); }, - title: Text(user.name ?? '', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(user.drawerLabel, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12)), - leading: pictureWidget(user.picture, context), - trailing: - const Icon(Icons.keyboard_arrow_right, color: Colors.black), - contentPadding: - const EdgeInsets.symmetric(vertical: 10, horizontal: 15), + child: Padding( + padding: EdgeInsets.fromLTRB( + 20, + MediaQuery.of(context).padding.top + 20, + 20, + 16), + child: Row( + children: [ + _avatar(user.picture), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name ?? '', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + _modePill(isProMode), + ], + ), + ), + Icon( + Icons.keyboard_arrow_right, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.4), + ), + ], + ), + ), ); }, ); - } else if (state.status == MyUserStatus.failure) { - return const Text('Error obteniendo datos del usuario'); + } else if (userState.status == MyUserStatus.failure) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('Error obteniendo datos del usuario'), + ); } else { - return const CircularProgressIndicator(); + return const Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ); } }, ); } - shouldProModeActive(BuildContext context, ProfessionalState state) { - return (state is LoadedModeProState) ? state.isProModeActive : false; + Widget _modePill(bool isProMode) { + final color = + isProMode ? const Color(0xFF0D9488) : const Color(0xFF1565C0); + final icon = isProMode ? Icons.badge_outlined : Icons.person_outline; + final label = isProMode ? 'Profesional' : 'Cliente'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white, size: 12), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); } - Widget pictureWidget(String? pictureUrl, BuildContext context) { + Widget _avatar(String? pictureUrl) { ImageProvider? imageProvider; - if (pictureUrl != null && pictureUrl.isNotEmpty) { imageProvider = NetworkImage(pictureUrl); } return Hero( tag: 'picture-profile', - child: pictureContainerWidget( - imageProvider, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF1565C0).withOpacity(0.3), width: 2), + image: imageProvider == null + ? null + : DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + child: imageProvider == null + ? Icon(CupertinoIcons.person, + color: Colors.grey.shade500, size: 28) + : null, ), ); } - - Widget pictureContainerWidget(ImageProvider? imageProvider) { - final image = imageProvider == null - ? null - : DecorationImage( - image: imageProvider, - fit: BoxFit.contain, - ); - - final widget = image == null - ? Icon( - CupertinoIcons.person, - color: Colors.grey.shade400, - size: 40, - ) - : null; - - return Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: image, - ), - child: widget, - ); - } } diff --git a/lib/components/general_drawer_item.dart b/lib/components/general_drawer_item.dart index 2d7f6d8..663f22f 100644 --- a/lib/components/general_drawer_item.dart +++ b/lib/components/general_drawer_item.dart @@ -20,28 +20,23 @@ class GeneralDrawerItem extends StatelessWidget { @override Widget build(BuildContext context) { + final onSurface = Theme.of(context).colorScheme.onSurface; return ListTile( onTap: onTap == null ? null : () => onTap!(), leading: leading == null ? null - : Icon( - leading, - color: Colors.black, - ), + : Icon(leading, color: color ?? onSurface.withOpacity(0.75)), trailing: trailing - ? const Icon( - Icons.keyboard_arrow_right, - color: Colors.black, - ) + ? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.4)) : null, title: Text( label, - style: TextStyle(fontSize: 15, color: color), + style: TextStyle(fontSize: 15, color: color ?? onSurface), ), subtitle: subtitle == null ? null - : Text(subtitle ?? "", style: const TextStyle(color: Colors.black54)), - // dense: true, + : Text(subtitle!, + style: TextStyle(color: onSurface.withOpacity(0.55))), ); } } diff --git a/lib/dependency/app_di.dart b/lib/dependency/app_di.dart index 012fc0d..3bd7f29 100644 --- a/lib/dependency/app_di.dart +++ b/lib/dependency/app_di.dart @@ -16,9 +16,11 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart'; import 'package:prosappco/blocs/sign_up_bloc/sign_up_bloc.dart'; import 'package:prosappco/blocs/sing_in_bloc/sign_in_bloc.dart'; +import 'package:prosappco/blocs/suggestion_bloc/suggestion_bloc.dart'; import 'package:prosappco/local_notifications/local_notifications.dart'; import 'package:score_repository/score_repository.dart'; import 'package:service_repository/service_repository.dart'; +import 'package:suggestion_repository/suggestion_repository.dart'; import 'package:user_repository/user_repository.dart'; import 'package:city_repository/city_repository.dart'; import 'package:setting_repository/setting_repository.dart'; @@ -42,6 +44,7 @@ class AppDI { injector.registerSingleton(() => ApiServiceRepository()); injector.registerSingleton(() => ApiChatRepository()); injector.registerSingleton(() => ApiScoreRepository()); + injector.registerSingleton(() => ApiSuggestionRepository()); injector.registerSingleton((() => AuthenticationBloc(myUserRepository: injector.get()))); @@ -105,5 +108,11 @@ class AppDI { userRepository: injector.get(), ), ); + + injector.registerDependency( + () => SuggestionBloc( + suggestionRepository: injector.get(), + ), + ); } } diff --git a/lib/screens/authentication/sign_up_screen.dart b/lib/screens/authentication/sign_up_screen.dart index e9baaef..fbf5d8b 100644 --- a/lib/screens/authentication/sign_up_screen.dart +++ b/lib/screens/authentication/sign_up_screen.dart @@ -45,7 +45,12 @@ class _SignUpScreenState extends State { signUpRequired = true; }); } else if (state is SignUpFailure) { - return; + setState(() { + signUpRequired = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message)), + ); } }, child: Column( diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 8314ac5..cb5f0be 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -110,7 +110,7 @@ class _ChatScreenState extends State { ), ), subtitle: widget.service.userId == - ApiUserRepository.currentUserId ?? '' + (ApiUserRepository.currentUserId ?? '') ? GeneralReputation( userId: widget.service.professionalId, builder: (context, reputation) { @@ -339,7 +339,7 @@ class _ChatScreenState extends State { List _messagesList(List messages) { return messages .map( - (e) => e.ownerId != ApiUserRepository.currentUserId ?? '' + (e) => e.ownerId != (ApiUserRepository.currentUserId ?? '') ? ListTile( title: Column( mainAxisAlignment: MainAxisAlignment.start, diff --git a/lib/screens/configuration/configuration_about_screen.dart b/lib/screens/configuration/configuration_about_screen.dart index 633f9e1..36aae01 100644 --- a/lib/screens/configuration/configuration_about_screen.dart +++ b/lib/screens/configuration/configuration_about_screen.dart @@ -1,12 +1,10 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:injector/injector.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:prosappco/components/general_drawer_item.dart'; -import 'package:prosappco/screens/web/web_view_screen.dart'; +import 'package:prosappco/screens/configuration/policy_text_screen.dart'; import 'package:setting_repository/setting_repository.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'dart:io' show Platform; class ConfigurationAboutScreen extends StatefulWidget { @@ -37,15 +35,24 @@ class _ConfigurationAboutScreenState extends State { ); } - void _launchURL(String url) async { - if (await canLaunch(url)) { - await launch(url, forceSafariVC: false, forceWebView: false); - } else { - throw 'No se pudo abrir el enlace $url'; + Future _openPolicy(String title, String Function(PoliciesEntity) pick) async { + try { + final policies = await settingRepository.getPolicies(); + if (!mounted) return; + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => PolicyTextScreen(title: title, content: pick(policies)), + ), + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No se pudo cargar el contenido')), + ); } } - @override Widget build(BuildContext context) { return Scaffold( @@ -56,44 +63,18 @@ class _ConfigurationAboutScreenState extends State { children: [ GeneralDrawerItem( label: 'Políticas de privacidad', - onTap: () { - if (kIsWeb) { - _launchURL(settings?.politicasPrivacidad ?? ''); - } else { - Navigator.push( - context, - CupertinoPageRoute( - builder: (BuildContext context) { - return WebViewScreen( - label: 'Políticas de privacidad', - link: settings?.politicasPrivacidad ?? '', - ); - }, - ), - ); - } - }, + onTap: () => _openPolicy( + 'Políticas de privacidad', + (p) => p.privacy, + ), trailing: true, ), GeneralDrawerItem( label: 'Términos y condiciones', - onTap: () { - if (kIsWeb) { - _launchURL(settings?.terminosCondiciones ?? ''); - } else { - Navigator.push( - context, - CupertinoPageRoute( - builder: (BuildContext context) { - return WebViewScreen( - label: 'Términos y condiciones', - link: settings?.terminosCondiciones ?? '', - ); - }, - ), - ); - } - }, + onTap: () => _openPolicy( + 'Términos y condiciones', + (p) => p.terms, + ), trailing: true, ), Platform.isIOS diff --git a/lib/screens/configuration/configuration_screen.dart b/lib/screens/configuration/configuration_screen.dart index 0c86c8d..c3830f4 100644 --- a/lib/screens/configuration/configuration_screen.dart +++ b/lib/screens/configuration/configuration_screen.dart @@ -1,130 +1,3 @@ -// import 'package:cloud_firestore/cloud_firestore.dart'; -// import 'package:firebase_auth/firebase_auth.dart'; -// import 'package:flutter/cupertino.dart'; -// import 'package:flutter/material.dart'; -// import 'package:get/get.dart'; -// import 'package:prosappco/src/components/pop_appbar.dart'; -// import 'package:prosappco/src/presentation/screens/about.dart'; - -// class ConfigurationScreen extends StatefulWidget { -// const ConfigurationScreen({super.key}); - -// @override -// State createState() => _ConfigurationScreenState(); -// } - -// class _ConfigurationScreenState extends State { -// late final FirebaseAuth _auth; - -// @override -// void initState() { -// super.initState(); -// _auth = FirebaseAuth.instance; -// } - -// Future deleteAccount() async { -// try { -// final currentUser = _auth.currentUser; - -// if (currentUser != null) { -// final uid = currentUser.uid; - -// await FirebaseFirestore.instance.collection('users').doc(uid).delete(); - -// await currentUser.delete(); - -// await _auth.signOut(); - -// Get.snackbar( -// 'Cuenta Eliminada', -// 'Tu cuenta ha sido eliminada con éxito.', -// snackPosition: SnackPosition.BOTTOM, -// ); -// } -// } catch (e) { -// Get.snackbar( -// 'Error al Eliminar Cuenta', -// 'Hubo un error al eliminar tu cuenta. Por favor, inténtalo de nuevo más tarde.', -// snackPosition: SnackPosition.BOTTOM, -// ); -// } -// } - -// Future _showDeleteAccountConfirmationDialog( -// BuildContext context) async { -// return showDialog( -// context: context, -// builder: (BuildContext context) { -// return AlertDialog( -// title: const Text('Eliminar Cuenta'), -// content: const Text( -// '¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer.'), -// actions: [ -// TextButton( -// onPressed: () { -// Navigator.of(context).pop(); -// }, -// child: const Text('Cancelar'), -// ), -// TextButton( -// onPressed: () { -// deleteAccount(); -// Navigator.of(context).pop(); -// }, -// child: const Text( -// 'Eliminar', -// style: -// TextStyle(color: Colors.red, fontWeight: FontWeight.w600), -// ), -// ), -// ], -// ); -// }, -// ); -// } - -// @override -// Widget build(BuildContext context) { -// return Scaffold( -// appBar: PopAppbar( -// onPressed: () { -// Navigator.pop(context); -// }, -// label: 'Configuración'), -// body: ListView( -// children: [ -// ListTile( -// onTap: () { -// Navigator.push( -// context, -// CupertinoPageRoute( -// builder: (BuildContext context) { -// return const AboutScreen(); -// }, -// ), -// ); -// }, -// title: const Text('Acerca de la aplicación'), -// trailing: const Icon( -// Icons.keyboard_arrow_right, -// color: Colors.black, -// ), -// ), -// ListTile( -// onTap: () { -// _showDeleteAccountConfirmationDialog(context); -// }, -// title: const Text( -// 'Eliminar cuenta', -// style: TextStyle(color: Colors.red), -// ), -// ), -// ], -// ), -// ); -// } -// } - import 'dart:developer'; import 'package:flutter/cupertino.dart'; @@ -132,9 +5,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injector/injector.dart'; import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart'; -import 'package:prosappco/components/general_drawer_item.dart'; import 'package:prosappco/screens/configuration/configuration_about_screen.dart'; +const _kPrimary = Color(0xFF1565C0); + class ConfigurationScreen extends StatelessWidget { const ConfigurationScreen({super.key}); @@ -153,38 +27,41 @@ class ConfigurationScreen extends StatelessWidget { child: Scaffold( appBar: AppBar( title: const Text('Configuración'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: ListView( children: [ - GeneralDrawerItem( + const SizedBox(height: 8), + _ConfigItem( + icon: Icons.info_outline_rounded, label: 'Acerca de la aplicación', - onTap: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (BuildContext context) { - return const ConfigurationAboutScreen(); - }, - ), - ); - }, - trailing: true, + onTap: () => Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const ConfigurationAboutScreen(), + ), + ), ), - GeneralDrawerItem( + _ConfigItem( + icon: Icons.logout_rounded, label: 'Cerrar sesión', onTap: () { try { settingBloc.add(SettingLogoutRequest()); } catch (e) { - log('xd conigura ${e.toString()}'); + log('logout error: ${e.toString()}'); } }, ), - GeneralDrawerItem( + const Divider(height: 1), + _ConfigItem( + icon: Icons.delete_outline_rounded, label: 'Eliminar cuenta', - onTap: () {}, color: Theme.of(context).colorScheme.error, - ) + onTap: () {}, + ), ], ), ), @@ -192,3 +69,39 @@ class ConfigurationScreen extends StatelessWidget { ); } } + +class _ConfigItem extends StatelessWidget { + final IconData icon; + final String label; + final Color? color; + final VoidCallback onTap; + + const _ConfigItem({ + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final onSurface = Theme.of(context).colorScheme.onSurface; + final c = color ?? onSurface; + return ListTile( + onTap: onTap, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: c.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: c, size: 20), + ), + title: Text(label, style: TextStyle(color: c, fontWeight: FontWeight.w500)), + trailing: color == null + ? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.35)) + : null, + ); + } +} diff --git a/lib/screens/configuration/configuration_support_screen.dart b/lib/screens/configuration/configuration_support_screen.dart index 646e9f7..b3c79ca 100644 --- a/lib/screens/configuration/configuration_support_screen.dart +++ b/lib/screens/configuration/configuration_support_screen.dart @@ -81,13 +81,17 @@ class _ConfigurationSupportScreenState Padding( padding: const EdgeInsets.only(top: 30, left: 35, right: 35), child: Text( - '${settings?.tituloSoporte}', + settings == null + ? 'Cargando...' + : (settings?.tituloSoporte ?? 'Soporte'), style: const TextStyle(fontWeight: FontWeight.w600), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 35), - child: Text(settings?.parrafoSoporte ?? 'Cargando...'), + child: Text( + settings == null ? 'Cargando...' : (settings?.parrafoSoporte ?? ''), + ), ), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/screens/configuration/policy_text_screen.dart b/lib/screens/configuration/policy_text_screen.dart new file mode 100644 index 0000000..a475980 --- /dev/null +++ b/lib/screens/configuration/policy_text_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class PolicyTextScreen extends StatelessWidget { + final String title; + final String content; + + const PolicyTextScreen({ + super.key, + required this.title, + required this.content, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Text( + content, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + ), + ); + } +} diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index eef1ceb..cfa5105 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -5,6 +5,8 @@ import 'package:prosappco/components/general_drawer.dart'; import 'package:prosappco/screens/lists/professional_pending_service_list.dart'; import 'package:prosappco/screens/user/user_map_screen.dart'; +const _kPrimary = Color(0xFF1565C0); + class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -15,19 +17,20 @@ class HomeScreen extends StatelessWidget { return SafeArea( child: shouldProModeActive(context, state) ? Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, - drawer: GeneralDrawer(), + backgroundColor: Theme.of(context).colorScheme.surface, + drawer: const GeneralDrawer(), appBar: AppBar( title: const Text('Solicitudes'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), - body: ProfessionalPendingServiceListScreen(), + body: const ProfessionalPendingServiceListScreen(), ) : Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, - drawer: GeneralDrawer(), - body: const Center( - child: UserMapScreen(), - ), + backgroundColor: Theme.of(context).colorScheme.surface, + drawer: const GeneralDrawer(), + body: const UserMapScreen(), ), ); }, diff --git a/lib/screens/lists/professional_list_screen.dart b/lib/screens/lists/professional_list_screen.dart index 5851abd..f3c71e1 100644 --- a/lib/screens/lists/professional_list_screen.dart +++ b/lib/screens/lists/professional_list_screen.dart @@ -1,8 +1,11 @@ import 'package:user_repository/user_repository.dart'; +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:injector/injector.dart'; import 'package:intl_phone_field/helpers.dart'; import 'package:profession_repository/profession_repository.dart'; @@ -34,6 +37,11 @@ class _ProfessionalListScreenState extends State { bool _isLoading = true; + double? _lat; + double? _lng; + bool _isLocating = false; + Timer? _debounce; + late final ProfessionalListBloc bloc; @override @@ -46,6 +54,56 @@ class _ProfessionalListScreenState extends State { _loadProfessions(); } + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } + + void _onSearchChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 450), () { + bloc.add(ProfessionalListFetch( + search: value.isEmpty ? null : value, + lat: _lat, + lng: _lng, + )); + }); + } + + Future _useMyLocation() async { + setState(() => _isLocating = true); + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) throw Exception('Location services disabled'); + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + throw Exception('Location permission denied'); + } + + final position = await Geolocator.getCurrentPosition(); + _lat = position.latitude; + _lng = position.longitude; + bloc.add(ProfessionalListFetch( + search: _searchController.text.isEmpty ? null : _searchController.text, + lat: _lat, + lng: _lng, + )); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No se pudo obtener tu ubicación')), + ); + } finally { + if (mounted) setState(() => _isLocating = false); + } + } + void _loadSettings() { settingRepository.getSettings().then( (value) => setState(() { @@ -134,14 +192,30 @@ class _ProfessionalListScreenState extends State { setState(() { _searchController.text = value; }); + _onSearchChanged(value); }, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Busca un profesional', - prefixIcon: Icon(Icons.search), - enabledBorder: UnderlineInputBorder( + prefixIcon: const Icon(Icons.search), + suffixIcon: _isLocating + ? const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location_outlined), + tooltip: 'Usar mi ubicación', + onPressed: _useMyLocation, + ), + enabledBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.grey), ), - focusedBorder: UnderlineInputBorder( + focusedBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.grey), ), ), @@ -174,7 +248,7 @@ class _ProfessionalListScreenState extends State { .toLowerCase() .contains(removeDiacritics(_searchController.text.toLowerCase()))) .where((user) => - user.myUser.id != ApiUserRepository.currentUserId ?? '') + user.myUser.id != (ApiUserRepository.currentUserId ?? '')) .where( (user) => user.professionalInfo.profession == _selectedProfession) .toList(); @@ -226,7 +300,7 @@ class _ProfessionalListScreenState extends State { ); }, child: filteredUsers[index].professionalInfo.rate.isEmpty || - settings?.tarifas == false + settings?.tarifas != true ? const Icon( Icons.keyboard_arrow_right, color: Colors.black, @@ -295,7 +369,10 @@ class _ProfessionalListScreenState extends State { ], ), subtitle: Text( - _disponibilidad(filteredUsers[index].professionalInfo), + _disponibilidad(filteredUsers[index].professionalInfo) + + (filteredUsers[index].distanceKm != null + ? ' · ${filteredUsers[index].distanceKm!.toStringAsFixed(1)} km' + : ''), style: const TextStyle( fontSize: 13, color: Colors.blue, diff --git a/lib/screens/lists/professional_pending_service_list.dart b/lib/screens/lists/professional_pending_service_list.dart index 3567871..3fd241a 100644 --- a/lib/screens/lists/professional_pending_service_list.dart +++ b/lib/screens/lists/professional_pending_service_list.dart @@ -66,6 +66,11 @@ class _ProfessionalPendingServiceListScreenState _RequestCard(info: serviceState.services[index]), ); } + if (serviceState is CreateServiceFailure) { + return _errorState(context, () => serviceBloc.add( + LoadPendingServicesForProfessional( + ApiUserRepository.currentUserId ?? ''))); + } return _shimmerList(); }, ), @@ -73,6 +78,37 @@ class _ProfessionalPendingServiceListScreenState ); } + Widget _errorState(BuildContext context, VoidCallback onRetry) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: context.subtle.withOpacity(0.1), shape: BoxShape.circle), + child: + Icon(Icons.error_outline, size: 36, color: context.subtle), + ), + const SizedBox(height: 16), + Text('No se pudo cargar la información', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: context.muted)), + const SizedBox(height: 6), + Text('Revisa tu conexión e inténtalo de nuevo.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: context.subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), + ]), + ), + ); + } + Widget _emptyState(BuildContext context) { return Center( child: Padding( diff --git a/lib/screens/lists/professional_service_history_list_screen.dart b/lib/screens/lists/professional_service_history_list_screen.dart index 6bdd4a1..5e2eecf 100644 --- a/lib/screens/lists/professional_service_history_list_screen.dart +++ b/lib/screens/lists/professional_service_history_list_screen.dart @@ -78,6 +78,12 @@ class _ProfessionalServiceHistoryListScreenState }, ); } + if (serviceState is CreateServiceFailure) { + return _errorState( + context, + () => serviceBloc.add(LoadServicesHistoryForProfessional( + ApiUserRepository.currentUserId ?? ''))); + } return _shimmerList(); }, ), @@ -86,6 +92,34 @@ class _ProfessionalServiceHistoryListScreenState } } +Widget _errorState(BuildContext context, VoidCallback onRetry) { + final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); + final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: subtle.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(Icons.error_outline, size: 36, color: subtle), + ), + const SizedBox(height: 16), + Text('No se pudo cargar la información', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: muted)), + const SizedBox(height: 6), + Text('Revisa tu conexión e inténtalo de nuevo.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), + ]), + ), + ); +} + Widget _emptyState(BuildContext context) { final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); diff --git a/lib/screens/lists/professional_service_list_screen.dart b/lib/screens/lists/professional_service_list_screen.dart index ed002ef..80b0718 100644 --- a/lib/screens/lists/professional_service_list_screen.dart +++ b/lib/screens/lists/professional_service_list_screen.dart @@ -78,6 +78,12 @@ class _ProfessionalServiceListScreenState }, ); } + if (serviceState is CreateServiceFailure) { + return _errorState( + context, + () => serviceBloc.add(LoadServicesForProfessional( + ApiUserRepository.currentUserId ?? ''))); + } return _shimmerList(); }, ), @@ -86,6 +92,34 @@ class _ProfessionalServiceListScreenState } } +Widget _errorState(BuildContext context, VoidCallback onRetry) { + final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); + final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: subtle.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(Icons.error_outline, size: 36, color: subtle), + ), + const SizedBox(height: 16), + Text('No se pudo cargar la información', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: muted)), + const SizedBox(height: 6), + Text('Revisa tu conexión e inténtalo de nuevo.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), + ]), + ), + ); +} + Widget _emptyState(BuildContext context) { final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); diff --git a/lib/screens/lists/user_service_history_list_screen.dart b/lib/screens/lists/user_service_history_list_screen.dart index f04602d..3070e5f 100644 --- a/lib/screens/lists/user_service_history_list_screen.dart +++ b/lib/screens/lists/user_service_history_list_screen.dart @@ -78,6 +78,12 @@ class _UserServiceHistoryListScreenState }, ); } + if (serviceState is CreateServiceFailure) { + return _errorState( + context, + () => serviceBloc.add(LoadServicesHistoryForUser( + ApiUserRepository.currentUserId ?? ''))); + } return _shimmerList(); }, ), @@ -86,6 +92,34 @@ class _UserServiceHistoryListScreenState } } +Widget _errorState(BuildContext context, VoidCallback onRetry) { + final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); + final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: subtle.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(Icons.error_outline, size: 36, color: subtle), + ), + const SizedBox(height: 16), + Text('No se pudo cargar la información', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: muted)), + const SizedBox(height: 6), + Text('Revisa tu conexión e inténtalo de nuevo.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), + ]), + ), + ); +} + Widget _emptyState(BuildContext context) { final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); diff --git a/lib/screens/lists/user_service_list_screen.dart b/lib/screens/lists/user_service_list_screen.dart index d6bc50b..7909c8a 100644 --- a/lib/screens/lists/user_service_list_screen.dart +++ b/lib/screens/lists/user_service_list_screen.dart @@ -75,6 +75,10 @@ class _UserServiceListScreenState extends State { }, ); } + if (serviceState is CreateServiceFailure) { + return _errorState(context, () => serviceBloc.add( + LoadServicesForUser(ApiUserRepository.currentUserId ?? ''))); + } return _shimmerList(); }, ), @@ -83,6 +87,34 @@ class _UserServiceListScreenState extends State { } } +Widget _errorState(BuildContext context, VoidCallback onRetry) { + final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); + final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: subtle.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(Icons.error_outline, size: 36, color: subtle), + ), + const SizedBox(height: 16), + Text('No se pudo cargar la información', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: muted)), + const SizedBox(height: 6), + Text('Revisa tu conexión e inténtalo de nuevo.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), + ]), + ), + ); +} + Widget _emptyState(BuildContext context) { final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); diff --git a/lib/screens/professional/professional_calendar_screen.dart b/lib/screens/professional/professional_calendar_screen.dart index 8d8c02e..d799039 100644 --- a/lib/screens/professional/professional_calendar_screen.dart +++ b/lib/screens/professional/professional_calendar_screen.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:professional_repository/professional_repository.dart'; import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/screens/service/professional_service_screen.dart'; +import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:service_repository/service_repository.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -44,6 +45,7 @@ class _ProfessionalCalendarScreenState List? _services; CalendarFormat _calendarFormat = CalendarFormat.month; bool isLoading = false; + bool _loadFailed = false; @override void initState() { @@ -54,9 +56,17 @@ class _ProfessionalCalendarScreenState } void _loadServices() { + setState(() => _loadFailed = false); serviceRepository .getServicesForProfessionalforCalendar(widget.userProfessional.id) - .then((services) => setState(() => _services = services)); + .then((services) { + if (!mounted) return; + setState(() => _services = services); + }).catchError((e) { + if (!mounted) return; + // Never fall back to an empty list: booked slots would render as free. + setState(() => _loadFailed = true); + }); } void _onDaySelected(DateTime day, DateTime focusedDay) { @@ -87,18 +97,33 @@ class _ProfessionalCalendarScreenState child: BlocConsumer( listener: (context, state) { if (state is CreateServiceLoading) isLoading = true; - if (state is CreateServiceFailure) isLoading = false; + if (state is CreateServiceFailure) { + isLoading = false; + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No se pudo completar la acción')), + ); + } + if (state is CreateServiceSuccess || state is ServiceStatusUpdated) { + isLoading = false; + _loadServices(); + } }, builder: (context, state) { return ListView( padding: const EdgeInsets.only(bottom: 32), children: [ _calendarCard(context), - _dayHeader(context, schedule, slots.length, occupied, available), - if (slots.isEmpty) - _emptyState(context) - else - ..._slotCards(context, slots, state), + if (_loadFailed) + _loadErrorState(context) + else ...[ + _dayHeader( + context, schedule, slots.length, occupied, available), + if (slots.isEmpty) + _emptyState(context) + else + ..._slotCards(context, slots, state), + ], ], ); }, @@ -337,9 +362,7 @@ class _ProfessionalCalendarScreenState for (final event in _services!) { if (today.toString() == event.day && time == event.range1Hour1) { if (event.userId == event.professionalId) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Horario ocupado por ti'))); + _confirmUnblock(event.id!, time); } else { Navigator.push( context, @@ -353,6 +376,35 @@ class _ProfessionalCalendarScreenState } } + void _confirmUnblock(String serviceId, TimeOfDay time) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Desbloquear horario'), + content: Text( + '¿Quieres liberar el horario de las ' + '${ScheduleEntity.getFormatTime(time)} ' + 'del ${DateFormat('dd-MM-yyyy').format(today)}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + context + .read() + .add(UpdateServiceStatus(serviceId, ServiceStatus.cancelled)); + }, + child: const Text('Desbloquear'), + ), + ], + ), + ); + } + void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) { showDialog( context: context, @@ -387,7 +439,8 @@ class _ProfessionalCalendarScreenState createdAt: DateTime.now().toIso8601String(), description: '', range1Hour1: time, - range1Hour2: time.replacing(hour: time.hour + 2), + range1Hour2: time.add( + minute: widget.userProfessional.slotDurationMinutes), rate: '0', location: ServiceLocationPreferences.office, status: ServiceStatus.selfBooked, @@ -395,11 +448,61 @@ class _ProfessionalCalendarScreenState }, child: const Text('Reservar'), ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + context.read().add( + BlockSlot(day: today.toString(), hour1: time), + ); + }, + child: const Text('Bloquear horario'), + ), ], ), ); } + Widget _loadErrorState(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 12, + offset: const Offset(0, 2)) + ], + ), + child: Column(children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: _kOccupied.withOpacity(0.1), shape: BoxShape.circle), + child: const Icon(Icons.wifi_off_outlined, + size: 32, color: _kOccupied), + ), + const SizedBox(height: 16), + Text('No se pudo cargar tu agenda', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: context.muted)), + const SizedBox(height: 6), + Text( + 'No mostramos horarios para evitar que reserves\nsobre una cita ya agendada.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _loadServices, child: const Text('Reintentar')), + ]), + ); + } + Widget _emptyState(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -451,13 +554,17 @@ class _ProfessionalCalendarScreenState if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) { return []; } + final stepMinutes = widget.userProfessional.slotDurationMinutes; if (s.continuousDay) { - return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!); + return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, + stepMinutes: stepMinutes); } if (s.range1Hour2 == null || s.range2Hour1 == null) return []; return [ - ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!), - ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!), + ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, + stepMinutes: stepMinutes), + ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, + stepMinutes: stepMinutes), ]; } diff --git a/lib/screens/professional/professional_denied_screen.dart b/lib/screens/professional/professional_denied_screen.dart index a831bae..3ade9a8 100644 --- a/lib/screens/professional/professional_denied_screen.dart +++ b/lib/screens/professional/professional_denied_screen.dart @@ -1,101 +1,225 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injector/injector.dart'; import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart'; +import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart'; +import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart'; +import 'package:prosappco/screens/professional/professional_form_screen.dart'; +import 'package:setting_repository/setting_repository.dart'; -class ProfessionalDeniedScreen extends StatelessWidget { +class ProfessionalDeniedScreen extends StatefulWidget { const ProfessionalDeniedScreen({super.key}); + @override + State createState() => + _ProfessionalDeniedScreenState(); +} + +class _ProfessionalDeniedScreenState extends State { + final settingRepository = Injector.appInstance.get(); + SettingEntity? _settings; + bool _loadingSettings = true; + bool _isResetting = false; + + @override + void initState() { + super.initState(); + settingRepository.getSettings().then((value) { + if (!mounted) return; + setState(() { + _settings = value; + _loadingSettings = false; + }); + }); + } + + void _confirmRetry() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Volver a registrarme'), + content: const Text( + 'Esta acción reiniciará tu solicitud de profesional y no se puede deshacer. ' + '¿Deseas continuar?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + context + .read() + .add(const ResetProfessionalApplicationEvent()); + }, + child: const Text('Continuar'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - color: const Color(0xFFFFEBEE), - shape: BoxShape.circle, - ), - child: const Icon(Icons.cancel_outlined, color: Color(0xFFE53935), size: 48), - ), - const SizedBox(height: 24), - const Text( - 'Solicitud rechazada', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - const Text( - 'Tu solicitud para convertirte en profesional no fue aprobada. Esto puede deberse a información incompleta o documentación no válida.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5), - ), - const SizedBox(height: 32), - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFFF3E0), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFFFCC02).withOpacity(0.4)), - ), + return BlocListener( + listener: (context, state) { + if (state is ResetProfessionalApplicationLoading) { + setState(() => _isResetting = true); + } else if (state is ResetProfessionalApplicationSuccess) { + setState(() => _isResetting = false); + Navigator.of(context).pushReplacement( + CupertinoPageRoute(builder: (_) => const ProfessionalFormScreen()), + ); + } else if (state is ProfessionalStateFailure) { + setState(() => _isResetting = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No se pudo reiniciar la solicitud')), + ); + } + }, + child: BlocBuilder( + builder: (context, myUserState) { + final rejectedAt = myUserState.user?.rejectedAt; + // Backend currently ships rejection_wait_days = 0 (no cooldown). + // 7 is only the fallback when /settings could not be read. + final waitDays = _settings?.rejectionWaitDays?.toInt() ?? 7; + + // Fail-open, same as the web: if we cannot tell when the rejection + // happened, assume the wait already elapsed. Locking someone out of + // re-applying forever is worse than letting them retry early. + final daysElapsed = rejectedAt != null + ? DateTime.now().difference(rejectedAt).inDays + : waitDays; + + final daysLeft = (waitDays - daysElapsed).clamp(0, waitDays); + final canRetry = !_loadingSettings && daysLeft == 0; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Row( - children: [ - Icon(Icons.info_outline, color: Color(0xFFF57C00), size: 20), - SizedBox(width: 8), - Text('¿Qué puedo hacer?', style: TextStyle(fontWeight: FontWeight.w600, color: Color(0xFFF57C00))), - ], + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + shape: BoxShape.circle, + ), + child: const Icon(Icons.cancel_outlined, + color: Color(0xFFE53935), size: 48), + ), + const SizedBox(height: 24), + const Text( + 'Solicitud rechazada', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1A1A2E)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const Text( + 'Tu solicitud para convertirte en profesional no fue aprobada. Esto puede deberse a información incompleta o documentación no válida.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5), + ), + const SizedBox(height: 32), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFCC02).withOpacity(0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Row( + children: [ + Icon(Icons.info_outline, + color: Color(0xFFF57C00), size: 20), + SizedBox(width: 8), + Text('¿Qué puedo hacer?', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFF57C00))), + ], + ), + SizedBox(height: 12), + _BulletPoint(text: 'Verifica que todos tus datos sean correctos'), + _BulletPoint( + text: 'Asegúrate de haber adjuntado los documentos requeridos'), + _BulletPoint( + text: 'Vuelve a registrarte como profesional con la información actualizada'), + _BulletPoint(text: 'Contacta a soporte si crees que fue un error'), + ], + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: (canRetry && !_isResetting) ? _confirmRetry : null, + icon: _isResetting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.refresh), + label: const Text('Volver a registrarme'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.shade300, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + if (!canRetry && !_loadingSettings) ...[ + const SizedBox(height: 8), + Text( + 'Podrás volver a intentarlo en $daysLeft día${daysLeft == 1 ? '' : 's'}', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => context + .read() + .add(AuthenticationLogoutRequested()), + icon: const Icon(Icons.logout), + label: const Text('Cerrar sesión'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + side: const BorderSide(color: Colors.grey), + ), + ), ), - SizedBox(height: 12), - _BulletPoint(text: 'Verifica que todos tus datos sean correctos'), - _BulletPoint(text: 'Asegúrate de haber adjuntado los documentos requeridos'), - _BulletPoint(text: 'Vuelve a registrarte como profesional con la información actualizada'), - _BulletPoint(text: 'Contacta a soporte si crees que fue un error'), ], ), ), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.refresh), - label: const Text('Volver a registrarme'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF42A4EF), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - elevation: 0, - ), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => context.read().add(AuthenticationLogoutRequested()), - icon: const Icon(Icons.logout), - label: const Text('Cerrar sesión'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.grey, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - side: const BorderSide(color: Colors.grey), - ), - ), - ), - ], - ), - ), + ), + ); + }, ), ); } diff --git a/lib/screens/professional/professional_form_screen.dart b/lib/screens/professional/professional_form_screen.dart index 31e91b2..cc5b3ef 100644 --- a/lib/screens/professional/professional_form_screen.dart +++ b/lib/screens/professional/professional_form_screen.dart @@ -30,6 +30,8 @@ class _ProfessionalFormScreenState extends State { PlatformFile? _certificadoPdfFile; List? _especializacionesPdfFiles = []; + bool _isSubmitting = false; + late final AuthBloc authBloc; @override @@ -50,7 +52,37 @@ class _ProfessionalFormScreenState extends State { Widget build(BuildContext context) { return BlocProvider( create: (context) => authBloc, - child: Scaffold( + child: BlocListener( + listener: (context, professionalState) { + if (professionalState is SendProfessionalToReviewLoading) { + setState(() => _isSubmitting = true); + } else if (professionalState is SendProfessionalToReviewSuccess) { + setState(() => _isSubmitting = false); + + // The picture upload only runs once the application actually landed. + final user = context.read().state.user; + if (user != null) { + context.read().add(UpdateUserInfo( + myUser: user.copyWith(proState: ProState.pending), + filePicture: _imageFile?.path)); + } + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Información enviada a revisión')), + ); + Navigator.pop(context); + } else if (professionalState is SendProfessionalToReviewFailure) { + setState(() => _isSubmitting = false); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No se pudo enviar tu solicitud. Revisa tu conexión e inténtalo de nuevo.')), + ); + } + }, + child: Scaffold( appBar: AppBar( title: const Text('Perfil profesional'), ), @@ -247,9 +279,20 @@ class _ProfessionalFormScreenState extends State { horizontal: 15, ), child: FilledButton( - onPressed: () { + onPressed: _isSubmitting + ? null + : () { final userId = - context.read().state.user!.id; + context.read().state.user?.id; + + if (userId == null) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No se pudo identificar tu usuario, vuelve a iniciar sesión'))); + return; + } final String? cedulaPdfPath = _cedulaPdfFile?.path; final String? certificadoPdfPath = @@ -300,20 +343,6 @@ class _ProfessionalFormScreenState extends State { specializationsPictures: especializacionesPdfPaths, )); - - final myUser = - state.user!.copyWith(proState: ProState.pending); - - context.read().add(UpdateUserInfo( - myUser: myUser, filePicture: _imageFile?.path)); - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Información enviada a revisión')), - ); - - Navigator.pop(context); }, style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, @@ -325,19 +354,27 @@ class _ProfessionalFormScreenState extends State { child: Container( alignment: Alignment.center, width: double.infinity, - child: const Text( - 'Enviar a revisión', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), + child: _isSubmitting + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text( + 'Enviar a revisión', + style: TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), ), )), ], ); }, ), + ), ), ); } diff --git a/lib/screens/professional/professional_profile_screen.dart b/lib/screens/professional/professional_profile_screen.dart index 73728d3..be73fd9 100644 --- a/lib/screens/professional/professional_profile_screen.dart +++ b/lib/screens/professional/professional_profile_screen.dart @@ -51,7 +51,9 @@ class _ProfessionalProfileScreenState final _rateController = TextEditingController(); Schedules schedules = Schedules.empty; + int _slotDurationMinutes = 120; bool isInit = false; + bool _isSaving = false; double _longitudeController = 0; double _latitudeController = 0; @@ -78,7 +80,28 @@ class _ProfessionalProfileScreenState @override Widget build(BuildContext context) { - return Scaffold( + return BlocListener( + listener: (context, saveState) { + if (saveState is UpdateProfessionalInfoLoading) { + setState(() => _isSaving = true); + } else if (saveState is UpdateProfessionalInfoSuccess) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Información actualizada correctamente')), + ); + } else if (saveState is UpdateProfessionalInfoFailure) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No se pudo guardar. Revisa tu conexión e inténtalo de nuevo.')), + ); + } + }, + child: Scaffold( backgroundColor: context.bg, appBar: AppBar( title: const Text('Perfil Profesional'), @@ -102,9 +125,32 @@ class _ProfessionalProfileScreenState ); } } + if (state is ProfessionalStateFailure) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 40, color: context.subtle), + const SizedBox(height: 12), + const Text('No se pudo cargar tu perfil profesional'), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => context + .read() + .add(const UpdateProfessionalEvent(isProModeActive: true)), + child: const Text('Reintentar'), + ), + ], + ), + ), + ); + } return const Center(child: Text('Vuelve atrás')); }, ), + ), ); } @@ -128,6 +174,7 @@ class _ProfessionalProfileScreenState deliveryValue = true; } _rateController.text = proInfo.rate; + _slotDurationMinutes = proInfo.slotDurationMinutes; loadFinish = true; } @@ -261,23 +308,39 @@ class _ProfessionalProfileScreenState color: _kPrimary)), ), ), - child: _scheduleRows(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _slotDurationDropdown(context), + const SizedBox(height: 8), + _scheduleRows(context), + ], + ), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ElevatedButton( - onPressed: _save, + onPressed: _isSaving ? null : _save, style: ElevatedButton.styleFrom( backgroundColor: _kPrimary, foregroundColor: Colors.white, + disabledBackgroundColor: _kPrimary.withOpacity(0.5), padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), elevation: 0, ), - child: const Text('Guardar cambios', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Guardar cambios', + style: + TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), ), ), const SizedBox(height: 32), @@ -481,6 +544,37 @@ class _ProfessionalProfileScreenState ]); } + Widget _slotDurationDropdown(BuildContext context) { + const options = [15, 20, 30, 45, 60, 90, 120]; + final value = options.contains(_slotDurationMinutes) + ? _slotDurationMinutes + : 120; + return DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: 'Duración de cada cita', + prefixIcon: Icon(Icons.timer_outlined, color: context.muted), + filled: true, + fillColor: context.isDark + ? Colors.white.withOpacity(0.05) + : Colors.black.withOpacity(0.04), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + items: options + .map((m) => DropdownMenuItem( + value: m, + child: Text(m < 60 ? '$m min' : '${m ~/ 60}h${m % 60 == 0 ? '' : ' ${m % 60}min'}'), + )) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _slotDurationMinutes = v); + }, + ); + } + Widget _scheduleRows(BuildContext context) { final days = <_DayEntry>[ _DayEntry('Lunes', schedules.monday), @@ -585,14 +679,12 @@ class _ProfessionalProfileScreenState schedules: schedules, ratePreferences: rateValue, rate: _rateController.text, + slotDurationMinutes: _slotDurationMinutes, )); - context.read().add( - UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path)); - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Información actualizada correctamente')), - ); + if (_imageFile != null) { + context.read().add( + UpdateProfessionalBannerInfo(fileBanner: _imageFile!.path)); + } } } diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index d803a88..82eba87 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -1,7 +1,10 @@ import 'dart:io'; +import 'package:city_repository/city_repository.dart'; import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; +import 'package:intl_phone_field/helpers.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; @@ -15,6 +18,19 @@ import 'package:prosappco/screens/profile/components/profile_item.dart'; import 'package:prosappco/screens/profile/profile_register_email_screen.dart'; import 'package:prosappco/screens/profile/profile_register_phone_screen.dart'; import 'package:prosappco/screens/profile/profile_update_password_screen.dart'; +import 'package:prosappco/utils/nominatim_geocoder.dart'; + +const _kPrimary = Color(0xFF1565C0); + +extension _Th on BuildContext { + ThemeData get _t => Theme.of(this); + Color get onSurface => _t.colorScheme.onSurface; + Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); + Color get card => _t.cardColor; + bool get isDark => _t.brightness == Brightness.dark; + Color get shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -27,14 +43,13 @@ class _ProfileScreenState extends State { final TextEditingController _nameController = TextEditingController(); final TextEditingController _cityController = TextEditingController(); final TextEditingController _emailController = TextEditingController(); - final TextEditingController _newEmailController = TextEditingController(); final TextEditingController _phoneController = TextEditingController(); final TextEditingController _birthdayController = TextEditingController(); final TextEditingController _genderController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); XFile? _imageFile; bool isLoading = false; + bool _isLocating = false; late final AuthBloc authBloc; @@ -49,44 +64,97 @@ class _ProfileScreenState extends State { _nameController.dispose(); _cityController.dispose(); _emailController.dispose(); - _newEmailController.dispose(); _phoneController.dispose(); _birthdayController.dispose(); _genderController.dispose(); - _passwordController.dispose(); - super.dispose(); } + Future _useMyLocation() async { + setState(() => _isLocating = true); + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) throw Exception('Location services disabled'); + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + throw Exception('Location permission denied'); + } + + final position = await Geolocator.getCurrentPosition(); + final cityName = await NominatimGeocoder.reverseGeocodeCity( + position.latitude, position.longitude); + + if (cityName == null) throw Exception('City not resolved'); + + final cities = + await Injector.appInstance.get().getCities(); + final normalized = removeDiacritics(cityName.toLowerCase().trim()); + + CityUi? match; + for (final c in cities) { + if (removeDiacritics(c.cityName.toLowerCase().trim()) == normalized) { + match = c; + break; + } + } + if (match == null) { + for (final c in cities) { + final cName = removeDiacritics(c.cityName.toLowerCase()); + if (cName.contains(normalized) || normalized.contains(cName)) { + match = c; + break; + } + } + } + + if (!mounted) return; + if (match != null) { + setState(() => _cityController.text = match!.cityName); + } else { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'No encontramos tu ciudad en la lista, selecciónala manualmente'), + )); + } + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('No se pudo obtener tu ubicación, selecciona tu ciudad manualmente'), + )); + } finally { + if (mounted) setState(() => _isLocating = false); + } + } + @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => authBloc, + create: (_) => authBloc, child: BlocListener( listener: (context, state) { if (state is UpdateUserInfoLoading) { - setState(() { - isLoading = true; - }); + setState(() => isLoading = true); } else if (state is UpdateUserInfoSuccess) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Información actualizada'), - )); - setState(() { - isLoading = false; - }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Información actualizada'))); + setState(() => isLoading = false); } else if (state is UpdateUserInfoFailure) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Error al actualizar la información'), - )); - setState(() { - isLoading = false; - }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Error al actualizar'))); + setState(() => isLoading = false); } }, child: Scaffold( appBar: AppBar( - title: const Text('Perfil'), + title: const Text('Mi perfil'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: BlocBuilder( builder: (context, state) { @@ -102,195 +170,21 @@ class _ProfileScreenState extends State { children: [ Expanded( child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 40, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - pictureWidget(state, context), - const SizedBox(height: 30), - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Nombre', - prefixIcon: Icon(Icons.person), - hintText: 'Nombre (obligatorio)', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(10.0), - )), - errorBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.red), - ), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.red, width: 2.0), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Por favor, ingrese su nombre'; - } - return null; - }, - ), - const SizedBox(height: 20.0), - TextFormField( - controller: _cityController, - readOnly: true, - onTap: () async { - final cityName = await Navigator.push( - context, - CupertinoPageRoute( - builder: (BuildContext context) { - return const CityListScreen(); - }, - ), - ); - - if (cityName != null) { - _cityController.text = cityName; - } - }, - decoration: const InputDecoration( - labelText: 'Ciudad', - prefixIcon: Icon(Icons.near_me_rounded), - hintText: 'Selecciona tu ciudad', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(10.0), - )), - errorBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.red), - ), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.red, width: 2.0), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Por favor, ingrese su nombre'; - } - return null; - }, - ), - _birthdayController.text.isEmpty && - _genderController.text.isEmpty - ? Column( - children: [ - const SizedBox(height: 20.0), - BirthdayPicker( - onDateSelected: (birthDay) { - _birthdayController.text = - DateFormat('dd/MM/yyyy') - .format(birthDay); - }, - controller: _birthdayController, - ), - const SizedBox(height: 20.0), - GenderDropdown( - controller: _genderController, - ), - ], - ) - : const SizedBox(), - const SizedBox(height: 20), - ProfileItem( - title: 'Configurar inicio de sesión con correo', - subtitle: _emailController.text, - leading: Icons.email_rounded, - onTap: () { - if (state.user!.name == null || - state.user!.name == '') { - ScaffoldMessenger.of(context) - .clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Por favor, ingrese su nombre'))); - return; - } - - if (state.user!.city == null || - state.user!.city == '') { - ScaffoldMessenger.of(context) - .clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Por favor, ingrese su ciudad'))); - return; - } - - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => _emailController - .text.isEmpty - ? ProfileRegisterEmailScreen() - : ProfileUpdatePasswordScreen( - email: _emailController.text)), - ); - }, - ), - const SizedBox(height: 20), - ProfileItem( - title: - 'Configurar inicio de sesión con celular', - subtitle: _phoneController.text, - leading: Icons.phone_iphone_rounded, - onTap: () { - if (state.user!.name == null || - state.user!.name == '') { - ScaffoldMessenger.of(context) - .clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Por favor, ingrese su nombre'))); - return; - } - - if (state.user!.city == null || - state.user!.city == '') { - ScaffoldMessenger.of(context) - .clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Por favor, ingrese su nombre'))); - return; - } - - if (_phoneController.text.isEmpty) { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ProfileRegisterPhoneScreen(), - ), - ); - } - }, - ), - const SizedBox(height: 10), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _avatarSection(state, context), + const SizedBox(height: 8), + _formSection(state, context), + ], ), ), ), - const Divider( - height: 1, - thickness: 0.5, - ), + const Divider(height: 1, thickness: 0.5), Padding( padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - child: saveButton(state, context), + vertical: 12, horizontal: 16), + child: _saveButton(state, context), ), ], ); @@ -304,29 +198,220 @@ class _ProfileScreenState extends State { ); } - Widget saveButton(MyUserState state, BuildContext context) { + Widget _avatarSection(MyUserState state, BuildContext context) { + return Container( + color: _kPrimary, + padding: const EdgeInsets.only(bottom: 28), + child: Center( + child: Stack( + children: [ + GestureDetector( + onTap: () async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + maxHeight: 500, + maxWidth: 500, + imageQuality: 40, + ); + if (image != null) setState(() => _imageFile = image); + }, + child: Hero( + tag: 'picture-profile', + child: _avatarContainer(state), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), blurRadius: 4) + ], + ), + child: + const Icon(Icons.camera_alt_outlined, size: 16, color: _kPrimary), + ), + ), + ], + ), + ), + ); + } + + Widget _avatarContainer(MyUserState state) { + ImageProvider? imageProvider; + if (_imageFile?.path != null && _imageFile!.path.isNotEmpty) { + imageProvider = FileImage(File(_imageFile!.path)); + } else if (state.user?.picture != null && + state.user!.picture!.isNotEmpty) { + imageProvider = NetworkImage(state.user!.picture!); + } + + return Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + image: imageProvider == null + ? null + : DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + child: imageProvider == null + ? const Icon(CupertinoIcons.person, color: Colors.white70, size: 44) + : null, + ); + } + + Widget _formSection(MyUserState state, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _field( + controller: _nameController, + label: 'Nombre', + icon: Icons.person_outline_rounded, + validator: (v) => + (v == null || v.isEmpty) ? 'Ingresa tu nombre' : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _cityController, + readOnly: true, + onTap: () async { + final cityName = await Navigator.push( + context, + CupertinoPageRoute(builder: (_) => const CityListScreen()), + ); + if (cityName != null) _cityController.text = cityName; + }, + decoration: _inputDeco( + 'Ciudad', + Icons.location_city_outlined, + hint: 'Selecciona tu ciudad', + suffixIcon: _isLocating + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location_outlined), + tooltip: 'Usar mi ubicación', + onPressed: _useMyLocation, + ), + ), + ), + if (_birthdayController.text.isEmpty && + _genderController.text.isEmpty) ...[ + const SizedBox(height: 14), + BirthdayPicker( + onDateSelected: (d) => _birthdayController.text = + DateFormat('dd/MM/yyyy').format(d), + controller: _birthdayController, + ), + const SizedBox(height: 14), + GenderDropdown(controller: _genderController), + ], + const SizedBox(height: 20), + Container( + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)], + ), + child: Column( + children: [ + ProfileItem( + title: 'Inicio de sesión con correo', + subtitle: _emailController.text, + leading: Icons.email_outlined, + onTap: () { + if (!_validateProfile(state, context)) return; + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => _emailController.text.isEmpty + ? ProfileRegisterEmailScreen() + : ProfileUpdatePasswordScreen( + email: _emailController.text), + ), + ); + }, + ), + Divider( + height: 1, + thickness: 0.5, + color: context.onSurface.withOpacity(0.08)), + ProfileItem( + title: 'Inicio de sesión con celular', + subtitle: _phoneController.text, + leading: Icons.phone_iphone_rounded, + onTap: () { + if (!_validateProfile(state, context)) return; + if (_phoneController.text.isEmpty) { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + const ProfileRegisterPhoneScreen()), + ); + } + }, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ); + } + + bool _validateProfile(MyUserState state, BuildContext context) { + if (state.user!.name == null || state.user!.name == '') { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Por favor, ingresa tu nombre'))); + return false; + } + if (state.user!.city == null || state.user!.city == '') { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Por favor, ingresa tu ciudad'))); + return false; + } + return true; + } + + Widget _saveButton(MyUserState state, BuildContext context) { return ElevatedButton( onPressed: () { - if (isLoading) { - return; - } - + if (isLoading) return; if (_nameController.text.isEmpty) { - ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Por favor, ingrese su nombre'))); - + const SnackBar(content: Text('Por favor, ingresa tu nombre'))); return; } - if (_cityController.text.isEmpty) { - ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Por favor, ingrese su ciudad'))); - + const SnackBar(content: Text('Por favor, ingresa tu ciudad'))); return; } - final myUser = state.user!.copyWith( name: _nameController.text, city: _cityController.text, @@ -336,102 +421,62 @@ class _ProfileScreenState extends State { birthday: _birthdayController.text, gender: _genderController.text, ); - context .read() .add(UpdateUserInfo(myUser: myUser, filePicture: _imageFile?.path)); - ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Información actualizada...')), - ); - + const SnackBar(content: Text('Actualizando información...'))); Navigator.pop(context); }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, + style: ElevatedButton.styleFrom( + backgroundColor: _kPrimary, + foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - child: Container( - alignment: Alignment.center, - child: isLoading - ? const CircularProgressIndicator( - color: Colors.white, - ) - : const Text( - 'Actualizar', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('Guardar cambios', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ); + } + + static InputDecoration _inputDeco(String label, IconData icon, + {String? hint, Widget? suffixIcon}) { + return InputDecoration( + labelText: label, + hintText: hint, + prefixIcon: Icon(icon), + suffixIcon: suffixIcon, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _kPrimary, width: 2), ), ); } - Widget pictureWidget(MyUserState state, BuildContext context) { - final pictureUrl = state.user?.picture; - final pathImageFile = _imageFile?.path; - - ImageProvider? imageProvider; - - if (pathImageFile != null && pathImageFile.isNotEmpty) { - imageProvider = FileImage(File(pathImageFile)); - } else if (pictureUrl != null && pictureUrl.isNotEmpty) { - imageProvider = NetworkImage(pictureUrl); - } - - return GestureDetector( - onTap: () async { - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage( - source: ImageSource.gallery, - maxHeight: 500, - maxWidth: 500, - imageQuality: 40, - ); - - if (image != null) { - setState(() { - _imageFile = image; - }); - } - }, - child: Hero( - tag: 'picture-profile', - child: pictureContainerWidget(imageProvider), - ), - ); - } - - Widget pictureContainerWidget(ImageProvider? imageProvider) { - final image = imageProvider == null - ? null - : DecorationImage( - image: imageProvider, - fit: BoxFit.contain, - ); - - final widget = image == null - ? Icon( - CupertinoIcons.person, - color: Colors.grey.shade400, - size: 40, - ) - : null; - - return Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: image, - ), - child: widget, + Widget _field({ + required TextEditingController controller, + required String label, + required IconData icon, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + decoration: _inputDeco(label, icon), + validator: validator, ); } } diff --git a/lib/screens/score/score_screen.dart b/lib/screens/score/score_screen.dart index 54343ef..9476f36 100644 --- a/lib/screens/score/score_screen.dart +++ b/lib/screens/score/score_screen.dart @@ -10,9 +10,21 @@ import 'package:score_repository/score_repository.dart'; import 'package:service_repository/service_repository.dart'; import 'package:user_repository/user_repository.dart'; +const _kPrimary = Color(0xFF1565C0); + +extension _Th on BuildContext { + ThemeData get _t => Theme.of(this); + Color get bg => _t.scaffoldBackgroundColor; + Color get card => _t.cardColor; + Color get onSurface => _t.colorScheme.onSurface; + Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); + bool get isDark => _t.brightness == Brightness.dark; + Color get shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} + class ScoreScreen extends StatefulWidget { final ServiceEntity service; - const ScoreScreen({Key? key, required this.service}) : super(key: key); @override @@ -21,7 +33,7 @@ class ScoreScreen extends StatefulWidget { class _ScoreScreenState extends State { double _rating = 1.0; - TextEditingController commentController = TextEditingController(); + final TextEditingController _commentController = TextEditingController(); late Future> _userInfoFuture; late bool isProfessional; late String userId; @@ -29,12 +41,9 @@ class _ScoreScreenState extends State { @override void initState() { super.initState(); - _userInfoFuture = _getUserAndProfessionalInfo(widget.service); + _userInfoFuture = _getUserInfo(widget.service); isProfessional = - ApiUserRepository.currentUserId ?? '' == widget.service.userId - ? true - : false; - + (ApiUserRepository.currentUserId ?? '') == widget.service.userId; userId = isProfessional ? widget.service.professionalId : widget.service.userId; } @@ -42,10 +51,14 @@ class _ScoreScreenState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get(), child: Scaffold( + backgroundColor: context.bg, appBar: AppBar( - title: const Text('Calificación'), + title: const Text('Calificar servicio'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: Column( children: [ @@ -53,311 +66,42 @@ class _ScoreScreenState extends State { child: SingleChildScrollView( child: Column( children: [ - FutureBuilder( + FutureBuilder>( future: _userInfoFuture, - builder: - (context, AsyncSnapshot> snapshot) { + builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator()); - } else { - if (snapshot.hasError) { - return Center( - child: - Text('Error inesperado: ${snapshot.error}'), - ); - } else { - final userInfo = snapshot.data![0] as MyUser; - - return SizedBox( - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 95, - height: 95, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: userInfo.picture == null - ? null - : DecorationImage( - image: NetworkImage( - userInfo.picture!), - fit: BoxFit.contain, - ), - ), - child: userInfo.picture == null - ? Icon( - CupertinoIcons.person, - color: Colors.grey.shade400, - size: 50, - ) - : null, - ), - const SizedBox(height: 8), - Text( - '${userInfo.name}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox( - height: 15, - ) - ], - ), - GeneralReputation( - userId: userId, - builder: (BuildContext context, - ReputationEntity reputation) { - final double average; - - if (isProfessional) { - average = reputation.averagePro; - } else { - average = reputation.average; - } - - return Positioned( - top: 3, - right: - MediaQuery.of(context).size.width * - 0.3, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black - .withOpacity(0.1), - spreadRadius: 1, - blurRadius: 2, - offset: const Offset( - 0, - 1, - ), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.yellow, - size: 20, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - average.toStringAsFixed(2), - style: const TextStyle( - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - }, - ) - ], - ), - ); - // Padding( - // padding: const EdgeInsets.symmetric(horizontal: 15), - // child: ListTile( - // leading: Container( - // width: 60, - // height: 60, - // decoration: BoxDecoration( - // color: Colors.grey.shade300, - // shape: BoxShape.circle, - // image: userInfo.picture == null - // ? null - // : DecorationImage( - // image: NetworkImage( - // userInfo.picture ?? ''), - // fit: BoxFit.contain, - // ), - // ), - // child: userInfo.picture == null - // ? Icon( - // CupertinoIcons.person, - // color: Colors.grey.shade400, - // size: 40, - // ) - // : null, - // ), - // title: Text( - // userInfo.name ?? '', - // overflow: TextOverflow.ellipsis, - // style: const TextStyle( - // fontSize: 15, - // fontWeight: FontWeight.w600, - // ), - // ), - // subtitle: const Text( - // 'Rating: 5.0', - // style: TextStyle( - // fontSize: 13, - // color: Colors.blue, - // ), - // ), - // ), - // ); - } + return const Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(), + ); } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(24), + child: Text('Error: ${snapshot.error}'), + ); + } + final userInfo = snapshot.data![0] as MyUser; + return _profileHeader(context, userInfo); }, ), - const SizedBox(height: 10), - const Text( - 'Califica el servicio', - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 10), - RatingBar.builder( - initialRating: _rating, - minRating: 1, - direction: Axis.horizontal, - allowHalfRating: true, - itemCount: 5, - itemSize: 40, - glow: false, - maxRating: 5, - itemPadding: const EdgeInsets.symmetric(horizontal: 5), - itemBuilder: (context, _) => const Icon( - Icons.star, - color: Color(0xFF2BA4EC), - ), - onRatingUpdate: (rating) { - setState(() { - _rating = rating; - }); - }, - ignoreGestures: false, - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - customMessage(_rating), - style: const TextStyle( - fontSize: 16, - color: Color(0xFF2BA4EC), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 40, vertical: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Comentario', - style: TextStyle( - fontSize: 20, fontWeight: FontWeight.w600), - ), - TextFormField( - maxLines: null, - maxLength: 400, - keyboardType: TextInputType.multiline, - controller: commentController, - ), - ], - ), - ), + const SizedBox(height: 4), + _ratingSection(context), + _commentSection(context), ], ), ), ), - const Divider( - height: 1, - thickness: 0.5, - ), + const Divider(height: 1, thickness: 0.5), BlocProvider( - create: (context) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get(), child: Padding( padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), + vertical: 12, horizontal: 16), child: BlocBuilder( builder: (context, serviceState) { - return FilledButton( - onPressed: () { - final CommentEntity comment = widget.service.userId == - ApiUserRepository.currentUserId ?? '' - ? CommentEntity( - serviceId: widget.service.id!, - authorId: - ApiUserRepository.currentUserId ?? '', - isFromUser: true, - score: _rating, - destinationId: widget.service.professionalId, - content: commentController.text.trim(), - createdAt: DateTime.now().toIso8601String(), - ) - : CommentEntity( - serviceId: widget.service.id!, - authorId: - ApiUserRepository.currentUserId ?? '', - isFromUser: false, - score: _rating, - destinationId: widget.service.userId, - content: commentController.text.trim(), - createdAt: DateTime.now().toIso8601String(), - ); - - BlocProvider.of(context) - .add(SendScoreEvent(comment: comment)); - - if (widget.service.userId == - ApiUserRepository.currentUserId ?? '') { - context.read().add( - UpdateProfessionalScored(widget.service.id!)); - } else { - context - .read() - .add(UpdateUserScored(widget.service.id!)); - } - - Navigator.pop(context); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Container( - alignment: Alignment.center, - width: double.infinity, - child: const Text( - 'Enviar calificación', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), - ), - ); + return _sendButton(context); }, ), ), @@ -368,38 +112,241 @@ class _ScoreScreenState extends State { ); } - String customMessage(double rating) { - if (rating > 4.0) { - return '¡Excelente! 👏🌟'; - } - - if (rating < 2.0) { - return '¡Malo! 😔❌'; - } - - if (rating <= 4.0 && rating >= 3.0) { - return '¡Bueno! 👍😊'; - } - - if (rating < 3.0 && rating >= 2.0) { - return '¡Regular! 😐🔄'; - } - - return ''; + Widget _profileHeader(BuildContext context, MyUser user) { + return Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 12, + offset: const Offset(0, 2)) + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 84, + height: 84, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _kPrimary.withOpacity(0.25), width: 3), + color: Colors.grey.shade200, + image: user.picture != null && user.picture!.isNotEmpty + ? DecorationImage( + image: NetworkImage(user.picture!), + fit: BoxFit.cover) + : null, + ), + child: user.picture == null || user.picture!.isEmpty + ? Icon(CupertinoIcons.person, + color: Colors.grey.shade500, size: 38) + : null, + ), + const SizedBox(height: 10), + Text(user.name ?? '', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: context.onSurface)), + ], + ), + GeneralReputation( + userId: userId, + builder: (_, ReputationEntity reputation) { + final average = isProfessional + ? reputation.averagePro + : reputation.average; + return Positioned( + top: 0, + right: MediaQuery.of(context).size.width * 0.18, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 6, + spreadRadius: 1) + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, color: Colors.amber, size: 14), + const SizedBox(width: 3), + Text(average.toStringAsFixed(1), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: context.onSurface)), + ]), + ), + ); + }, + ), + ], + ), + ); } - Future> _getUserAndProfessionalInfo( - ServiceEntity service) async { + Widget _ratingSection(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)], + ), + child: Column( + children: [ + Text('¿Cómo fue el servicio?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: context.onSurface)), + const SizedBox(height: 14), + RatingBar.builder( + initialRating: _rating, + minRating: 1, + direction: Axis.horizontal, + allowHalfRating: true, + itemCount: 5, + itemSize: 42, + glow: false, + maxRating: 5, + itemPadding: const EdgeInsets.symmetric(horizontal: 4), + itemBuilder: (_, __) => + const Icon(Icons.star_rounded, color: _kPrimary), + onRatingUpdate: (r) => setState(() => _rating = r), + ), + const SizedBox(height: 8), + Text( + _ratingMessage(_rating), + style: + TextStyle(fontSize: 14, color: _kPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _commentSection(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Comentario (opcional)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.onSurface)), + const SizedBox(height: 10), + TextFormField( + controller: _commentController, + maxLines: 4, + maxLength: 400, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintText: 'Cuéntanos tu experiencia...', + hintStyle: TextStyle(color: context.muted, fontSize: 13), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _kPrimary, width: 2), + ), + contentPadding: const EdgeInsets.all(12), + ), + ), + ], + ), + ); + } + + Widget _sendButton(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + final isUser = widget.service.userId == + (ApiUserRepository.currentUserId ?? ''); + final comment = CommentEntity( + serviceId: widget.service.id!, + authorId: ApiUserRepository.currentUserId ?? '', + isFromUser: isUser, + score: _rating, + destinationId: isUser + ? widget.service.professionalId + : widget.service.userId, + content: _commentController.text.trim(), + createdAt: DateTime.now().toIso8601String(), + ); + BlocProvider.of(context) + .add(SendScoreEvent(comment: comment)); + if (isUser) { + context + .read() + .add(UpdateProfessionalScored(widget.service.id!)); + } else { + context + .read() + .add(UpdateUserScored(widget.service.id!)); + } + Navigator.pop(context); + }, + icon: const Icon(Icons.send_rounded, size: 18), + label: const Text('Enviar calificación', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ); + } + + String _ratingMessage(double rating) { + if (rating > 4.0) return '¡Excelente!'; + if (rating < 2.0) return 'Necesita mejorar'; + if (rating >= 3.0) return '¡Bien!'; + return 'Regular'; + } + + Future> _getUserInfo(ServiceEntity service) async { final userRepo = Injector.appInstance.get(); - - final MyUser? userInfo; - - if (ApiUserRepository.currentUserId ?? '' == service.userId) { - userInfo = await userRepo.getMyUser(service.professionalId); - } else { - userInfo = await userRepo.getMyUser(service.userId); - } - + final currentId = ApiUserRepository.currentUserId ?? ''; + final targetId = + currentId == service.userId ? service.professionalId : service.userId; + final userInfo = await userRepo.getMyUser(targetId); return [userInfo]; } } diff --git a/lib/screens/service/professional_service_screen.dart b/lib/screens/service/professional_service_screen.dart index 52d5cb1..d5afac2 100644 --- a/lib/screens/service/professional_service_screen.dart +++ b/lib/screens/service/professional_service_screen.dart @@ -17,6 +17,20 @@ import 'package:setting_repository/setting_repository.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:user_repository/user_repository.dart'; +const _kPrimary = Color(0xFF1565C0); + +extension _Th on BuildContext { + ThemeData get _t => Theme.of(this); + Color get bg => _t.scaffoldBackgroundColor; + Color get card => _t.cardColor; + Color get onSurface => _t.colorScheme.onSurface; + Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); + Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35); + bool get isDark => _t.brightness == Brightness.dark; + Color get shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} + class ProfessionalServiceScreen extends StatefulWidget { final String serviceId; const ProfessionalServiceScreen({super.key, required this.serviceId}); @@ -26,7 +40,8 @@ class ProfessionalServiceScreen extends StatefulWidget { _ProfessionalServiceScreenState(); } -class _ProfessionalServiceScreenState extends State { +class _ProfessionalServiceScreenState + extends State { final settingRepository = Injector.appInstance.get(); SettingEntity? settings; Timer? _timer; @@ -34,7 +49,6 @@ class _ProfessionalServiceScreenState extends State { @override void initState() { super.initState(); - _loadSettings(); _startTimer(); } @@ -46,264 +60,67 @@ class _ProfessionalServiceScreenState extends State { } void _startTimer() { - _timer = Timer.periodic(const Duration(minutes: 5), (timer) { + _timer = Timer.periodic(const Duration(minutes: 5), (_) { setState(() {}); }); } void _loadSettings() { - settingRepository.getSettings().then( - (value) => setState(() { - settings = value; - }), - ); + settingRepository.getSettings().then((v) => setState(() => settings = v)); } @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get(), child: Scaffold( + backgroundColor: context.bg, appBar: AppBar( - title: const Text('Servicio'), + title: const Text('Detalle del servicio'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: BlocBuilder( builder: (context, state) { if (state is ServiceLoaded) { final service = state.service; - return FutureBuilder( - future: _getUserAndProfessionalInfo(service), - builder: (BuildContext context, - AsyncSnapshot> snapshot) { + return FutureBuilder>( + future: _getUserInfo(service), + builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); - } else { - if (snapshot.hasError) { - return Center( - child: Text('Error inesperado: ${snapshot.error}'), - ); - } else { - final userInfo = snapshot.data![0] as MyUser; - - return Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 95, - height: 95, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: userInfo.picture == null - ? null - : DecorationImage( - image: NetworkImage( - userInfo.picture!), - fit: BoxFit.contain, - ), - ), - child: userInfo.picture == null - ? Icon( - CupertinoIcons.person, - color: - Colors.grey.shade400, - size: 50, - ) - : null, - ), - const SizedBox(height: 8), - Text( - '${userInfo.name}', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - ), - ), - Row( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: Text( - '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}', - style: TextStyle( - fontSize: 15, - color: Colors.grey[600], - ), - ), - ), - ), - ], - ), - ], - ), - GeneralReputation( - userId: service.userId, - builder: (BuildContext context, - ReputationEntity reputation) { - final average = reputation.average; - - return Positioned( - top: 3, - right: MediaQuery.of(context) - .size - .width * - 0.3, - child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 10, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black - .withOpacity(0.1), - spreadRadius: 1, - blurRadius: 2, - offset: const Offset( - 0, - 1, - ), - ), - ], - ), - child: Row( - mainAxisSize: - MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.yellow, - size: 20, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - average - .toStringAsFixed(2), - style: const TextStyle( - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - }, - ), - ], - ), - ), - Container( - margin: const EdgeInsets.only( - left: 40, - right: 40, - top: 20, - bottom: 20, - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 15, - ), - decoration: BoxDecoration( - color: const Color(0xFFD6F4FF), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.5), - spreadRadius: 1, - blurRadius: 5, - offset: const Offset(1, 3), - ), - ], - ), - child: Row( - children: [ - const Icon( - Icons.error_outline, - size: 27, - color: Colors.black54, - ), - const SizedBox(width: 15), - service.location == - ServiceLocationPreferences - .delivery - ? const Text( - 'Servicio a domicilio.', - style: TextStyle( - color: Colors.black, - fontSize: 14, - ), - ) - : const Text( - 'Servicio en tu consultorio', - style: TextStyle( - color: Colors.black, - fontSize: 14, - ), - ), - ], - ), - ), - customMapButton(service), - service.description.isEmpty - ? const SizedBox() - : Container( - alignment: Alignment.center, - width: MediaQuery.of(context) - .size - .width * - 0.8, - child: Text( - '"${service.description.trim()}"', - style: TextStyle( - color: Colors.grey[600], - fontStyle: FontStyle.italic, - ), - ), - ), - const SizedBox(height: 20), - customActionButtons(service, userInfo), - const SizedBox(height: 50), - customMessageStatus(service), - const SizedBox(height: 20), - ], - ), - ), - ), - const Divider( - height: 1, - thickness: 0.5, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - child: customButton(service, context), - ), - ], - ); - } } + if (snapshot.hasError) { + return Center( + child: Text('Error: ${snapshot.error}')); + } + final userInfo = snapshot.data![0] as MyUser; + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _headerCard(context, service, userInfo), + _locationCard(context, service), + if (service.description.isNotEmpty) + _descriptionCard(context, service), + _actionButtons(context, service, userInfo), + _statusBanner(context, service), + const SizedBox(height: 24), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.5), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 16), + child: _bottomButton(context, service), + ), + ], + ); }, ); } else { @@ -317,526 +134,394 @@ class _ProfessionalServiceScreenState extends State { ); } - Widget customActionButtons(ServiceEntity service, MyUser user) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); - - if (service.status == ServiceStatus.acepted || - service.status == ServiceStatus.active) { - if (now.isAfter(serviceDateTime)) { - return const SizedBox(); - } else { - return Wrap( - alignment: WrapAlignment.center, - spacing: 15, - children: [ - Visibility( - visible: user.phone == null ? false : true, - child: ElevatedButton( - onPressed: () => launch("tel:${user.phone}"), - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - Icons.phone_android, - size: 30, - color: Color(0xFF2BA4EC), - ), + Widget _headerCard( + BuildContext context, ServiceEntity service, MyUser user) { + return Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 12, + offset: const Offset(0, 2)) + ], + ), + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + // Avatar + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: + Border.all(color: _kPrimary.withOpacity(0.25), width: 3), + color: Colors.grey.shade200, + image: user.picture != null && user.picture!.isNotEmpty + ? DecorationImage( + image: NetworkImage(user.picture!), + fit: BoxFit.cover) + : null, ), + child: user.picture == null || user.picture!.isEmpty + ? Icon(CupertinoIcons.person, + color: Colors.grey.shade500, size: 40) + : null, ), - ), - ElevatedButton( - onPressed: () { - if (service.id != null) { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ChatScreen(service: service), + // Star rating badge + GeneralReputation( + userId: service.userId, + builder: (_, ReputationEntity reputation) { + return Positioned( + top: 0, + right: MediaQuery.of(context).size.width * 0.18, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 6, + spreadRadius: 1) + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, color: Colors.amber, size: 14), + const SizedBox(width: 3), + Text(reputation.average.toStringAsFixed(1), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: context.onSurface)), + ]), ), ); - } - }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - Icons.message, - size: 30, - color: Color(0xFF2BA4EC), - ), - ), - ), - Visibility( - visible: user.phone == null ? false : true, - child: ElevatedButton( - onPressed: () async { - final whatsappUrl = - 'https://wa.me/${user.phone}?text=${Uri.parse('Hola! me contactaste por Prossapp')}'; - if (!await launch(whatsappUrl)) { - throw Exception('Could not launch $whatsappUrl'); - } }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - CommunityMaterialIcons.whatsapp, - size: 30, - color: Color(0xFF2BA4EC), - ), - ), ), + ], + ), + const SizedBox(height: 10), + Text(user.name ?? '', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: context.onSurface)), + const SizedBox(height: 4), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.calendar_today_outlined, size: 13, color: context.muted), + const SizedBox(width: 5), + Text( + '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} · ${ScheduleEntity.getFormatTime(service.range1Hour1) ?? ''}', + style: TextStyle( + fontSize: 13, + color: context.muted, + fontWeight: FontWeight.w500), ), - ], - ); - } - } - - return const SizedBox(); + ]), + ], + ), + ); } - Widget customMapButton(ServiceEntity service) { + Widget _locationCard(BuildContext context, ServiceEntity service) { + final isDelivery = + service.location == ServiceLocationPreferences.delivery; + if (!isDelivery && + service.status != ServiceStatus.acepted && + service.status != ServiceStatus.pending && + service.status != ServiceStatus.active) { + return const SizedBox(); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.06), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _kPrimary.withOpacity(0.15)), + ), + child: Row( + children: [ + Icon( + isDelivery ? Icons.home_outlined : Icons.business_outlined, + color: _kPrimary, + size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isDelivery + ? 'Servicio a domicilio' + : 'Servicio en consultorio', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _kPrimary)), + if (isDelivery && + (service.status == ServiceStatus.acepted || + service.status == ServiceStatus.pending || + service.status == ServiceStatus.active) && + service.address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(service.address, + style: TextStyle( + fontSize: 12, color: context.muted)), + ), + ], + ), + ), + if (isDelivery && + (service.status == ServiceStatus.acepted || + service.status == ServiceStatus.pending || + service.status == ServiceStatus.active)) + IconButton( + icon: Icon(Icons.near_me, color: _kPrimary), + onPressed: () async { + final url = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=${service.latitude},${service.longitude}'); + if (!await launchUrl(url)) throw Exception('No se pudo abrir mapa'); + }, + ), + ], + ), + ); + } + + Widget _descriptionCard(BuildContext context, ServiceEntity service) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: context.shadowSm, blurRadius: 8) + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.format_quote_rounded, + color: context.subtle, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + service.description.trim(), + style: TextStyle( + fontSize: 13, + color: context.muted, + fontStyle: FontStyle.italic, + height: 1.5), + ), + ), + ], + ), + ); + } + + Widget _actionButtons( + BuildContext context, ServiceEntity service, MyUser user) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); + if ((service.status == ServiceStatus.acepted || - service.status == ServiceStatus.pending || service.status == ServiceStatus.active) && - service.location == ServiceLocationPreferences.delivery) { + !now.isAfter(serviceDateTime)) { return Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 25, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () async { - final Uri url = Uri.parse( - 'https://www.google.com/maps/search/?api=1&query=${service.latitude},${service.longitude}'); - if (!await launchUrl(url)) { - throw Exception('Could not launch $url'); + if (user.phone != null) + _ActionBtn( + icon: Icons.phone_outlined, + label: 'Llamar', + onTap: () => launchUrl(Uri.parse('tel:${user.phone}')), + ), + if (user.phone != null) const SizedBox(width: 10), + _ActionBtn( + icon: Icons.chat_bubble_outline_rounded, + label: 'Chat', + onTap: () { + if (service.id != null) { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => ChatScreen(service: service))); } }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: const Color(0xFF2BA4EC), - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric( - vertical: 15, - horizontal: 0, - ), - child: Icon( - Icons.near_me, - size: 25, - color: Colors.white, - ), - ), ), - const SizedBox(width: 15), - SizedBox( - width: MediaQuery.of(context).size.width * 0.6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - service.address, - maxLines: 3, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: 15, - //center - ), - ), - service.aditionalAddress.isEmpty - ? Container() - : Text( - service.aditionalAddress, - textAlign: TextAlign.start, - maxLines: 3, - style: const TextStyle( - fontSize: 15, - color: Colors.black54, - ), - ), - ], + if (user.phone != null) const SizedBox(width: 10), + if (user.phone != null) + _ActionBtn( + icon: CommunityMaterialIcons.whatsapp, + label: 'WhatsApp', + onTap: () async { + final url = + 'https://wa.me/${user.phone}?text=${Uri.encodeFull('Hola! me contactaste por Prossapp')}'; + if (!await launchUrl(Uri.parse(url))) { + throw Exception('No se pudo abrir WhatsApp'); + } + }, ), - ), ], ), ); } - return const SizedBox(); } - Widget customMessageStatus(ServiceEntity service) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); + Widget _statusBanner(BuildContext context, ServiceEntity service) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); - if (now.isAfter(serviceDateTime)) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Caducado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - Text( - 'Tu servicio ha excedido \n el tiempo de espera', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, color: Colors.red), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: - const Icon(Icons.close_rounded, color: Colors.red, size: 48), - ), - ) - ], + final bool expired = now.isAfter(serviceDateTime); + + if (expired) { + return _StatusCard( + color: const Color(0xFFDC2626), + icon: Icons.timer_off_outlined, + title: 'Caducado', + subtitle: 'El tiempo del servicio ha expirado', ); - } else { - if (service.status == ServiceStatus.cancelled) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Text( - 'Cancelado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close_rounded, - color: Colors.red, size: 48), - ), - ) - ], - ); - } - if (service.status == ServiceStatus.denied) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Text( - 'Rechazado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close_rounded, - color: Colors.red, size: 48), - ), - ) - ], - ); - } - - if (service.status == ServiceStatus.completed && - service.userScored == false) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.green.shade50, - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - children: [ - const Text( - 'Completado', - style: TextStyle(fontSize: 32, color: Colors.green), - ), - const Text( - 'Califica el servicio', - style: TextStyle(fontSize: 20, color: Colors.green), - ), - const SizedBox(height: 15), - FilledButton( - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ScoreScreen( - service: service, - ), - ), - ); - }, - style: FilledButton.styleFrom( - backgroundColor: Colors.green, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Container( - alignment: Alignment.center, - width: MediaQuery.of(context).size.width * 0.4, - child: const Text( - 'Calificar', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), - ), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.green.shade50, width: 4), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.star, - color: Colors.green, - size: 48, - ), - ), - ) - ], - ); - } - - if (service.status == ServiceStatus.completed && - service.userScored == true) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.green.shade50, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - children: [ - Text( - 'Completado', - style: TextStyle(fontSize: 32, color: Colors.green), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.green.shade50, width: 4), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.star, - color: Colors.green, - size: 48, - ), - ), - ) - ], - ); - } } - return const SizedBox(); + switch (service.status) { + case ServiceStatus.cancelled: + return _StatusCard( + color: const Color(0xFF9CA3AF), + icon: Icons.remove_circle_outline, + title: 'Cancelado', + subtitle: 'El servicio fue cancelado', + ); + case ServiceStatus.denied: + return _StatusCard( + color: const Color(0xFFDC2626), + icon: Icons.cancel_outlined, + title: 'Rechazado', + subtitle: 'El servicio fue rechazado', + ); + case ServiceStatus.completed: + if (service.userScored == false) { + return _StatusCard( + color: const Color(0xFF16A34A), + icon: Icons.star_outline_rounded, + title: 'Completado', + subtitle: 'Califica el servicio', + action: _ScoreButton(service: service), + ); + } + return _StatusCard( + color: const Color(0xFF16A34A), + icon: Icons.task_alt_outlined, + title: 'Completado', + subtitle: '¡Servicio finalizado exitosamente!', + ); + default: + return const SizedBox(); + } } - Widget customButton(ServiceEntity service, BuildContext context) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); + Widget _bottomButton(BuildContext context, ServiceEntity service) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); if (now.isAfter(serviceDateTime)) { return GeneralSecondaryButton( - label: 'Volver', - onPressed: () { - Navigator.pop(context); - }, - ); - } else { - if (service.status == ServiceStatus.pending) { - return Column( - children: [ - GeneralSecondaryButton( - label: 'Rechazar servicio', - color: Theme.of(context).colorScheme.error, - onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.denied)); - } - }, - ), - const SizedBox(height: 10), - GeneralSecondaryButton( - label: 'Aceptar servicio', - onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.acepted)); - } - }, - ), - ], - ); - } + label: 'Volver', onPressed: () => Navigator.pop(context)); + } - if (service.status == ServiceStatus.acepted) { - if (serviceDateTime.difference(now).inHours > 1) { - return GeneralSecondaryButton( - label: 'Cancelar servicio', + if (service.status == ServiceStatus.pending) { + return Column( + children: [ + GeneralSecondaryButton( + label: 'Rechazar servicio', color: Theme.of(context).colorScheme.error, onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.cancelled)); + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add( + UpdateServiceStatus(widget.serviceId, ServiceStatus.denied)); } }, - ); - } else { - return GeneralSecondaryButton( - label: 'Iniciar servicio', + ), + const SizedBox(height: 10), + GeneralSecondaryButton( + label: 'Aceptar servicio', onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { + final s = context.read().state; + if (s is ServiceLoaded) { context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.active)); + widget.serviceId, ServiceStatus.acepted)); } }, - ); - } - } + ), + ], + ); + } - if (service.status == ServiceStatus.active) { + if (service.status == ServiceStatus.acepted) { + if (serviceDateTime.difference(now).inHours > 1) { return GeneralSecondaryButton( - label: 'Terminar servicio', - onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.completed)); - } - }); + label: 'Cancelar servicio', + color: Theme.of(context).colorScheme.error, + onPressed: () { + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add(UpdateServiceStatus( + widget.serviceId, ServiceStatus.cancelled)); + } + }, + ); + } else { + return GeneralSecondaryButton( + label: 'Iniciar servicio', + onPressed: () { + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add( + UpdateServiceStatus(widget.serviceId, ServiceStatus.active)); + } + }, + ); } } + if (service.status == ServiceStatus.active) { + return GeneralSecondaryButton( + label: 'Terminar servicio', + onPressed: () { + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add(UpdateServiceStatus( + widget.serviceId, ServiceStatus.completed)); + } + }, + ); + } + return GeneralSecondaryButton( - label: 'Volver', - onPressed: () { - Navigator.pop(context); - }, - ); + label: 'Volver', onPressed: () => Navigator.pop(context)); } String formatCurrency(int number) { @@ -845,11 +530,128 @@ class _ProfessionalServiceScreenState extends State { return '\$${formatter.format(number)}'; } - Future> _getUserAndProfessionalInfo( - ServiceEntity service) async { + Future> _getUserInfo(ServiceEntity service) async { final userRepo = Injector.appInstance.get(); final userInfo = await userRepo.getMyUser(service.userId); - return [userInfo]; } } + +// ── shared widgets ────────────────────────────────────────────────────────── + +class _ActionBtn extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _ActionBtn( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onTap, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: _kPrimary, + side: BorderSide(color: _kPrimary.withOpacity(0.5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ); + } +} + +class _StatusCard extends StatelessWidget { + final Color color; + final IconData icon; + final String title; + final String subtitle; + final Widget? action; + const _StatusCard({ + required this.color, + required this.icon, + required this.title, + required this.subtitle, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: color)), + Text(subtitle, + style: TextStyle( + fontSize: 12, + color: color.withOpacity(0.75))), + ], + ), + ), + ], + ), + if (action != null) ...[ + const SizedBox(height: 12), + action!, + ], + ], + ), + ); + } +} + +class _ScoreButton extends StatelessWidget { + final ServiceEntity service; + const _ScoreButton({required this.service}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.push( + context, + CupertinoPageRoute(builder: (_) => ScoreScreen(service: service)), + ), + icon: const Icon(Icons.star_outline_rounded, size: 18), + label: const Text('Calificar servicio'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ); + } +} diff --git a/lib/screens/service/user_service_screen.dart b/lib/screens/service/user_service_screen.dart index 826fb08..c553829 100644 --- a/lib/screens/service/user_service_screen.dart +++ b/lib/screens/service/user_service_screen.dart @@ -18,6 +18,20 @@ import 'package:setting_repository/setting_repository.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:user_repository/user_repository.dart'; +const _kPrimary = Color(0xFF1565C0); + +extension _Th on BuildContext { + ThemeData get _t => Theme.of(this); + Color get bg => _t.scaffoldBackgroundColor; + Color get card => _t.cardColor; + Color get onSurface => _t.colorScheme.onSurface; + Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); + Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35); + bool get isDark => _t.brightness == Brightness.dark; + Color get shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} + class UserServiceScreen extends StatefulWidget { final String serviceId; const UserServiceScreen({super.key, required this.serviceId}); @@ -34,7 +48,6 @@ class _UserServiceScreenState extends State { @override void initState() { super.initState(); - _loadSettings(); _startTimer(); } @@ -46,400 +59,71 @@ class _UserServiceScreenState extends State { } void _startTimer() { - _timer = Timer.periodic(const Duration(minutes: 5), (timer) { + _timer = Timer.periodic(const Duration(minutes: 5), (_) { setState(() {}); }); } void _loadSettings() { - settingRepository.getSettings().then( - (value) => setState(() { - settings = value; - }), - ); + settingRepository.getSettings().then((v) => setState(() => settings = v)); } @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get(), child: Scaffold( + backgroundColor: context.bg, appBar: AppBar( - title: const Text('Servicio'), + title: const Text('Detalle del servicio'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: BlocBuilder( builder: (context, state) { if (state is ServiceLoaded) { final service = state.service; - return FutureBuilder( + return FutureBuilder>( future: _getUserAndProfessionalInfo(service), - builder: (BuildContext context, - AsyncSnapshot> snapshot) { + builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); - } else { - if (snapshot.hasError) { - return Center( - child: Text('Error inesperado: ${snapshot.error}'), - ); - } else { - final userInfo = snapshot.data![0] as MyUser; - final professionalInfo = - snapshot.data![1] as ProfessionalEntity; - - return Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 95, - height: 95, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: userInfo.picture == null - ? null - : DecorationImage( - image: NetworkImage( - userInfo.picture!), - fit: BoxFit.contain, - ), - ), - child: userInfo.picture == null - ? Icon( - CupertinoIcons.person, - color: - Colors.grey.shade400, - size: 50, - ) - : null, - ), - const SizedBox(height: 8), - Text( - '${userInfo.name}', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - ), - ), - Row( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: Text( - '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}', - style: TextStyle( - fontSize: 15, - color: Colors.grey[600], - ), - ), - ), - ), - ], - ), - ], - ), - GeneralReputation( - userId: service.professionalId, - builder: (BuildContext context, - ReputationEntity reputation) { - final average = - reputation.averagePro; - - return Positioned( - top: 3, - right: MediaQuery.of(context) - .size - .width * - 0.3, - child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 10, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black - .withOpacity(0.1), - spreadRadius: 1, - blurRadius: 2, - offset: const Offset( - 0, - 1, - ), - ), - ], - ), - child: Row( - mainAxisSize: - MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.yellow, - size: 20, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - average - .toStringAsFixed(2), - style: const TextStyle( - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - }, - ), - ], - ), - ), - - // ListTile( - // leading: Container( - // width: 60, - // height: 60, - // decoration: BoxDecoration( - // color: Colors.grey.shade300, - // shape: BoxShape.circle, - // image: userInfo.picture == null - // ? null - // : DecorationImage( - // image: NetworkImage( - // userInfo.picture!, - // ), - // fit: BoxFit.contain, - // ), - // ), - // child: userInfo.picture == null - // ? Icon( - // CupertinoIcons.person, - // color: Colors.grey.shade400, - // size: 40, - // ) - // : null, - // ), - // title: Row( - // crossAxisAlignment: - // CrossAxisAlignment.start, - // children: [ - // Text( - // '${userInfo.name}', - // style: const TextStyle( - // fontWeight: FontWeight.bold), - // ), - // const SizedBox(width: 5), - // Text( - // '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}', - // style: TextStyle( - // color: Colors.grey[600], - // ), - // ), - // ], - // ), - // subtitle: const Text('Rating: 5.0'), - // ), - - Container( - margin: const EdgeInsets.only( - left: 40, - right: 40, - top: 20, - bottom: 20, - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 15, - ), - decoration: BoxDecoration( - color: const Color(0xFFD6F4FF), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.5), - spreadRadius: 1, - blurRadius: 5, - offset: const Offset(1, 3), - ), - ], - ), - child: Row( - children: [ - const Icon( - Icons.error_outline, - size: 27, - color: Colors.black54, - ), - const SizedBox(width: 15), - service.location == - ServiceLocationPreferences - .delivery - ? const Text( - 'Servicio a tu domicilio.', - style: TextStyle( - color: Colors.black, - fontSize: 14, - ), - ) - : const Text( - 'Servicio en sitio / consultorio', - style: TextStyle( - color: Colors.black, - fontSize: 14, - ), - ), - ], - ), - ), - Visibility( - visible: settings?.tarifas ?? false, - child: Column( - children: [ - Text( - formatCurrency( - int.tryParse(service.rate) ?? 0), - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 25), - ), - const Text('Tarifa de consulta'), - const SizedBox(height: 15), - const Text( - 'Metodos de pago', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - professionalInfo - .paymentMethods.datafono || - professionalInfo - .paymentMethods.nequi || - professionalInfo.paymentMethods - .transferencia - ? Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - Visibility( - visible: professionalInfo - .paymentMethods - .datafono, - child: const Chip( - label: - Text('Datafono')), - ), - Visibility( - visible: professionalInfo - .paymentMethods.nequi, - child: const Chip( - label: Text('Nequi')), - ), - Visibility( - visible: professionalInfo - .paymentMethods - .transferencia, - child: const Chip( - label: Text( - 'Transferencia Bancaria')), - ), - ], - ) - : const Text( - 'No hay metodos de pago registrados', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - color: Colors.black45, - ), - ), - ], - ), - ), - - customMapButton(service), - - // ListTile( - // leading: const Icon(Icons.near_me), - // title: Text( - // service.address, - // style: TextStyle( - // fontSize: 15, - // color: Colors.grey[600], - // ), - // ), - // subtitle: service.aditionalAddress.isEmpty - // ? null - // : Text( - // service.aditionalAddress, - // style: TextStyle( - // fontSize: 15, - // color: Colors.grey[600], - // ), - // ), - // ), - service.description.isEmpty - ? const SizedBox() - : Container( - alignment: Alignment.center, - width: MediaQuery.of(context) - .size - .width * - 0.8, - child: Text( - '"${service.description.trim()}"', - style: TextStyle( - color: Colors.grey[600], - fontStyle: FontStyle.italic, - ), - ), - ), - const SizedBox(height: 20), - customActionButtons(service, userInfo), - const SizedBox(height: 50), - customMessageStatus(service), - const SizedBox(height: 20), - ], - ), - ), - ), - const Divider( - height: 1, - thickness: 0.5, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - child: customButton(service, context, userInfo), - ), - ], - ); - } } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + final userInfo = snapshot.data![0] as MyUser; + final professionalInfo = + snapshot.data![1] as ProfessionalEntity; + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _headerCard(context, service, userInfo, + professionalInfo), + _locationCard(context, service), + if (settings?.tarifas == true) + _rateCard(context, service, professionalInfo), + if (service.description.isNotEmpty) + _descriptionCard(context, service), + _actionButtons(context, service, userInfo), + _statusBanner(context, service), + const SizedBox(height: 24), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.5), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 16), + child: _bottomButton(context, service, userInfo), + ), + ], + ); }, ); } else { @@ -453,463 +137,405 @@ class _UserServiceScreenState extends State { ); } - Widget customMapButton(ServiceEntity service) { + Widget _headerCard(BuildContext context, ServiceEntity service, MyUser user, + ProfessionalEntity professional) { + return Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 12, + offset: const Offset(0, 2)) + ], + ), + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: + Border.all(color: _kPrimary.withOpacity(0.25), width: 3), + color: Colors.grey.shade200, + image: user.picture != null && user.picture!.isNotEmpty + ? DecorationImage( + image: NetworkImage(user.picture!), + fit: BoxFit.cover) + : null, + ), + child: user.picture == null || user.picture!.isEmpty + ? Icon(CupertinoIcons.person, + color: Colors.grey.shade500, size: 40) + : null, + ), + GeneralReputation( + userId: service.professionalId, + builder: (_, ReputationEntity reputation) { + return Positioned( + top: 0, + right: MediaQuery.of(context).size.width * 0.18, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 6, + spreadRadius: 1) + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, color: Colors.amber, size: 14), + const SizedBox(width: 3), + Text(reputation.averagePro.toStringAsFixed(1), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: context.onSurface)), + ]), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 10), + Text(user.name ?? '', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: context.onSurface)), + const SizedBox(height: 2), + Text(professional.profession, + style: TextStyle(fontSize: 13, color: context.muted)), + const SizedBox(height: 4), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.calendar_today_outlined, size: 13, color: context.muted), + const SizedBox(width: 5), + Text( + '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} · ${ScheduleEntity.getFormatTime(service.range1Hour1) ?? ''}', + style: TextStyle( + fontSize: 13, + color: context.muted, + fontWeight: FontWeight.w500), + ), + ]), + ], + ), + ); + } + + Widget _locationCard(BuildContext context, ServiceEntity service) { + final isOffice = + service.location == ServiceLocationPreferences.office; + if (!isOffice && + service.status != ServiceStatus.acepted && + service.status != ServiceStatus.active) { + return const SizedBox(); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.06), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _kPrimary.withOpacity(0.15)), + ), + child: Row( + children: [ + Icon( + isOffice ? Icons.business_outlined : Icons.home_outlined, + color: _kPrimary, + size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isOffice + ? 'Servicio en sitio / consultorio' + : 'Servicio a tu domicilio', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _kPrimary)), + if (isOffice && service.address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(service.address, + style: TextStyle(fontSize: 12, color: context.muted)), + ), + ], + ), + ), + if (isOffice && + (service.status == ServiceStatus.acepted || + service.status == ServiceStatus.active)) + IconButton( + icon: Icon(Icons.near_me, color: _kPrimary), + onPressed: () async { + final url = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=${service.latitude},${service.longitude}'); + if (!await launchUrl(url)) { + throw Exception('No se pudo abrir mapa'); + } + }, + ), + ], + ), + ); + } + + Widget _rateCard(BuildContext context, ServiceEntity service, + ProfessionalEntity professional) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: context.shadowSm, blurRadius: 8) + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + formatCurrency(int.tryParse(service.rate) ?? 0), + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: context.onSurface), + ), + const SizedBox(width: 6), + Text('tarifa de consulta', + style: TextStyle(fontSize: 12, color: context.muted)), + ], + ), + if (professional.paymentMethods.datafono || + professional.paymentMethods.nequi || + professional.paymentMethods.transferencia) ...[ + const SizedBox(height: 10), + Text('Métodos de pago', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.onSurface)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.center, + children: [ + if (professional.paymentMethods.datafono) + _PayBadge('Datafono'), + if (professional.paymentMethods.nequi) _PayBadge('Nequi'), + if (professional.paymentMethods.transferencia) + _PayBadge('Transferencia'), + ], + ), + ], + ], + ), + ); + } + + Widget _descriptionCard(BuildContext context, ServiceEntity service) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.format_quote_rounded, + color: context.subtle, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + service.description.trim(), + style: TextStyle( + fontSize: 13, + color: context.muted, + fontStyle: FontStyle.italic, + height: 1.5), + ), + ), + ], + ), + ); + } + + Widget _actionButtons( + BuildContext context, ServiceEntity service, MyUser user) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); + if ((service.status == ServiceStatus.acepted || service.status == ServiceStatus.active) && - service.location == ServiceLocationPreferences.office) { + !now.isAfter(serviceDateTime)) { return Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 25, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () async { - final Uri url = Uri.parse( - 'https://www.google.com/maps/search/?api=1&query=${service.latitude},${service.longitude}'); - - if (!await launchUrl(url)) { - throw Exception('Could not launch $url'); + if (user.phone != null) + _ActionBtn( + icon: Icons.phone_outlined, + label: 'Llamar', + onTap: () => launchUrl(Uri.parse('tel:${user.phone}')), + ), + if (user.phone != null) const SizedBox(width: 10), + _ActionBtn( + icon: Icons.chat_bubble_outline_rounded, + label: 'Chat', + onTap: () { + if (service.id != null) { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => ChatScreen(service: service))); } }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: const Color(0xFF2BA4EC), - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric( - vertical: 15, - horizontal: 0, - ), - child: Icon( - Icons.near_me, - size: 25, - color: Colors.white, - ), - ), ), - const SizedBox(width: 15), - SizedBox( - width: MediaQuery.of(context).size.width * 0.6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - service.address, - maxLines: 3, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: 15, - //center - ), - ), - service.aditionalAddress.isEmpty - ? Container() - : Text( - service.aditionalAddress, - textAlign: TextAlign.start, - maxLines: 3, - style: const TextStyle( - fontSize: 15, - color: Colors.black54, - ), - ), - ], + if (user.phone != null) const SizedBox(width: 10), + if (user.phone != null) + _ActionBtn( + icon: CommunityMaterialIcons.whatsapp, + label: 'WhatsApp', + onTap: () async { + final url = + 'https://wa.me/${user.phone}?text=${Uri.encodeFull('Hola! me contactaste por Prossapp')}'; + if (!await launchUrl(Uri.parse(url))) { + throw Exception('No se pudo abrir WhatsApp'); + } + }, ), - ), ], ), ); } - return const SizedBox(); } - Widget customActionButtons(ServiceEntity service, MyUser user) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); - - if (service.status == ServiceStatus.acepted || - service.status == ServiceStatus.active) { - if (now.isAfter(serviceDateTime)) { - return const SizedBox(); - } else { - return Wrap( - alignment: WrapAlignment.center, - spacing: 15, - children: [ - Visibility( - visible: user.phone == null ? false : true, - child: ElevatedButton( - onPressed: () => launch("tel:${user.phone}"), - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - Icons.phone_android, - size: 30, - color: Color(0xFF2BA4EC), - ), - ), - ), - ), - ElevatedButton( - onPressed: () { - if (service.id != null) { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ChatScreen(service: service), - ), - ); - } - }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - Icons.message, - size: 30, - color: Color(0xFF2BA4EC), - ), - ), - ), - Visibility( - visible: user.phone == null ? false : true, - child: ElevatedButton( - onPressed: () async { - final whatsappUrl = - 'https://wa.me/${user.phone}?text=${Uri.parse('Hola! me contactaste por Prossapp')}'; - if (!await launch(whatsappUrl)) { - throw Exception('Could not launch $whatsappUrl'); - } - }, - style: ElevatedButton.styleFrom( - foregroundColor: const Color(0xFF2BA4EC), - backgroundColor: Colors.white, - shape: const CircleBorder( - side: BorderSide( - color: Color(0xFF2BA4EC), - width: 2, - ), - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 0), - child: Icon( - CommunityMaterialIcons.whatsapp, - size: 30, - color: Color(0xFF2BA4EC), - ), - ), - ), - ), - ], - ); - } - } - - return const SizedBox(); - } - - Widget customMessageStatus(ServiceEntity service) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); + Widget _statusBanner(BuildContext context, ServiceEntity service) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); if (now.isAfter(serviceDateTime)) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Caducado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - Text( - 'Tu servicio ha excedido \n el tiempo de espera', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, color: Colors.red), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: - const Icon(Icons.close_rounded, color: Colors.red, size: 48), - ), - ) - ], + return _StatusCard( + color: const Color(0xFFDC2626), + icon: Icons.timer_off_outlined, + title: 'Caducado', + subtitle: 'El tiempo del servicio ha expirado', ); - } else { - if (service.status == ServiceStatus.cancelled) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Text( - 'Cancelado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close_rounded, - color: Colors.red, size: 48), - ), - ) - ], - ); - } - if (service.status == ServiceStatus.denied) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.red.shade100, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Text( - 'Rechazado', - style: TextStyle(fontSize: 32, color: Colors.red), - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.red.shade100, width: 4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close_rounded, - color: Colors.red, size: 48), - ), - ) - ], - ); - } - - if (service.status == ServiceStatus.completed && - service.userScored == false) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.green.shade50, - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - children: [ - const Text( - 'Completado', - style: TextStyle(fontSize: 32, color: Colors.green), - ), - const Text( - 'Califica el servicio', - style: TextStyle(fontSize: 20, color: Colors.green), - ), - const SizedBox(height: 15), - FilledButton( - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ScoreScreen( - service: service, - ), - ), - ); - }, - style: FilledButton.styleFrom( - backgroundColor: Colors.green, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Container( - alignment: Alignment.center, - width: MediaQuery.of(context).size.width * 0.4, - child: const Text( - 'Calificar', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), - ), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.green.shade50, width: 4), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.star, - color: Colors.green, - size: 48, - ), - ), - ) - ], - ); - } - - if (service.status == ServiceStatus.completed && - service.userScored == true) { - return Stack( - alignment: AlignmentDirectional.topCenter, - clipBehavior: Clip.none, - children: [ - Card( - color: Colors.green.shade50, - child: const Padding( - padding: EdgeInsets.fromLTRB(32, 56, 32, 32), - child: Column( - children: [ - Text( - 'Completado', - style: TextStyle(fontSize: 32, color: Colors.green), - ), - ], - ), - ), - ), - Positioned( - top: -40, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.green.shade50, width: 4), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.star, - color: Colors.green, - size: 48, - ), - ), - ) - ], - ); - } } - return const SizedBox(); + switch (service.status) { + case ServiceStatus.cancelled: + return _StatusCard( + color: const Color(0xFF9CA3AF), + icon: Icons.remove_circle_outline, + title: 'Cancelado', + subtitle: 'El servicio fue cancelado', + ); + case ServiceStatus.denied: + return _StatusCard( + color: const Color(0xFFDC2626), + icon: Icons.cancel_outlined, + title: 'Rechazado', + subtitle: 'El servicio fue rechazado', + ); + case ServiceStatus.completed: + if (service.userScored == false) { + return _StatusCard( + color: const Color(0xFF16A34A), + icon: Icons.star_outline_rounded, + title: 'Completado', + subtitle: '¡Califica el servicio!', + action: _ScoreButton(service: service), + ); + } + return _StatusCard( + color: const Color(0xFF16A34A), + icon: Icons.task_alt_outlined, + title: 'Completado', + subtitle: '¡Servicio finalizado exitosamente!', + ); + default: + return const SizedBox(); + } } - Widget customButton( - ServiceEntity service, BuildContext context, MyUser userInfo) { - DateTime serviceDate = DateTime.parse(service.day); - DateTime now = DateTime.now(); - DateTime serviceDateTime = DateTime( - serviceDate.year, - serviceDate.month, - serviceDate.day, - service.range1Hour2.hour, - service.range1Hour2.minute, - ); + Widget _bottomButton( + BuildContext context, ServiceEntity service, MyUser userInfo) { + final serviceDate = DateTime.parse(service.day); + final now = DateTime.now(); + final serviceDateTime = DateTime(serviceDate.year, serviceDate.month, + serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute); if (now.isAfter(serviceDateTime)) { return GeneralSecondaryButton( - label: 'Volver', + label: 'Volver', onPressed: () => Navigator.pop(context)); + } + + if (service.status == ServiceStatus.pending) { + return GeneralSecondaryButton( + label: 'Cancelar servicio', + color: Theme.of(context).colorScheme.error, onPressed: () { - Navigator.pop(context); + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add( + UpdateServiceStatus(widget.serviceId, ServiceStatus.cancelled)); + } + if (userInfo.token != null) { + LocalNotifications.sendPushNotification( + userInfo.token!, + 'Servicio cancelado', + 'Servicio cancelado por ${userInfo.name}', + ); + } }, ); - } else { - if (service.status == ServiceStatus.pending) { + } + + if (service.status == ServiceStatus.acepted) { + if (serviceDateTime.difference(now).inDays > 1) { return GeneralSecondaryButton( label: 'Cancelar servicio', color: Theme.of(context).colorScheme.error, onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { + final s = context.read().state; + if (s is ServiceLoaded) { context.read().add(UpdateServiceStatus( widget.serviceId, ServiceStatus.cancelled)); } - if (userInfo.token != null) { LocalNotifications.sendPushNotification( userInfo.token!, @@ -920,50 +546,23 @@ class _UserServiceScreenState extends State { }, ); } + } - if (service.status == ServiceStatus.acepted) { - if (serviceDateTime.difference(now).inDays > 1) { - return GeneralSecondaryButton( - label: 'Cancelar servicio', - color: Theme.of(context).colorScheme.error, - onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.cancelled)); - } - - if (userInfo.token != null) { - LocalNotifications.sendPushNotification( - userInfo.token!, - 'Servicio cancelado', - 'Servicio cancelado por ${userInfo.name}', - ); - } - }, - ); - } - } - - if (service.status == ServiceStatus.active) { - return GeneralSecondaryButton( - label: 'Terminar servicio', - onPressed: () { - final currentState = context.read().state; - if (currentState is ServiceLoaded) { - context.read().add(UpdateServiceStatus( - widget.serviceId, ServiceStatus.completed)); - } - }); - } + if (service.status == ServiceStatus.active) { + return GeneralSecondaryButton( + label: 'Terminar servicio', + onPressed: () { + final s = context.read().state; + if (s is ServiceLoaded) { + context.read().add(UpdateServiceStatus( + widget.serviceId, ServiceStatus.completed)); + } + }, + ); } return GeneralSecondaryButton( - label: 'Volver', - onPressed: () { - Navigator.pop(context); - }, - ); + label: 'Volver', onPressed: () => Navigator.pop(context)); } String formatCurrency(int number) { @@ -975,12 +574,149 @@ class _UserServiceScreenState extends State { Future> _getUserAndProfessionalInfo( ServiceEntity service) async { final userRepo = Injector.appInstance.get(); - final professionalRepo = Injector.appInstance.get(); - + final professionalRepo = + Injector.appInstance.get(); final userInfo = await userRepo.getMyUser(service.professionalId); final professionalInfo = await professionalRepo.getProInfo(service.professionalId); - return [userInfo, professionalInfo]; } } + +// ── shared widgets ────────────────────────────────────────────────────────── + +class _ActionBtn extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _ActionBtn( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onTap, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: _kPrimary, + side: BorderSide(color: _kPrimary.withOpacity(0.5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ); + } +} + +class _PayBadge extends StatelessWidget { + final String label; + const _PayBadge(this.label); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _kPrimary.withOpacity(0.25)), + ), + child: Text(label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _kPrimary)), + ); + } +} + +class _StatusCard extends StatelessWidget { + final Color color; + final IconData icon; + final String title; + final String subtitle; + final Widget? action; + const _StatusCard({ + required this.color, + required this.icon, + required this.title, + required this.subtitle, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withOpacity(0.15), shape: BoxShape.circle), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: color)), + Text(subtitle, + style: TextStyle( + fontSize: 12, color: color.withOpacity(0.75))), + ], + ), + ), + ], + ), + if (action != null) ...[ + const SizedBox(height: 12), + action!, + ], + ], + ), + ); + } +} + +class _ScoreButton extends StatelessWidget { + final ServiceEntity service; + const _ScoreButton({required this.service}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.push( + context, + CupertinoPageRoute(builder: (_) => ScoreScreen(service: service)), + ), + icon: const Icon(Icons.star_outline_rounded, size: 18), + label: const Text('Calificar servicio'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ); + } +} diff --git a/lib/screens/suggestions/suggestion_screen.dart b/lib/screens/suggestions/suggestion_screen.dart new file mode 100644 index 0000000..40cb707 --- /dev/null +++ b/lib/screens/suggestions/suggestion_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injector/injector.dart'; +import 'package:prosappco/blocs/suggestion_bloc/suggestion_bloc.dart'; + +const _kPrimary = Color(0xFF1565C0); + +class SuggestionScreen extends StatefulWidget { + const SuggestionScreen({super.key}); + + @override + State createState() => _SuggestionScreenState(); +} + +class _SuggestionScreenState extends State { + final _formKey = GlobalKey(); + final _messageController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Injector.appInstance.get(), + child: BlocListener( + listener: (context, state) { + if (state is SuggestionLoading) { + setState(() => _isLoading = true); + } else if (state is SuggestionSuccess) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('¡Gracias por tu sugerencia!')), + ); + Navigator.of(context).pop(); + } else if (state is SuggestionFailure) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No se pudo enviar la sugerencia'), + ), + ); + } + }, + child: Scaffold( + appBar: AppBar(title: const Text('Sugerencias')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '¿Tienes una idea o algo que podamos mejorar? ' + 'Cuéntanos.', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 16), + TextFormField( + controller: _messageController, + minLines: 5, + maxLines: 8, + maxLength: 1000, + decoration: InputDecoration( + hintText: 'Escribe tu sugerencia aquí...', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: _kPrimary, width: 2), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Escribe un mensaje' + : null, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: _isLoading + ? null + : () { + if (_formKey.currentState?.validate() != true) { + return; + } + context.read().add( + SubmitSuggestion( + message: _messageController.text.trim(), + ), + ); + }, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Enviar'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/user/user_calendar_screen.dart b/lib/screens/user/user_calendar_screen.dart index c8c82d8..b63fba4 100644 --- a/lib/screens/user/user_calendar_screen.dart +++ b/lib/screens/user/user_calendar_screen.dart @@ -195,6 +195,7 @@ class UserCalendarScreenState extends State { List ranges = TimeOfDayUtils.genRanges( schedule.range1Hour1!, schedule.range2Hour2!, + stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, ); return rangesItemList(ranges, _services, today); @@ -215,10 +216,12 @@ class UserCalendarScreenState extends State { List ranges1 = TimeOfDayUtils.genRanges( schedule.range1Hour1!, schedule.range1Hour2!, + stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, ); List ranges2 = TimeOfDayUtils.genRanges( schedule.range2Hour1!, schedule.range2Hour2!, + stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, ); return [ diff --git a/lib/screens/user/user_map_screen.dart b/lib/screens/user/user_map_screen.dart index 23b3a30..9915b57 100644 --- a/lib/screens/user/user_map_screen.dart +++ b/lib/screens/user/user_map_screen.dart @@ -19,8 +19,11 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/constansts.dart'; import 'package:prosappco/local_notifications/local_notifications.dart'; import 'package:prosappco/screens/lists/professional_list_screen.dart'; +import 'package:prosappco/screens/profile/profile_screen.dart'; import 'package:prosappco/screens/user/user_service_screen.dart'; +import 'package:prosappco/utils/nominatim_geocoder.dart'; import 'package:prosappco/utils/time_of_day_extension.dart'; +import 'package:prosappco/utils/version_utils.dart'; import 'package:service_repository/service_repository.dart'; import 'package:setting_repository/setting_repository.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -42,7 +45,7 @@ class _UserMapScreenState extends State { final settingRepository = Injector.appInstance.get(); SettingEntity? settings; - late String appVersion; + String appVersion = ''; final DateFormat formatter = DateFormat('dd/MM/yyyy'); @@ -125,7 +128,7 @@ class _UserMapScreenState extends State { void _checkForUpdate(SettingEntity settings) { if (Platform.isAndroid) { - if (appVersion != settings.versionAndroid) { + if (isUpdateRequired(appVersion, settings.versionAndroid)) { showDialog( context: context, builder: (context) { @@ -165,7 +168,7 @@ class _UserMapScreenState extends State { } } if (Platform.isIOS) { - if (appVersion != settings.versionIos) { + if (isUpdateRequired(appVersion, settings.versionIos)) { showDialog( context: context, builder: (context) { @@ -377,9 +380,16 @@ class _UserMapScreenState extends State { state.user?.phone == null) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Por favor completa tu perfil y agrega tu celular'), + SnackBar( + content: const Text('Por favor completa tu perfil y agrega tu celular'), + action: SnackBarAction( + label: 'Ir al perfil', + onPressed: () { + Navigator.push(context, CupertinoPageRoute( + builder: (context) => const ProfileScreen(), + )); + }, + ), ), ); return; @@ -552,7 +562,10 @@ class _UserMapScreenState extends State { description: _observationController.text, range1Hour1: horaSeleccionada!, range1Hour2: - horaSeleccionada!.add(hour: 2), + horaSeleccionada!.add( + minute: profesionalSeleccionado! + .professionalInfo + .slotDurationMinutes), rate: profesionalSeleccionado! .professionalInfo.rate, location: serviceLocationPreference!, @@ -577,7 +590,10 @@ class _UserMapScreenState extends State { description: _observationController.text, range1Hour1: horaSeleccionada!, range1Hour2: - horaSeleccionada!.add(hour: 2), + horaSeleccionada!.add( + minute: profesionalSeleccionado! + .professionalInfo + .slotDurationMinutes), rate: '0', location: serviceLocationPreference!, ), @@ -600,7 +616,10 @@ class _UserMapScreenState extends State { description: _observationController.text, range1Hour1: horaSeleccionada!, range1Hour2: - horaSeleccionada!.add(hour: 2), + horaSeleccionada!.add( + minute: profesionalSeleccionado! + .professionalInfo + .slotDurationMinutes), rate: profesionalSeleccionado! .professionalInfo.rate, location: serviceLocationPreference!, @@ -620,7 +639,10 @@ class _UserMapScreenState extends State { description: _observationController.text, range1Hour1: horaSeleccionada!, range1Hour2: - horaSeleccionada!.add(hour: 2), + horaSeleccionada!.add( + minute: profesionalSeleccionado! + .professionalInfo + .slotDurationMinutes), rate: '0', location: serviceLocationPreference!, ), @@ -745,6 +767,25 @@ class _UserMapScreenState extends State { ); }); + _animateCameraToPosition(_currentP!); + } catch (e) { + log(e.toString()); + await _locateByUserCity(); + } + } + + Future _locateByUserCity() async { + try { + final city = Injector.appInstance.get().state.user?.city; + if (city == null || city.isEmpty) return; + + final coords = await NominatimGeocoder.forwardGeocodeCity(city); + if (coords == null) return; + + setState(() { + _currentP = LatLng(coords.$1, coords.$2); + }); + _animateCameraToPosition(_currentP!); } catch (e) { log(e.toString()); diff --git a/lib/screens/user/user_view_profile_screen.dart b/lib/screens/user/user_view_profile_screen.dart index 101cf87..471aa30 100644 --- a/lib/screens/user/user_view_profile_screen.dart +++ b/lib/screens/user/user_view_profile_screen.dart @@ -1,204 +1,279 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart'; -class UserViewProfileScreen extends StatelessWidget { - UserProfessional userProfessional; +const _kPrimary = Color(0xFF1565C0); - UserViewProfileScreen({ +extension _Th on BuildContext { + ThemeData get _t => Theme.of(this); + Color get bg => _t.scaffoldBackgroundColor; + Color get card => _t.cardColor; + Color get onSurface => _t.colorScheme.onSurface; + Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); + Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35); + bool get isDark => _t.brightness == Brightness.dark; + Color get shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} + +class UserViewProfileScreen extends StatelessWidget { + final UserProfessional userProfessional; + + const UserViewProfileScreen({ super.key, required this.userProfessional, }); - final double bannerHeight = 200; - final double profileHeight = 160; - @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Perfil Profesional'), - ), - body: ListView( - padding: EdgeInsets.zero, - children: [ - buildTop(), - buildContent(), + backgroundColor: context.bg, + body: CustomScrollView( + slivers: [ + _SliverHeader(userProfessional: userProfessional), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _nameSection(context), + const SizedBox(height: 16), + if (userProfessional + .professionalInfo.specializations.isNotEmpty) + _sectionCard( + context, + icon: Icons.stars_outlined, + title: 'Especialidades', + child: Wrap( + spacing: 8, + runSpacing: 8, + children: userProfessional + .professionalInfo.specializations + .map((s) => _Chip(label: s)) + .toList(), + ), + ), + if (userProfessional.professionalInfo.specializations.isNotEmpty) + const SizedBox(height: 12), + if (userProfessional.professionalInfo.rate.isNotEmpty) + _sectionCard( + context, + icon: Icons.payments_outlined, + title: 'Tarifa de consulta', + child: Row( + children: [ + Text( + _formatCurrency( + int.tryParse(userProfessional + .professionalInfo.rate) ?? + 0), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: context.onSurface), + ), + const SizedBox(width: 6), + Text('por consulta', + style: TextStyle( + fontSize: 12, color: context.muted)), + ], + ), + ), + if (userProfessional.professionalInfo.rate.isNotEmpty) + const SizedBox(height: 12), + if (userProfessional.professionalInfo.paymentMethods.datafono || + userProfessional.professionalInfo.paymentMethods.nequi || + userProfessional + .professionalInfo.paymentMethods.transferencia) + _sectionCard( + context, + icon: Icons.credit_card_outlined, + title: 'Métodos de pago', + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (userProfessional + .professionalInfo.paymentMethods.datafono) + _Chip(label: 'Datafono', icon: Icons.point_of_sale_outlined), + if (userProfessional + .professionalInfo.paymentMethods.nequi) + _Chip(label: 'Nequi', icon: Icons.phone_android_outlined), + if (userProfessional + .professionalInfo.paymentMethods.transferencia) + _Chip( + label: 'Transferencia', + icon: Icons.account_balance_outlined), + ], + ), + ), + ]), + ), + ), ], ), ); } - Stack buildTop() { - final top = bannerHeight - profileHeight / 2; - final bottom = profileHeight / 2; - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Container( - margin: EdgeInsets.only(bottom: bottom), - child: buildBannerImage(), - ), - Positioned( - top: top, - child: buildProfileImage(), - ), - ], - ); - } - - buildContent() { + Widget _nameSection(BuildContext context) { return Column( children: [ Text( userProfessional.myUser.name ?? '', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: context.onSurface), ), + const SizedBox(height: 4), Text( - '${userProfessional.professionalInfo.profession}, ${userProfessional.myUser.city ?? ''}', + '${userProfessional.professionalInfo.profession} · ${userProfessional.myUser.city ?? ''}', + textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 18, color: Colors.black54), + style: TextStyle(fontSize: 14, color: context.muted), ), - const Divider(), - const Text( - 'Especialidades:', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - userProfessional.professionalInfo.specializations.isEmpty - ? const Text( - 'No posee especialidades', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, color: Colors.black45), - ) - : Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: userProfessional.professionalInfo.specializations - .map((specialization) { - return Chip( - label: Text(specialization), - ); - }).toList(), - ), - Visibility( - visible: userProfessional.professionalInfo.rate.isNotEmpty, - child: const Divider(), - ), - Visibility( - visible: userProfessional.professionalInfo.rate.isNotEmpty, - child: const Text( - 'Tarifa:', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - ), - Visibility( - visible: userProfessional.professionalInfo.rate.isNotEmpty, - child: Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - Chip(label: Text('\$${userProfessional.professionalInfo.rate}')), - ], - ), - ), - const Divider(), - const Text( - 'Metodos de pago recibidos:', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - userProfessional.professionalInfo.paymentMethods.datafono || - userProfessional.professionalInfo.paymentMethods.nequi || - userProfessional.professionalInfo.paymentMethods.transferencia - ? Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - Visibility( - visible: userProfessional - .professionalInfo.paymentMethods.datafono, - child: const Chip(label: Text('Datafono')), - ), - Visibility( - visible: - userProfessional.professionalInfo.paymentMethods.nequi, - child: const Chip(label: Text('Nequi')), - ), - Visibility( - visible: userProfessional - .professionalInfo.paymentMethods.transferencia, - child: const Chip(label: Text('Transferencia Bancaria')), - ), - ], - ) - : const Text( - 'No hay metodos de pago registrados', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, color: Colors.black45), - ), - const Divider(), ], ); } - Container buildProfileImage() { + Widget _sectionCard(BuildContext context, + {required IconData icon, + required String title, + required Widget child}) { return Container( - width: 155, - height: 155, + width: double.infinity, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 5, - ), + color: context.card, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: context.shadowSm, blurRadius: 10) + ], ), - child: Container( - width: 150, - height: 150, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - border: Border.all(width: 0), - image: userProfessional.myUser.picture == null - ? null - : DecorationImage( - image: NetworkImage(userProfessional.myUser.picture ?? ''), - fit: BoxFit.contain, - ), - ), - child: userProfessional.myUser.picture == null - ? Icon( - CupertinoIcons.person, - color: Colors.grey.shade400, - size: 40, - ) - : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: _kPrimary), + const SizedBox(width: 7), + Text(title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _kPrimary)), + ], + ), + const SizedBox(height: 10), + child, + ], ), ); } - Container buildBannerImage() { - return Container( - color: Colors.grey, - child: Image.network( - userProfessional.professionalInfo.bannerPicture, - fit: BoxFit.cover, - width: double.infinity, - height: bannerHeight, + String _formatCurrency(int n) { + final formatter = + NumberFormat.currency(locale: 'es_CO', decimalDigits: 0, symbol: ''); + return '\$${formatter.format(n)}'; + } +} + +class _SliverHeader extends StatelessWidget { + final UserProfessional userProfessional; + const _SliverHeader({required this.userProfessional}); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + expandedHeight: 220, + pinned: true, + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.parallax, + background: Stack( + fit: StackFit.expand, + children: [ + // Banner + Image.network( + userProfessional.professionalInfo.bannerPicture, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(color: _kPrimary.withOpacity(0.6)), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.45), + ], + ), + ), + ), + // Avatar centered at bottom + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Center( + child: Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + color: Colors.grey.shade300, + image: userProfessional.myUser.picture == null + ? null + : DecorationImage( + image: NetworkImage( + userProfessional.myUser.picture!), + fit: BoxFit.cover), + ), + child: userProfessional.myUser.picture == null + ? Icon(CupertinoIcons.person, + color: Colors.grey.shade400, size: 40) + : null, + ), + ), + ), + ], + ), ), ); } } + +class _Chip extends StatelessWidget { + final String label; + final IconData? icon; + const _Chip({required this.label, this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.07), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _kPrimary.withOpacity(0.2)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: _kPrimary), + const SizedBox(width: 4), + ], + Text(label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _kPrimary)), + ]), + ); + } +} diff --git a/lib/utils/nominatim_geocoder.dart b/lib/utils/nominatim_geocoder.dart new file mode 100644 index 0000000..94e6be5 --- /dev/null +++ b/lib/utils/nominatim_geocoder.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class NominatimGeocoder { + static Future reverseGeocodeCity(double lat, double lng) async { + final uri = Uri.parse( + 'https://nominatim.openstreetmap.org/reverse' + '?format=json&lat=$lat&lon=$lng&zoom=10&addressdetails=1', + ); + final res = await http.get( + uri, + headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'}, + ); + final data = jsonDecode(res.body) as Map; + final address = data['address'] as Map?; + return address?['city'] as String? ?? + address?['town'] as String? ?? + address?['municipality'] as String? ?? + address?['county'] as String?; + } + + static Future<(double lat, double lng)?> forwardGeocodeCity( + String cityName) async { + final uri = Uri.parse( + 'https://nominatim.openstreetmap.org/search' + '?format=json&limit=1&q=${Uri.encodeQueryComponent(cityName)}', + ); + final res = await http.get( + uri, + headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'}, + ); + final data = jsonDecode(res.body) as List; + if (data.isEmpty) return null; + final first = data.first as Map; + final lat = double.tryParse(first['lat']?.toString() ?? ''); + final lng = double.tryParse(first['lon']?.toString() ?? ''); + if (lat == null || lng == null) return null; + return (lat, lng); + } +} diff --git a/lib/utils/time_of_day_extension.dart b/lib/utils/time_of_day_extension.dart index a631b51..edb5cac 100644 --- a/lib/utils/time_of_day_extension.dart +++ b/lib/utils/time_of_day_extension.dart @@ -1,8 +1,15 @@ import 'package:flutter/material.dart'; extension TimeOfDayExtension on TimeOfDay { + /// Adds the given offset, carrying minutes into hours. + /// + /// Without the carry, adding a minute-based step (e.g. 45) would leave the + /// hour untouched and produce values like "8:90", which makes [isBefore] + /// loops run forever. The hour is intentionally NOT wrapped at 24 so that + /// comparisons against an end-of-day bound still terminate. TimeOfDay add({int hour = 0, int minute = 0}) { - return replacing(hour: this.hour + hour, minute: this.minute + minute); + final totalMinutes = (this.hour + hour) * 60 + this.minute + minute; + return TimeOfDay(hour: totalMinutes ~/ 60, minute: totalMinutes % 60); } int compareTo(TimeOfDay other) { diff --git a/lib/utils/time_of_day_utils.dart b/lib/utils/time_of_day_utils.dart index cca94b1..e62f6dd 100644 --- a/lib/utils/time_of_day_utils.dart +++ b/lib/utils/time_of_day_utils.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import 'package:prosappco/utils/time_of_day_extension.dart'; class TimeOfDayUtils { - static List genRanges(TimeOfDay timeStart, TimeOfDay timeEnd) { + static List genRanges(TimeOfDay timeStart, TimeOfDay timeEnd, + {int stepMinutes = 30}) { + final step = stepMinutes.clamp(5, 480); List ranges = []; TimeOfDay current = timeStart; while (current.isBefore(timeEnd)) { ranges.add(current); - // Sumar 2 horas al objeto DateTime - current = current.add(hour: 2); + current = current.add(minute: step); } return ranges; } diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart new file mode 100644 index 0000000..983bde4 --- /dev/null +++ b/lib/utils/version_utils.dart @@ -0,0 +1,48 @@ +/// Compares dotted numeric versions, e.g. "1.0.14" vs "1.0.2". +/// +/// Returns a negative number when [a] is older than [b], 0 when they are +/// equivalent, and a positive number when [a] is newer. Missing segments count +/// as 0, so "1.0" and "1.0.0" are equivalent. Returns null when either side +/// cannot be parsed. +int? compareVersions(String? a, String? b) { + final left = _segments(a); + final right = _segments(b); + if (left == null || right == null) return null; + + final length = left.length > right.length ? left.length : right.length; + for (var i = 0; i < length; i++) { + final l = i < left.length ? left[i] : 0; + final r = i < right.length ? right[i] : 0; + if (l != r) return l - r; + } + return 0; +} + +/// Whether [current] is strictly older than [minimum]. +/// +/// Deliberately fails open: a missing or unparseable value returns false, so a +/// backend misconfiguration can never lock users out of the app behind the +/// blocking "you must update" dialog. Equal or newer versions are fine too — a +/// build ahead of the configured minimum is not out of date. +bool isUpdateRequired(String? current, String? minimum) { + final result = compareVersions(current, minimum); + if (result == null) return false; + return result < 0; +} + +List? _segments(String? version) { + if (version == null) return null; + final trimmed = version.trim(); + if (trimmed.isEmpty) return null; + + // Tolerate build suffixes such as "1.0.14+14" or "1.0.14-beta". + final core = trimmed.split(RegExp(r'[+\-]')).first; + final parts = core.split('.'); + final segments = []; + for (final part in parts) { + final value = int.tryParse(part.trim()); + if (value == null) return null; + segments.add(value); + } + return segments.isEmpty ? null : segments; +} diff --git a/packages/chat_repository/lib/src/repositories/api_chat_repository.dart b/packages/chat_repository/lib/src/repositories/api_chat_repository.dart index e21e8ae..9641b38 100644 --- a/packages/chat_repository/lib/src/repositories/api_chat_repository.dart +++ b/packages/chat_repository/lib/src/repositories/api_chat_repository.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chat_repository/chat_repository.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; /// API-backed replacement for FirebaseChatRepository. @@ -57,7 +59,7 @@ class ApiChatRepository { final res = await http.post( Uri.parse('$_base/chat/start/$professionalId'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final data = jsonDecode(res.body) as Map; return _chatFromApi(data); } @@ -81,7 +83,7 @@ class ApiChatRepository { final res = await http.get( Uri.parse('$_base/chat/$chatId/messages'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); if (res.statusCode == 404) return null; final messages = jsonDecode(res.body) as List? ?? []; return ChatEntity( @@ -102,7 +104,7 @@ class ApiChatRepository { Uri.parse('$_base/chat/$chatId/message'), headers: await _headers(), body: jsonEncode({'content': message.content}), - ); + ).timeout(_kHttpTimeout); } /// Get all chats for the current user. @@ -111,7 +113,7 @@ class ApiChatRepository { final res = await http.get( Uri.parse('$_base/chat/my'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final data = jsonDecode(res.body) as List? ?? []; return data.map((e) => _chatFromApi(e as Map)).toList(); } catch (_) { diff --git a/packages/chat_repository/pubspec.lock b/packages/chat_repository/pubspec.lock index 4073c3d..4584669 100644 --- a/packages/chat_repository/pubspec.lock +++ b/packages/chat_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,30 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: + ffi: dependency: transitive description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "5.0.0" - firebase_core_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -136,30 +96,38 @@ packages: description: flutter source: sdk version: "0.0.0" - js: - dependency: transitive + http: + dependency: "direct main" description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -188,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -208,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -216,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -265,10 +321,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -281,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/city_repository/lib/src/repositories/api_city_repository.dart b/packages/city_repository/lib/src/repositories/api_city_repository.dart index fc50d11..2a7377e 100644 --- a/packages/city_repository/lib/src/repositories/api_city_repository.dart +++ b/packages/city_repository/lib/src/repositories/api_city_repository.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:city_repository/city_repository.dart'; import 'city_repo.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; class ApiCityRepository implements CityRepository { @@ -33,7 +35,7 @@ class ApiCityRepository implements CityRepository { final countriesRes = await http.get( Uri.parse('$_base/locations/countries'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final countries = jsonDecode(countriesRes.body) as List; for (final country in countries) { @@ -43,7 +45,7 @@ class ApiCityRepository implements CityRepository { final regionsRes = await http.get( Uri.parse('$_base/locations/countries/$countryId/regions'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final regions = jsonDecode(regionsRes.body) as List; for (final region in regions) { @@ -53,7 +55,7 @@ class ApiCityRepository implements CityRepository { final citiesRes = await http.get( Uri.parse('$_base/locations/regions/$regionId/cities'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final citiesList = jsonDecode(citiesRes.body) as List; for (final city in citiesList) { diff --git a/packages/city_repository/pubspec.lock b/packages/city_repository/pubspec.lock index 4073c3d..4584669 100644 --- a/packages/city_repository/pubspec.lock +++ b/packages/city_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,30 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: + ffi: dependency: transitive description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "5.0.0" - firebase_core_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -136,30 +96,38 @@ packages: description: flutter source: sdk version: "0.0.0" - js: - dependency: transitive + http: + dependency: "direct main" description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -188,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -208,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -216,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -265,10 +321,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -281,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/profession_repository/lib/src/models/models.dart b/packages/profession_repository/lib/src/models/models.dart index 285be39..ade4887 100644 --- a/packages/profession_repository/lib/src/models/models.dart +++ b/packages/profession_repository/lib/src/models/models.dart @@ -1 +1,2 @@ export 'profession_ui.dart'; +export 'professions.dart'; diff --git a/packages/profession_repository/lib/src/models/professions.dart b/packages/profession_repository/lib/src/models/professions.dart new file mode 100644 index 0000000..560fc63 --- /dev/null +++ b/packages/profession_repository/lib/src/models/professions.dart @@ -0,0 +1,10 @@ +import 'package:equatable/equatable.dart'; + +class Professions extends Equatable { + final List professions; + + const Professions(this.professions); + + @override + List get props => [professions]; +} diff --git a/packages/profession_repository/lib/src/repositories/api_profession_repository.dart b/packages/profession_repository/lib/src/repositories/api_profession_repository.dart index c156080..b1b0353 100644 --- a/packages/profession_repository/lib/src/repositories/api_profession_repository.dart +++ b/packages/profession_repository/lib/src/repositories/api_profession_repository.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:profession_repository/profession_repository.dart'; import 'profession_repo.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; class ApiProfessionRepository implements ProfessionRepository { @@ -27,7 +29,7 @@ class ApiProfessionRepository implements ProfessionRepository { final res = await http.get( Uri.parse('$_base/professions'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final data = jsonDecode(res.body); // Backend returns either a list of profession objects or a map with a professions key if (data is List) { diff --git a/packages/profession_repository/pubspec.lock b/packages/profession_repository/pubspec.lock index 4073c3d..4584669 100644 --- a/packages/profession_repository/pubspec.lock +++ b/packages/profession_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,30 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: + ffi: dependency: transitive description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "5.0.0" - firebase_core_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -136,30 +96,38 @@ packages: description: flutter source: sdk version: "0.0.0" - js: - dependency: transitive + http: + dependency: "direct main" description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -188,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -208,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -216,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -265,10 +321,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -281,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/professional_repository/lib/src/entities/payment_method_entity.dart b/packages/professional_repository/lib/src/entities/payment_method_entity.dart index 7b1706e..e5aa67c 100644 --- a/packages/professional_repository/lib/src/entities/payment_method_entity.dart +++ b/packages/professional_repository/lib/src/entities/payment_method_entity.dart @@ -19,9 +19,9 @@ class PaymentMethodEntity extends Equatable { static PaymentMethodEntity fromDocument(Map doc) { return PaymentMethodEntity( - nequi: doc['nequi'] as bool, - datafono: doc['datafono'] as bool, - transferencia: doc['transferencia'] as bool, + nequi: doc['nequi'] as bool? ?? false, + datafono: doc['datafono'] as bool? ?? false, + transferencia: doc['transferencia'] as bool? ?? false, ); } diff --git a/packages/professional_repository/lib/src/entities/professional_entity.dart b/packages/professional_repository/lib/src/entities/professional_entity.dart index 808e2b3..368fc7a 100644 --- a/packages/professional_repository/lib/src/entities/professional_entity.dart +++ b/packages/professional_repository/lib/src/entities/professional_entity.dart @@ -19,6 +19,7 @@ class ProfessionalEntity extends Equatable { final List specializationsPictures; final Schedules schedules; final PaymentMethodEntity paymentMethods; + final int slotDurationMinutes; const ProfessionalEntity({ required this.id, @@ -38,6 +39,7 @@ class ProfessionalEntity extends Equatable { required this.specializationsPictures, required this.schedules, required this.paymentMethods, + this.slotDurationMinutes = 120, }); static ProfessionalEntity fromDocument(Map doc) { @@ -60,6 +62,8 @@ class ProfessionalEntity extends Equatable { List.from(doc['specializations_pictures']), schedules: Schedules.fromDocument(doc['schedules']), paymentMethods: PaymentMethodEntity.fromDocument(doc['payment_methods']), + slotDurationMinutes: + (doc['slot_duration_minutes'] as num?)?.toInt() ?? 120, ); } @@ -81,6 +85,7 @@ class ProfessionalEntity extends Equatable { List? specializationsPictures, Schedules? schedules, PaymentMethodEntity? paymentMethods, + int? slotDurationMinutes, }) { return ProfessionalEntity( id: id ?? this.id, @@ -102,6 +107,7 @@ class ProfessionalEntity extends Equatable { specializationsPictures ?? this.specializationsPictures, schedules: schedules ?? this.schedules, paymentMethods: paymentMethods ?? this.paymentMethods, + slotDurationMinutes: slotDurationMinutes ?? this.slotDurationMinutes, ); } @@ -124,6 +130,7 @@ class ProfessionalEntity extends Equatable { 'specializations_pictures': specializationsPictures, 'schedules': schedules.toJson(), 'payment_methods': paymentMethods.toDocument(), + 'slot_duration_minutes': slotDurationMinutes, }; } @@ -145,7 +152,8 @@ class ProfessionalEntity extends Equatable { specializations, specializationsPictures, schedules, - paymentMethods + paymentMethods, + slotDurationMinutes, ]; @override diff --git a/packages/professional_repository/lib/src/entities/schedule_entity.dart b/packages/professional_repository/lib/src/entities/schedule_entity.dart index 8026bbf..3cf4175 100644 --- a/packages/professional_repository/lib/src/entities/schedule_entity.dart +++ b/packages/professional_repository/lib/src/entities/schedule_entity.dart @@ -58,11 +58,17 @@ class ScheduleEntity extends Equatable { ); } - static TimeOfDay? _parseTime(String? time) { + static TimeOfDay? _parseTime(String? time) => parseTime(time); + + /// Accepts both "HH:MM" and the ISO8601 the backend returns + /// (e.g. "1970-01-01T08:30:00.000Z"). The time is read as wall clock — + /// no timezone conversion — so 08:00 stays 08:00. + static TimeOfDay? parseTime(String? time) { try { if (time == null) return null; - final components = time.split(':'); - if (components.length != 2) { + final t = time.contains('T') ? time.split('T')[1] : time; + final components = t.split(':'); + if (components.length < 2) { return null; } final hour = int.parse(components[0]); diff --git a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart index a9b6c10..4d34db4 100644 --- a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart +++ b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:professional_repository/professional_repository.dart'; @@ -19,6 +20,8 @@ class ApiProfessionalRepository { String? _token; + static const _timeout = Duration(seconds: 20); + ApiProfessionalRepository() { _isProModeActiveBroadcast.add(isProModeActive); } @@ -38,19 +41,58 @@ class ApiProfessionalRepository { } Future _get(String path) async { - final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); + final res = await http + .get(Uri.parse('$_base$path'), headers: await _headers()) + .timeout(_timeout); return jsonDecode(res.body); } Future _patch(String path, Map body) async { - final res = await http.patch( - Uri.parse('$_base$path'), - headers: await _headers(), - body: jsonEncode(body), - ); + final res = await http + .patch( + Uri.parse('$_base$path'), + headers: await _headers(), + body: jsonEncode(body), + ) + .timeout(_timeout); + if (res.statusCode >= 400) { + throw Exception('No se pudo guardar la información (${res.statusCode})'); + } return jsonDecode(res.body); } + /// Uploads [file] to storage and returns its public URL. + /// Throws when the upload fails, so callers never treat a failure as success. + Future _uploadFile(String file) async { + final token = await _getToken(); + final req = + http.MultipartRequest('POST', Uri.parse('$_base/storage/upload')); + if (token != null) req.headers['Authorization'] = 'Bearer $token'; + req.files.add(await http.MultipartFile.fromPath('file', file)); + final streamed = await req.send().timeout(const Duration(seconds: 60)); + final res = await http.Response.fromStream(streamed); + if (res.statusCode >= 400) { + throw Exception('No se pudo subir el archivo (${res.statusCode})'); + } + final body = jsonDecode(res.body) as Map; + final url = body['url']?.toString(); + if (url == null || url.isEmpty) { + throw Exception('El servidor no devolvió la URL del archivo'); + } + return url; + } + + Future _delete(String path) async { + final res = await http + .delete(Uri.parse('$_base$path'), headers: await _headers()) + .timeout(_timeout); + if (res.statusCode >= 400) { + throw Exception('Error al reiniciar la solicitud (${res.statusCode})'); + } + } + + Future deleteProfessionalInfo() => _delete('/professionals/me'); + ProfessionalEntity? lastProInfo() => _proInfo; Stream streamProInfo() => _proInfoBroadcast.stream; @@ -62,34 +104,97 @@ class ApiProfessionalRepository { _isProModeActiveBroadcast.add(isProModeActive); } + /// The backend sends `location_preferences` as a string ('office' | + /// 'delivery' | 'both'); older payloads used the enum index. + LocationPreferences _locationPrefsFromValue(dynamic v) { + if (v is num) return intToEnum(v.toInt()); + if (v is String) { + switch (v) { + case 'delivery': + return LocationPreferences.delivery; + case 'both': + return LocationPreferences.both; + default: + return LocationPreferences.office; + } + } + return LocationPreferences.office; + } + + /// The backend sends `schedules` as an array of rows keyed by + /// `day_of_week` (0 = Monday … 6 = Sunday), not as a map of day names. + Schedules _schedulesFromApi(dynamic raw) { + if (raw is Map) { + return Schedules.fromDocument(raw as Map); + } + if (raw is! List || raw.isEmpty) return Schedules.empty; + + final byDay = >{}; + for (final s in raw) { + if (s is! Map) continue; + final day = (s['day_of_week'] as num?)?.toInt(); + if (day != null) byDay[day] = s.cast(); + } + + ScheduleEntity entityFor(int day) { + final s = byDay[day]; + if (s == null) return ScheduleEntity.empty; + return ScheduleEntity( + enabled: s['enabled'] as bool? ?? false, + continuousDay: s['continuous_day'] as bool? ?? false, + range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()), + range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()), + range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()), + range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()), + ); + } + + return Schedules( + monday: entityFor(0), + tuesday: entityFor(1), + wednesday: entityFor(2), + thursday: entityFor(3), + friday: entityFor(4), + saturday: entityFor(5), + sunday: entityFor(6), + ); + } + ProfessionalEntity _fromApi(Map json) { return ProfessionalEntity( id: json['user_id']?.toString() ?? json['id']?.toString() ?? '', identification: json['identification']?.toString() ?? '', address: json['address']?.toString() ?? '', - aditionalAddress: json['aditional_address']?.toString() ?? '', + aditionalAddress: (json['additional_address'] ?? json['aditional_address']) + ?.toString() ?? + '', profession: json['profession']?.toString() ?? '', ratePreferences: json['rate_preferences'] as bool? ?? false, rate: json['rate']?.toString() ?? '', - locationPreferences: intToEnum((json['location_preferences'] as num?)?.toInt() ?? 0), + locationPreferences: + _locationPrefsFromValue(json['location_preferences']), bannerPicture: json['banner_picture']?.toString() ?? '', identificationPicture: json['identification_picture']?.toString() ?? '', certificatePicture: json['certificate_picture']?.toString() ?? '', latitude: double.tryParse(json['latitude']?.toString() ?? '0') ?? 0.0, longitude: double.tryParse(json['longitude']?.toString() ?? '0') ?? 0.0, - specializations: json['specializations'] != null - ? List.from(json['specializations']) - : [], - specializationsPictures: json['specializations_pictures'] != null - ? List.from(json['specializations_pictures']) - : [], - schedules: json['schedules'] != null - ? Schedules.fromDocument(json['schedules'] as Map) - : Schedules.empty, + specializations: json['specializations'] is List + ? (json['specializations'] as List) + .map((e) => e.toString()) + .toList() + : [], + specializationsPictures: json['specializations_pictures'] is List + ? (json['specializations_pictures'] as List) + .map((e) => e.toString()) + .toList() + : [], + schedules: _schedulesFromApi(json['schedules']), paymentMethods: json['payment_methods'] != null ? PaymentMethodEntity.fromDocument( json['payment_methods'] as Map) : PaymentMethodEntity.empty, + slotDurationMinutes: + (json['slot_duration_minutes'] as num?)?.toInt() ?? 120, ); } @@ -109,7 +214,9 @@ class ApiProfessionalRepository { final data = await _get('/professionals/$myUserId'); if (data == null) return null; return _fromApi(data as Map); - } catch (_) { + } catch (e) { + // Logged because a silent null here surfaces as an endless spinner. + log('getProInfo($myUserId) failed: $e'); return null; } } @@ -123,8 +230,9 @@ class ApiProfessionalRepository { double latitude, double longitude, Schedules schedules, - PaymentMethodEntity paymentMethods, - ) async { + PaymentMethodEntity paymentMethods, { + int slotDurationMinutes = 120, + }) async { await _patch('/professionals/me', { 'address': address, 'aditional_address': aditionalAddress, @@ -135,6 +243,7 @@ class ApiProfessionalRepository { 'longitude': longitude, 'schedules': schedules.toJson(), 'payment_methods': paymentMethods.toDocument(), + 'slot_duration_minutes': slotDurationMinutes, }); if (_proInfo != null) { @@ -147,51 +256,21 @@ class ApiProfessionalRepository { } Future uploadBannerPicture(String file) async { - final token = await _getToken(); - final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload')); - if (token != null) req.headers['Authorization'] = 'Bearer $token'; - req.files.add(await http.MultipartFile.fromPath('file', file)); - final streamed = await req.send(); - final res = await http.Response.fromStream(streamed); - final body = jsonDecode(res.body) as Map; - final url = body['url'] as String; + final url = await _uploadFile(file); await _patch('/professionals/me', {'banner_picture': url}); } - Future uploadPdfCedula(String file, String userId) async { - final token = await _getToken(); - final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload')); - if (token != null) req.headers['Authorization'] = 'Bearer $token'; - req.files.add(await http.MultipartFile.fromPath('file', file)); - final streamed = await req.send(); - final res = await http.Response.fromStream(streamed); - final body = jsonDecode(res.body) as Map; - return body['url'] as String; - } + Future uploadPdfCedula(String file, String userId) => + _uploadFile(file); - Future uploadPdfCertificado(String file, String userId) async { - final token = await _getToken(); - final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload')); - if (token != null) req.headers['Authorization'] = 'Bearer $token'; - req.files.add(await http.MultipartFile.fromPath('file', file)); - final streamed = await req.send(); - final res = await http.Response.fromStream(streamed); - final body = jsonDecode(res.body) as Map; - return body['url'] as String; - } + Future uploadPdfCertificado(String file, String userId) => + _uploadFile(file); Future> uploadPdfsEspecializaciones( List files, String userId) async { final List urls = []; for (final file in files) { - final token = await _getToken(); - final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload')); - if (token != null) req.headers['Authorization'] = 'Bearer $token'; - req.files.add(await http.MultipartFile.fromPath('file', file)); - final streamed = await req.send(); - final res = await http.Response.fromStream(streamed); - final body = jsonDecode(res.body) as Map; - urls.add(body['url'] as String); + urls.add(await _uploadFile(file)); } return urls; } @@ -206,6 +285,26 @@ class ApiProfessionalRepository { } } + Future> searchProfessionals({ + String? search, + String? city, + double? lat, + double? lng, + }) async { + final params = {}; + if (search != null && search.isNotEmpty) params['search'] = search; + if (city != null && city.isNotEmpty) params['city'] = city; + if (lat != null) params['lat'] = lat.toString(); + if (lng != null) params['lng'] = lng.toString(); + + final uri = Uri.parse('$_base/professionals') + .replace(queryParameters: params.isEmpty ? null : params); + final res = await http.get(uri, headers: await _headers()); + final body = jsonDecode(res.body); + final List raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []); + return raw.map((e) => _fromApi(e as Map)).toList(); + } + Future> getProfessionalsFromIds( Iterable ids) async { final result = []; diff --git a/packages/professional_repository/pubspec.lock b/packages/professional_repository/pubspec.lock index 67cf39b..9fd8ddd 100644 --- a/packages/professional_repository/pubspec.lock +++ b/packages/professional_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,78 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: "17b841e1b000c3441b8ffceca88f468e078d0443db9643e77541bdfb7a3fd16b" - url: "https://pub.dev" - source: hosted - version: "4.17.8" - firebase_auth_platform_interface: + ffi: dependency: transitive description: - name: firebase_auth_platform_interface - sha256: f294ceef40409a36c819a14280ca864fe487b44033e5276443377c66cb448310 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "7.1.8" - firebase_auth_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_auth_web - sha256: "1f231da900fe7ff9f2974f8adcbdb3363c410c24725978afa5dc33e1e7e62e06" + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "5.9.8" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 - url: "https://pub.dev" - source: hosted - version: "5.0.0" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 - url: "https://pub.dev" - source: hosted - version: "2.11.5" - firebase_storage: - dependency: "direct main" - description: - name: firebase_storage - sha256: ce1b0efe8dc111058c5f079b2f2ce84906d030d0fd2eef70c42ca1253c67039a - url: "https://pub.dev" - source: hosted - version: "11.6.9" - firebase_storage_platform_interface: - dependency: transitive - description: - name: firebase_storage_platform_interface - sha256: "4a2b64d4dac096390a0b7f2e7b6d086d0546c4a1bf7ee3fc4b5ae4cc41005c46" - url: "https://pub.dev" - source: hosted - version: "5.1.12" - firebase_storage_web: - dependency: transitive - description: - name: firebase_storage_web - sha256: "4153814db8d59138e816d9f016736e4095c45675a2c18f2868d11ffd8cc6a4ca" - url: "https://pub.dev" - source: hosted - version: "3.7.3" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -185,7 +97,7 @@ packages: source: sdk version: "0.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba @@ -204,34 +116,26 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" + version: "0.19.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -260,18 +164,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -280,6 +184,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -288,6 +224,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + 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 @@ -337,10 +329,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -361,10 +353,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: @@ -373,6 +365,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/score_repository/lib/src/repositories/api_score_repository.dart b/packages/score_repository/lib/src/repositories/api_score_repository.dart index aee5e0f..50975dc 100644 --- a/packages/score_repository/lib/src/repositories/api_score_repository.dart +++ b/packages/score_repository/lib/src/repositories/api_score_repository.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:score_repository/score_repository.dart'; import 'package:score_repository/src/entities/comment_entity.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; class ApiScoreRepository { @@ -43,7 +45,7 @@ class ApiScoreRepository { final res = await http.get( Uri.parse('$_base/comments/reputation/$userId'), headers: await _headers(), - ); + ).timeout(_kHttpTimeout); final data = jsonDecode(res.body) as Map; final rep = ReputationEntity.fromDocument(data); _reputation = rep; @@ -73,7 +75,7 @@ class ApiScoreRepository { } Future> _fetchComments(String path) async { - final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); + final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_kHttpTimeout); final body = jsonDecode(res.body); final List raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []); return raw.map((e) => CommentEntity.fromDocument(e as Map)).toList(); @@ -91,7 +93,7 @@ class ApiScoreRepository { 'score': comment.score, 'is_from_user': comment.isFromUser, }), - ); + ).timeout(_kHttpTimeout); await getReputationByUserId(comment.destinationId); } catch (_) {} } diff --git a/packages/score_repository/pubspec.lock b/packages/score_repository/pubspec.lock index 8af9bf3..8e91e65 100644 --- a/packages/score_repository/pubspec.lock +++ b/packages/score_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,54 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: "17b841e1b000c3441b8ffceca88f468e078d0443db9643e77541bdfb7a3fd16b" - url: "https://pub.dev" - source: hosted - version: "4.17.8" - firebase_auth_platform_interface: + ffi: dependency: transitive description: - name: firebase_auth_platform_interface - sha256: f294ceef40409a36c819a14280ca864fe487b44033e5276443377c66cb448310 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "7.1.8" - firebase_auth_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_auth_web - sha256: "1f231da900fe7ff9f2974f8adcbdb3363c410c24725978afa5dc33e1e7e62e06" + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "5.9.8" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 - url: "https://pub.dev" - source: hosted - version: "5.0.0" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 - url: "https://pub.dev" - source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -160,6 +96,14 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -168,30 +112,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -220,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -240,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -248,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -297,10 +321,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -321,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/service_repository/lib/src/repositories/api_service_repository.dart b/packages/service_repository/lib/src/repositories/api_service_repository.dart index 93b10d8..7be970e 100644 --- a/packages/service_repository/lib/src/repositories/api_service_repository.dart +++ b/packages/service_repository/lib/src/repositories/api_service_repository.dart @@ -5,6 +5,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:service_repository/service_repository.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; // Backend uses string status; Flutter uses int-indexed enum @@ -50,7 +52,7 @@ class ApiServiceRepository { } Future _get(String path) async { - final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); + final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_kHttpTimeout); return jsonDecode(res.body); } @@ -59,7 +61,7 @@ class ApiServiceRepository { Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body), - ); + ).timeout(_kHttpTimeout); return jsonDecode(res.body); } @@ -68,7 +70,7 @@ class ApiServiceRepository { Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body), - ); + ).timeout(_kHttpTimeout); return jsonDecode(res.body); } @@ -149,6 +151,17 @@ class ApiServiceRepository { }); } + Future blockSlot(String day, TimeOfDay hour1) async { + String fmt(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + final data = await _post('/services/block', { + 'day': day, + 'range1_hour1': fmt(hour1), + }); + return data['id']?.toString() ?? ''; + } + Stream getService(String serviceId) { final controller = StreamController(); _get('/services/$serviceId').then((data) { @@ -169,12 +182,11 @@ class ApiServiceRepository { return _stream('/services/professional'); } - Future> getServicesForProfessionalforCalendar(String professionalId) async { - try { - return _parseList(await _get('/services/professional/calendar')); - } catch (_) { - return []; - } + /// Throws on failure: the caller must distinguish "no bookings" from + /// "could not load bookings", otherwise busy slots render as free. + Future> getServicesForProfessionalforCalendar( + String professionalId) async { + return _parseList(await _get('/services/professional/calendar')); } Stream> getServicesHistoryForUser(String userId) { diff --git a/packages/service_repository/pubspec.lock b/packages/service_repository/pubspec.lock index 4073c3d..4584669 100644 --- a/packages/service_repository/pubspec.lock +++ b/packages/service_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,30 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: + ffi: dependency: transitive description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "5.0.0" - firebase_core_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -136,30 +96,38 @@ packages: description: flutter source: sdk version: "0.0.0" - js: - dependency: transitive + http: + dependency: "direct main" description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -188,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -208,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -216,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -265,10 +321,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -281,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/setting_repository/lib/src/entities/entities.dart b/packages/setting_repository/lib/src/entities/entities.dart index 15a67d9..a544441 100644 --- a/packages/setting_repository/lib/src/entities/entities.dart +++ b/packages/setting_repository/lib/src/entities/entities.dart @@ -1 +1,2 @@ export '/src/entities/setting_entity.dart'; +export '/src/entities/policies_entity.dart'; diff --git a/packages/setting_repository/lib/src/entities/policies_entity.dart b/packages/setting_repository/lib/src/entities/policies_entity.dart new file mode 100644 index 0000000..63f9067 --- /dev/null +++ b/packages/setting_repository/lib/src/entities/policies_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class PoliciesEntity extends Equatable { + final String privacy; + final String terms; + + const PoliciesEntity({ + required this.privacy, + required this.terms, + }); + + static PoliciesEntity fromDocument(Map doc) { + return PoliciesEntity( + privacy: doc['privacy'] as String? ?? '', + terms: doc['terms'] as String? ?? '', + ); + } + + @override + List get props => [privacy, terms]; +} diff --git a/packages/setting_repository/lib/src/entities/setting_entity.dart b/packages/setting_repository/lib/src/entities/setting_entity.dart index 12a460d..f840df9 100644 --- a/packages/setting_repository/lib/src/entities/setting_entity.dart +++ b/packages/setting_repository/lib/src/entities/setting_entity.dart @@ -19,6 +19,7 @@ class SettingEntity extends Equatable { final String? terminosCondiciones; final String? terminosCondicionesTitle; final String? terminosCondicionesBody; + final num? rejectionWaitDays; const SettingEntity({ required this.tarifas, @@ -39,6 +40,7 @@ class SettingEntity extends Equatable { required this.terminosCondiciones, required this.terminosCondicionesTitle, required this.terminosCondicionesBody, + this.rejectionWaitDays, }); static SettingEntity fromDocument(Map doc) { @@ -61,6 +63,7 @@ class SettingEntity extends Equatable { terminosCondiciones: doc['terminos_condiciones'] as String?, terminosCondicionesTitle: doc['terminos_condiciones_title'] as String?, terminosCondicionesBody: doc['terminos_condiciones_body'] as String?, + rejectionWaitDays: doc['rejection_wait_days'] as num?, ); } @@ -84,6 +87,7 @@ class SettingEntity extends Equatable { terminosCondiciones, terminosCondicionesTitle, terminosCondicionesBody, + rejectionWaitDays, ]; @override diff --git a/packages/setting_repository/lib/src/repositories/api_setting_repository.dart b/packages/setting_repository/lib/src/repositories/api_setting_repository.dart index 0954c5f..2bf8628 100644 --- a/packages/setting_repository/lib/src/repositories/api_setting_repository.dart +++ b/packages/setting_repository/lib/src/repositories/api_setting_repository.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:setting_repository/src/entities/entities.dart'; import 'package:setting_repository/src/repositories/setting_repo.dart'; +const _kHttpTimeout = Duration(seconds: 20); + const _base = 'https://backend.prosapp.co/api/v1'; class ApiSettingRepository implements SettingRepository { @@ -27,22 +29,29 @@ class ApiSettingRepository implements SettingRepository { final res = await http.get( Uri.parse('$_base/settings'), headers: await _headers(), - ); - final data = jsonDecode(res.body); + ).timeout(_kHttpTimeout); + final data = jsonDecode(res.body) as Map; - // Backend returns {key: string, value: any}[] or a flat map - Map doc = {}; - if (data is List) { - for (final entry in data) { - if (entry is Map) { - final key = entry['key']?.toString(); - final value = entry['value']; - if (key != null) doc[key] = value; - } - } - } else if (data is Map) { - doc = Map.from(data); - } + // Backend returns nested structure: {version: {android, ios}, soporte: {...}, politicas: {...}} + final version = data['version'] as Map? ?? {}; + final soporte = data['soporte'] as Map? ?? {}; + final politicas = data['politicas'] as Map? ?? {}; + + final doc = { + 'version_android': version['android'], + 'version_ios': version['ios'], + 'titulo_soporte': soporte['titulo'] ?? data['support_title'], + 'parrafo_soporte': soporte['descripcion'] ?? + soporte['parrafo'] ?? + data['support_description'], + 'email_soporte': soporte['email'] ?? data['support_email'], + 'numero_soporte': soporte['telefono'] ?? data['support_phone'], + 'dias_soporte': soporte['dias'] ?? data['support_days'], + 'horas_soporte': soporte['horas'] ?? data['support_hours'], + 'politicas_privacidad_title': politicas['titulo'], + 'politicas_privacidad_body': politicas['body'], + 'rejection_wait_days': data['rejection_wait_days'], + }; return SettingEntity.fromDocument(doc); } catch (_) { @@ -69,4 +78,14 @@ class ApiSettingRepository implements SettingRepository { ); } } + + @override + Future getPolicies() async { + final res = await http.get( + Uri.parse('$_base/settings/policies'), + headers: await _headers(), + ).timeout(_kHttpTimeout); + final data = jsonDecode(res.body) as Map; + return PoliciesEntity.fromDocument(data); + } } diff --git a/packages/setting_repository/lib/src/repositories/setting_repo.dart b/packages/setting_repository/lib/src/repositories/setting_repo.dart index 568b4e4..72246cf 100644 --- a/packages/setting_repository/lib/src/repositories/setting_repo.dart +++ b/packages/setting_repository/lib/src/repositories/setting_repo.dart @@ -2,4 +2,5 @@ import 'package:setting_repository/src/entities/entities.dart'; abstract class SettingRepository { Future getSettings(); + Future getPolicies(); } diff --git a/packages/setting_repository/pubspec.lock b/packages/setting_repository/pubspec.lock index 4073c3d..4584669 100644 --- a/packages/setting_repository/pubspec.lock +++ b/packages/setting_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" - url: "https://pub.dev" - source: hosted - version: "1.3.25" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900" - url: "https://pub.dev" - source: hosted - version: "4.15.8" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0 - url: "https://pub.dev" - source: hosted - version: "6.1.9" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16 - url: "https://pub.dev" - source: hosted - version: "3.10.8" collection: dependency: transitive description: @@ -89,30 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" - url: "https://pub.dev" - source: hosted - version: "2.27.0" - firebase_core_platform_interface: + ffi: dependency: transitive description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "5.0.0" - firebase_core_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_core_web - sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -136,30 +96,38 @@ packages: description: flutter source: sdk version: "0.0.0" - js: - dependency: transitive + http: + dependency: "direct main" description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -188,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -208,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -216,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + 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: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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 @@ -265,10 +321,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -281,18 +345,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/suggestion_repository/lib/src/repositories/api_suggestion_repository.dart b/packages/suggestion_repository/lib/src/repositories/api_suggestion_repository.dart new file mode 100644 index 0000000..0472efe --- /dev/null +++ b/packages/suggestion_repository/lib/src/repositories/api_suggestion_repository.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kHttpTimeout = Duration(seconds: 20); + +const _base = 'https://backend.prosapp.co/api/v1'; + +class ApiSuggestionRepository { + String? _token; + + Future _getToken() async { + if (_token != null) return _token; + final prefs = await SharedPreferences.getInstance(); + return _token = prefs.getString('token'); + } + + Future> _headers() async { + final t = await _getToken(); + return { + 'Content-Type': 'application/json', + if (t != null) 'Authorization': 'Bearer $t', + }; + } + + Future createSuggestion(String message) async { + final res = await http.post( + Uri.parse('$_base/suggestions'), + headers: await _headers(), + body: jsonEncode({'message': message}), + ).timeout(_kHttpTimeout); + if (res.statusCode >= 400) { + throw Exception('No se pudo enviar la sugerencia (${res.statusCode})'); + } + } +} diff --git a/packages/suggestion_repository/lib/suggestion_repository.dart b/packages/suggestion_repository/lib/suggestion_repository.dart new file mode 100644 index 0000000..4556cfb --- /dev/null +++ b/packages/suggestion_repository/lib/suggestion_repository.dart @@ -0,0 +1,3 @@ +library suggestion_repository; + +export 'src/repositories/api_suggestion_repository.dart'; diff --git a/packages/suggestion_repository/pubspec.yaml b/packages/suggestion_repository/pubspec.yaml new file mode 100644 index 0000000..1ea2f49 --- /dev/null +++ b/packages/suggestion_repository/pubspec.yaml @@ -0,0 +1,24 @@ +name: suggestion_repository +description: Dart package for the suggestion repository. +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ">=2.19.3 <3.0.0" + +dependencies: + flutter: + sdk: flutter + equatable: ^2.0.5 + http: ^1.1.0 + shared_preferences: ^2.0.10 + + +dev_dependencies: + flutter_lints: ^2.0.0 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/user_repository/lib/src/models/my_user.dart b/packages/user_repository/lib/src/models/my_user.dart index 8088692..a085334 100644 --- a/packages/user_repository/lib/src/models/my_user.dart +++ b/packages/user_repository/lib/src/models/my_user.dart @@ -14,6 +14,7 @@ class MyUser extends Equatable { final ProState proState; final String? token; final bool isPhoneVerified; + final DateTime? rejectedAt; const MyUser({ required this.id, @@ -28,6 +29,7 @@ class MyUser extends Equatable { required this.proState, this.token, this.isPhoneVerified = false, + this.rejectedAt, }); get drawerLabel => email != null && email!.isNotEmpty @@ -66,6 +68,7 @@ class MyUser extends Equatable { ProState? proState, String? token, bool? isPhoneVerified, + DateTime? rejectedAt, }) { return MyUser( id: id ?? this.id, @@ -80,6 +83,7 @@ class MyUser extends Equatable { proState: proState ?? this.proState, token: token ?? this.token, isPhoneVerified: isPhoneVerified ?? this.isPhoneVerified, + rejectedAt: rejectedAt ?? this.rejectedAt, ); } @@ -135,5 +139,6 @@ class MyUser extends Equatable { proState, token, isPhoneVerified, + rejectedAt, ]; } diff --git a/packages/user_repository/lib/src/repositories/api_user_repository.dart b/packages/user_repository/lib/src/repositories/api_user_repository.dart index 5c3284b..c7e33fa 100644 --- a/packages/user_repository/lib/src/repositories/api_user_repository.dart +++ b/packages/user_repository/lib/src/repositories/api_user_repository.dart @@ -57,6 +57,19 @@ class ApiUserRepository implements UserRepository { } MyUser _fromApi(Map json) { + final proState = _proStateFromInt((json['pro_state'] as num?)?.toInt() ?? 0); + + DateTime? rejectedAt; + try { + if (proState == ProState.denied) { + final pro = json['professionals']; + final updatedAt = pro is Map ? pro['updated_at']?.toString() : null; + if (updatedAt != null) rejectedAt = DateTime.tryParse(updatedAt); + } + } catch (_) { + rejectedAt = null; + } + return MyUser( id: json['id']?.toString() ?? '', email: json['email']?.toString(), @@ -67,9 +80,10 @@ class ApiUserRepository implements UserRepository { picture: json['picture']?.toString(), birthday: json['birthday']?.toString(), gender: json['gender']?.toString(), - proState: _proStateFromInt((json['pro_state'] as num?)?.toInt() ?? 0), + proState: proState, token: null, isPhoneVerified: json['is_phone_verified'] as bool? ?? false, + rejectedAt: rejectedAt, ); } diff --git a/packages/user_repository/pubspec.lock b/packages/user_repository/pubspec.lock index e68972b..ccf5ddc 100644 --- a/packages/user_repository/pubspec.lock +++ b/packages/user_repository/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "1a52f1afae8ab7ac4741425114713bdbba802f1ce1e0648e167ffcc6e05e96cf" - url: "https://pub.dev" - source: hosted - version: "1.3.21" async: dependency: transitive description: @@ -41,30 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: b62be7f11ba72fd6112d8921336d670023a6784898e5cf4d21754cc114d8b56c - url: "https://pub.dev" - source: hosted - version: "4.15.4" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: "53f34ec3b6e90537786bfeabc5be1798e03518b5ffbf8acef4d64523f517b010" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: "85367362561333e40d48ce60351b0d9e58f457fad8d06e36ae5d94f1b9b29518" - url: "https://pub.dev" - source: hosted - version: "3.10.4" collection: dependency: transitive description: @@ -89,78 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: "549f8ceb8cfc1920f85dea0ab73fb7dc209ee8182916b252eda342786c33369d" - url: "https://pub.dev" - source: hosted - version: "4.17.4" - firebase_auth_platform_interface: + ffi: dependency: transitive description: - name: firebase_auth_platform_interface - sha256: "83bfc14649f673db17ad0bffaa0222019f99f3ddf499bcc8b46e1eb3443d3e08" + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "7.1.4" - firebase_auth_web: + version: "2.1.3" + file: dependency: transitive description: - name: firebase_auth_web - sha256: d2266452698dd5f6e522408dacfa06bb7f9703b5bdd11498fce2812ded50805b + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "5.9.4" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "7e049e32a9d347616edb39542cf92cd53fdb4a99fb6af0a0bff327c14cd76445" - url: "https://pub.dev" - source: hosted - version: "2.25.4" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 - url: "https://pub.dev" - source: hosted - version: "5.0.0" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: "57e61d6010e253b36d38191cefd6199d7849152cdcd234b61ca290cdb278a0ba" - url: "https://pub.dev" - source: hosted - version: "2.11.4" - firebase_storage: - dependency: "direct main" - description: - name: firebase_storage - sha256: b87029b506972987a827feaf296c21cd0fe1bb69c2595be1672253ba5205573e - url: "https://pub.dev" - source: hosted - version: "11.6.5" - firebase_storage_platform_interface: - dependency: transitive - description: - name: firebase_storage_platform_interface - sha256: "180822103b164d0d597131f2fb658cd1c438148abafc6f2256b565227303ba35" - url: "https://pub.dev" - source: hosted - version: "5.1.8" - firebase_storage_web: - dependency: transitive - description: - name: firebase_storage_web - sha256: "9523c455521b0497ee436be8614aab52f719309d16147a5b11091e44e4c5aa0a" - url: "https://pub.dev" - source: hosted - version: "3.6.22" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -185,7 +97,7 @@ packages: source: sdk version: "0.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba @@ -200,30 +112,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -252,18 +156,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -272,6 +176,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -280,6 +216,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + 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: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + 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 @@ -329,10 +321,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -353,10 +345,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: @@ -365,6 +357,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.lock b/pubspec.lock index 6ea2e99..87cf6dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7" + sha256: "9371d13b8ee442e3bfc08a24e3a1b3742c839abbfaf5eef11b79c4b862c89bf7" url: "https://pub.dev" source: hosted - version: "1.3.35" + version: "1.3.41" animate_do: dependency: "direct main" description: @@ -95,30 +95,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: a0f161b92610e078b4962d7e6ebeb66dc9cce0ada3514aeee442f68165d78185 - url: "https://pub.dev" - source: hosted - version: "4.17.5" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: "6a55b319f8d33c307396b9104512e8130a61904528ab7bd8b5402678fca54b81" - url: "https://pub.dev" - source: hosted - version: "6.2.5" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: "89dfa1304d3da48b3039abbb2865e3d30896ef858e569a16804a99f4362283a9" - url: "https://pub.dev" - source: hosted - version: "3.12.5" collection: dependency: transitive description: @@ -255,102 +231,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: cfc2d970829202eca09e2896f0a5aa7c87302817ecc0bdfa954f026046bf10ba - url: "https://pub.dev" - source: hosted - version: "4.20.0" - firebase_auth_platform_interface: - dependency: transitive - description: - name: firebase_auth_platform_interface - sha256: a0270e1db3b2098a14cb2a2342b3cd2e7e458e0c391b1f64f6f78b14296ec093 - url: "https://pub.dev" - source: hosted - version: "7.3.0" - firebase_auth_web: - dependency: transitive - description: - name: firebase_auth_web - sha256: "64e067e763c6378b7e774e872f0f59f6812885e43020e25cde08f42e9459837b" - url: "https://pub.dev" - source: hosted - version: "5.12.0" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c" + sha256: "06537da27db981947fa535bb91ca120b4e9cb59cb87278dbdde718558cafc9ff" url: "https://pub.dev" source: hosted - version: "2.32.0" + version: "3.4.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + sha256: "8bcfad6d7033f5ea951d15b867622a824b13812178bfec0c779b9d81de011bbb" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.4.2" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "43d9e951ac52b87ae9cc38ecdcca1e8fa7b52a1dd26a96085ba41ce5108db8e9" + sha256: "362e52457ed2b7b180964769c1e04d1e0ea0259fdf7025fdfedd019d4ae2bd88" url: "https://pub.dev" source: hosted - version: "2.17.0" + version: "2.17.5" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: a1662cc95d9750a324ad9df349b873360af6f11414902021f130c68ec02267c4 + sha256: "29941ba5a3204d80656c0e52103369aa9a53edfd9ceae05a2bb3376f24fda453" url: "https://pub.dev" source: hosted - version: "14.9.4" + version: "15.1.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147" + sha256: "26c5370d3a79b15c8032724a68a4741e28f63e1f1a45699c4f0a8ae740aadd72" url: "https://pub.dev" source: hosted - version: "4.5.37" + version: "4.5.43" firebase_messaging_web: - dependency: "direct main" + dependency: transitive description: name: firebase_messaging_web - sha256: "0d34dca01a7b103ed7f20138bffbb28eb0e61a677bf9e78a028a932e2c7322d5" + sha256: "58276cd5d9e22a9320ef9e5bc358628920f770f93c91221f8b638e8346ed5df4" url: "https://pub.dev" source: hosted - version: "3.8.7" - firebase_storage: - dependency: transitive - description: - name: firebase_storage - sha256: "2ae478ceec9f458c1bcbf0ee3e0100e4e909708979e83f16d5d9fba35a5b42c1" - url: "https://pub.dev" - source: hosted - version: "11.7.7" - firebase_storage_platform_interface: - dependency: transitive - description: - name: firebase_storage_platform_interface - sha256: "4e18662e6a66e2e0e181c06f94707de06d5097d70cfe2b5141bf64660c5b5da9" - url: "https://pub.dev" - source: hosted - version: "5.1.22" - firebase_storage_web: - dependency: "direct main" - description: - name: firebase_storage_web - sha256: "3a44aacd38a372efb159f6fe36bb4a7d79823949383816457fd43d3d47602a53" - url: "https://pub.dev" - source: hosted - version: "3.9.7" + version: "3.8.13" fixnum: dependency: transitive description: @@ -474,7 +402,7 @@ packages: source: hosted version: "0.1.2" flutter_test: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -571,14 +499,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.3" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "9482364c9f8b7bd36902572ebc3a7c2b5c8ee57a9c93e6eb5099c1a9ec5265d8" - url: "https://pub.dev" - source: hosted - version: "0.3.1+1" google_maps: dependency: transitive description: @@ -627,46 +547,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.7" - google_sign_in: - dependency: "direct main" - description: - name: google_sign_in - sha256: "0b8787cb9c1a68ad398e8010e8c8766bfa33556d2ab97c439fb4137756d7308f" - url: "https://pub.dev" - source: hosted - version: "6.2.1" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "7647893c65e6720973f0e579051c8f84b877b486614d9f70a404259c41a4632e" - url: "https://pub.dev" - source: hosted - version: "6.1.23" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: a058c9880be456f21e2e8571c1126eaacd570bdc5b6c6d9d15aea4bdf22ca9fe - url: "https://pub.dev" - source: hosted - version: "5.7.6" - google_sign_in_platform_interface: - dependency: transitive - description: - name: google_sign_in_platform_interface - sha256: "1f6e5787d7a120cc0359ddf315c92309069171306242e181c09472d1b00a2971" - url: "https://pub.dev" - source: hosted - version: "2.4.5" - google_sign_in_web: - dependency: transitive - description: - name: google_sign_in_web - sha256: fc0f14ed45ea616a6cfb4d1c7534c2221b7092cc4f29a709f0c3053cc3e821bd - url: "https://pub.dev" - source: hosted - version: "0.12.4" html: dependency: transitive description: @@ -799,18 +679,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -863,18 +743,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: transitive description: @@ -1163,6 +1043,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + suggestion_repository: + dependency: "direct main" + description: + path: "packages/suggestion_repository" + relative: true + source: path + version: "1.0.0+1" table_calendar: dependency: "direct main" description: @@ -1183,10 +1070,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" timezone: dependency: transitive description: @@ -1310,10 +1197,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ed3d690..24792d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: "none" version: 1.0.14+14 environment: - sdk: ">=2.19.3 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: animate_do: ^3.0.2 @@ -60,6 +60,8 @@ dependencies: path: packages/setting_repository shared_preferences: ^2.0.10 shimmer: ^3.0.0 + suggestion_repository: + path: packages/suggestion_repository table_calendar: ^3.1.1 universal_html: ^2.2.3 url_launcher: ^6.1.10 @@ -70,6 +72,8 @@ dependencies: dev_dependencies: flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter flutter: uses-material-design: true diff --git a/test/time_slots_test.dart b/test/time_slots_test.dart new file mode 100644 index 0000000..9b7df89 --- /dev/null +++ b/test/time_slots_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prosappco/utils/time_of_day_extension.dart'; +import 'package:prosappco/utils/time_of_day_utils.dart'; + +void main() { + group('TimeOfDayExtension.add', () { + test('carries minutes into hours', () { + expect(const TimeOfDay(hour: 8, minute: 45).add(minute: 45), + const TimeOfDay(hour: 9, minute: 30)); + }); + + test('does not wrap past midnight, so range loops still terminate', () { + expect(const TimeOfDay(hour: 23, minute: 30).add(minute: 45).hour, 24); + }); + }); + + group('TimeOfDayUtils.genRanges', () { + // Regression: a minute-based step used to leave the hour untouched + // ("8:90"), so isBefore() never became false and this looped forever, + // freezing the calendar screen. + test('minute-based step terminates and lands on real times', () { + final slots = TimeOfDayUtils.genRanges( + const TimeOfDay(hour: 8, minute: 0), + const TimeOfDay(hour: 18, minute: 0), + stepMinutes: 45, + ); + + expect(slots.length, 14); + expect(slots.first, const TimeOfDay(hour: 8, minute: 0)); + expect(slots[1], const TimeOfDay(hour: 8, minute: 45)); + expect(slots[2], const TimeOfDay(hour: 9, minute: 30)); + expect(slots.last, const TimeOfDay(hour: 17, minute: 45)); + expect(slots.every((t) => t.minute < 60), isTrue); + }); + + test('two-hour step keeps the historical behaviour', () { + final slots = TimeOfDayUtils.genRanges( + const TimeOfDay(hour: 8, minute: 0), + const TimeOfDay(hour: 18, minute: 0), + stepMinutes: 120, + ); + + expect(slots.map((t) => t.hour).toList(), [8, 10, 12, 14, 16]); + }); + + test('end before start yields no slots', () { + final slots = TimeOfDayUtils.genRanges( + const TimeOfDay(hour: 18, minute: 0), + const TimeOfDay(hour: 8, minute: 0), + stepMinutes: 45, + ); + + expect(slots, isEmpty); + }); + }); +}